mirror of
https://github.com/johrpan/musicus.git
synced 2025-10-26 11:47:25 +01:00
Add support for importing an audio CD
This commit is contained in:
parent
e2d36a88b8
commit
1bc79765be
21 changed files with 1190 additions and 203 deletions
|
|
@ -7,6 +7,7 @@ edition = "2018"
|
|||
anyhow = "1.0.33"
|
||||
diesel = { version = "1.4.5", features = ["sqlite"] }
|
||||
diesel_migrations = "1.4.0"
|
||||
discid = "0.4.4"
|
||||
fragile = "1.0.0"
|
||||
futures = "0.3.6"
|
||||
futures-channel = "0.3.5"
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
DROP TABLE persons;
|
||||
PRAGMA defer_foreign_keys;
|
||||
|
||||
DROP TABLE instruments;
|
||||
DROP TABLE "persons";
|
||||
DROP TABLE "instruments";
|
||||
DROP TABLE "works";
|
||||
DROP TABLE "instrumentations";
|
||||
DROP TABLE "work_parts";
|
||||
DROP TABLE "work_sections";
|
||||
DROP TABLE "ensembles";
|
||||
DROP TABLE "recordings";
|
||||
DROP TABLE "performances";
|
||||
DROP TABLE "mediums";
|
||||
DROP TABLE "track_sets";
|
||||
DROP TABLE "tracks";
|
||||
DROP TABLE "files";
|
||||
|
||||
DROP TABLE works;
|
||||
|
||||
DROP TABLE instrumentations;
|
||||
|
||||
DROP TABLE work_parts;
|
||||
|
||||
DROP TABLE work_sections;
|
||||
|
||||
DROP TABLE ensembles;
|
||||
|
||||
DROP TABLE recordings;
|
||||
|
||||
DROP TABLE performances;
|
||||
|
||||
DROP TABLE tracks;
|
||||
|
|
@ -1,64 +1,82 @@
|
|||
CREATE TABLE persons (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL
|
||||
CREATE TABLE "persons" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"first_name" TEXT NOT NULL,
|
||||
"last_name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE instruments (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
CREATE TABLE "instruments" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE works (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
composer TEXT NOT NULL REFERENCES persons(id),
|
||||
title TEXT NOT NULL
|
||||
CREATE TABLE "works" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"composer" TEXT NOT NULL REFERENCES "persons"("id"),
|
||||
"title" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE instrumentations (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
|
||||
instrument TEXT NOT NULL REFERENCES instruments(id) ON DELETE CASCADE
|
||||
CREATE TABLE "instrumentations" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
|
||||
"instrument" TEXT NOT NULL REFERENCES "instruments"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE work_parts (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
|
||||
part_index BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
composer TEXT REFERENCES persons(id)
|
||||
CREATE TABLE "work_parts" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
|
||||
"part_index" BIGINT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"composer" TEXT REFERENCES "persons"("id")
|
||||
);
|
||||
|
||||
CREATE TABLE work_sections (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
before_index BIGINT NOT NULL
|
||||
CREATE TABLE "work_sections" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
|
||||
"title" TEXT NOT NULL,
|
||||
"before_index" BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ensembles (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
CREATE TABLE "ensembles" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE recordings (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
work TEXT NOT NULL REFERENCES works(id),
|
||||
comment TEXT NOT NULL
|
||||
CREATE TABLE "recordings" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"work" TEXT NOT NULL REFERENCES "works"("id"),
|
||||
"comment" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE performances (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
recording TEXT NOT NULL REFERENCES recordings(id) ON DELETE CASCADE,
|
||||
person TEXT REFERENCES persons(id),
|
||||
ensemble TEXT REFERENCES ensembles(id),
|
||||
role TEXT REFERENCES instruments(id)
|
||||
CREATE TABLE "performances" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"recording" TEXT NOT NULL REFERENCES "recordings"("id") ON DELETE CASCADE,
|
||||
"person" TEXT REFERENCES "persons"("id"),
|
||||
"ensemble" TEXT REFERENCES "ensembles"("id"),
|
||||
"role" TEXT REFERENCES "instruments"("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "mediums" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"discid" TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE "track_sets" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"medium" TEXT NOT NULL REFERENCES "mediums"("id") ON DELETE CASCADE,
|
||||
"index" INTEGER NOT NULL,
|
||||
"recording" TEXT NOT NULL REFERENCES "recordings"("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "tracks" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"track_set" TEXT NOT NULL REFERENCES "track_sets"("id") ON DELETE CASCADE,
|
||||
"index" INTEGER NOT NULL,
|
||||
"work_parts" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "files" (
|
||||
"file_name" TEXT NOT NULL PRIMARY KEY,
|
||||
"track" TEXT NOT NULL REFERENCES "tracks"("id")
|
||||
);
|
||||
|
||||
CREATE TABLE tracks (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
file_name TEXT NOT NULL,
|
||||
recording TEXT NOT NULL REFERENCES recordings(id),
|
||||
track_index INTEGER NOT NULL,
|
||||
work_parts TEXT NOT NULL
|
||||
);
|
||||
|
|
@ -2,8 +2,13 @@
|
|||
<gresources>
|
||||
<gresource prefix="/de/johrpan/musicus">
|
||||
<file preprocess="xml-stripblanks">ui/ensemble_editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/ensemble_selector.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/ensemble_screen.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/ensemble_selector.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/import_disc_dialog.ui</file>
|
||||
<<<<<<< HEAD
|
||||
<file preprocess="xml-stripblanks">ui/import_folder_dialog.ui</file>
|
||||
=======
|
||||
>>>>>>> wip/cd-ripping-old
|
||||
<file preprocess="xml-stripblanks">ui/instrument_editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/instrument_selector.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/login_dialog.ui</file>
|
||||
|
|
|
|||
247
musicus/res/ui/import_disc_dialog.ui
Normal file
247
musicus/res/ui/import_disc_dialog.ui
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.38.1 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.24"/>
|
||||
<requires lib="libhandy" version="1.0"/>
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="HdyHeaderBar">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="title" translatable="yes">Import CD</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStack" id="stack">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="transition-type">crossfade</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkInfoBar" id="info_bar">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="message-type">error</property>
|
||||
<property name="revealed">False</property>
|
||||
<child internal-child="action_area">
|
||||
<object class="GtkButtonBox">
|
||||
<property name="can-focus">False</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="layout-style">end</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child internal-child="content_area">
|
||||
<object class="GtkBox">
|
||||
<property name="can-focus">False</property>
|
||||
<property name="spacing">16</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="label" translatable="yes">Failed to load the CD. Make sure you have inserted it into your drive.</property>
|
||||
<property name="wrap">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="border-width">18</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">18</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="opacity">0.5019607843137255</property>
|
||||
<property name="pixel-size">80</property>
|
||||
<property name="icon-name">media-optical-cd-audio-symbolic</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="opacity">0.5019607843137255</property>
|
||||
<property name="label" translatable="yes">Import from audio CD</property>
|
||||
<attributes>
|
||||
<attribute name="size" value="16384"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="opacity">0.5019607843137255</property>
|
||||
<property name="label" translatable="yes">Insert an audio compact disc into your drive and click the button below. The disc will be copied in the background while you set up the metadata.</property>
|
||||
<property name="justify">center</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="max-width-chars">40</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="import_button">
|
||||
<property name="label" translatable="yes">Import</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<property name="halign">center</property>
|
||||
<style>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="name">start</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="active">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="name">loading</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<child>
|
||||
<object class="GtkViewport">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="shadow-type">none</property>
|
||||
<child>
|
||||
<object class="HdyClamp">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="maximum-size">500</property>
|
||||
<property name="tightening-threshold">300</property>
|
||||
<child>
|
||||
<object class="GtkFrame" id="frame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="label-xalign">0</property>
|
||||
<property name="shadow-type">in</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child type="label_item">
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="name">content</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
117
musicus/res/ui/import_folder_dialg.ui
Normal file
117
musicus/res/ui/import_folder_dialg.ui
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.38.2 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.24"/>
|
||||
<requires lib="libhandy" version="1.0"/>
|
||||
<object class="GtkBox" id="widget">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="HdyHeaderBar">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="title" translatable="yes">Import folder</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="back_button">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="vexpand">True</property>
|
||||
<property name="border-width">18</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">18</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="opacity">0.50196078431372548</property>
|
||||
<property name="pixel-size">80</property>
|
||||
<property name="icon-name">folder-symbolic</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="opacity">0.50196078431372548</property>
|
||||
<property name="label" translatable="yes">Import from a folder</property>
|
||||
<attributes>
|
||||
<attribute name="size" value="16384"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="opacity">0.50196078431372548</property>
|
||||
<property name="label" translatable="yes">Select a folder containing audio files with the button below. After adding the metdata in the next step, the folder will be copied to your music library.</property>
|
||||
<property name="justify">center</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="max-width-chars">40</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="import_button">
|
||||
<property name="label" translatable="yes">Select</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<property name="halign">center</property>
|
||||
<style>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -327,6 +327,10 @@
|
|||
</object>
|
||||
<menu id="menu">
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Import CD</attribute>
|
||||
<attribute name="action">win.import-disc</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Preferences</attribute>
|
||||
<attribute name="action">win.preferences</attribute>
|
||||
|
|
|
|||
54
musicus/src/database/files.rs
Normal file
54
musicus/src/database/files.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use super::schema::files;
|
||||
use super::Database;
|
||||
use anyhow::Result;
|
||||
use diesel::prelude::*;
|
||||
|
||||
/// Table data to associate audio files with tracks.
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[table_name = "files"]
|
||||
struct FileRow {
|
||||
pub file_name: String,
|
||||
pub track: String,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Insert or update a file. This assumes that the track is already in the
|
||||
/// database.
|
||||
pub fn update_file(&self, file_name: &str, track_id: &str) -> Result<()> {
|
||||
let row = FileRow {
|
||||
file_name: file_name.to_owned(),
|
||||
track: track_id.to_owned(),
|
||||
};
|
||||
|
||||
diesel::insert_into(files::table)
|
||||
.values(row)
|
||||
.execute(&self.connection)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an existing file. This will not delete the file from the file
|
||||
/// system but just the representing row from the database.
|
||||
pub fn delete_file(&self, file_name: &str) -> Result<()> {
|
||||
diesel::delete(files::table.filter(files::file_name.eq(file_name)))
|
||||
.execute(&self.connection)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the file name of the audio file for the specified track.
|
||||
pub fn get_file(&self, track_id: &str) -> Result<Option<String>> {
|
||||
let row = files::table
|
||||
.filter(files::track.eq(track_id))
|
||||
.load::<FileRow>(&self.connection)?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
let file_name = match row {
|
||||
Some(row) => Some(row.file_name),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(file_name)
|
||||
}
|
||||
}
|
||||
187
musicus/src/database/medium.rs
Normal file
187
musicus/src/database/medium.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
use super::generate_id;
|
||||
use super::schema::{mediums, track_sets, tracks};
|
||||
use super::{Database, Recording};
|
||||
use anyhow::{anyhow, Error, Result};
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Representation of someting like a physical audio disc or a folder with
|
||||
/// audio files (i.e. a collection of tracks for one or more recordings).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Medium {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub discid: Option<String>,
|
||||
pub tracks: Vec<TrackSet>,
|
||||
}
|
||||
|
||||
/// A set of tracks of one recording within a medium.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackSet {
|
||||
pub recording: Recording,
|
||||
pub tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
/// A track within a recording on a medium.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Track {
|
||||
work_parts: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Table data for a [`Medium`].
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[table_name = "mediums"]
|
||||
struct MediumRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub discid: Option<String>,
|
||||
}
|
||||
|
||||
/// Table data for a [`TrackSet`].
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[table_name = "track_sets"]
|
||||
struct TrackSetRow {
|
||||
pub id: String,
|
||||
pub medium: String,
|
||||
pub index: i32,
|
||||
pub recording: String,
|
||||
}
|
||||
|
||||
/// Table data for a [`Track`].
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[table_name = "tracks"]
|
||||
struct TrackRow {
|
||||
pub id: String,
|
||||
pub track_set: String,
|
||||
pub index: i32,
|
||||
pub work_parts: String,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Update an existing medium or insert a new one.
|
||||
pub fn update_medium(&self, medium: Medium) -> Result<()> {
|
||||
self.defer_foreign_keys()?;
|
||||
|
||||
self.connection.transaction::<(), Error, _>(|| {
|
||||
let medium_id = &medium.id;
|
||||
|
||||
// This will also delete the track sets and tracks.
|
||||
self.delete_medium(medium_id)?;
|
||||
|
||||
for (index, track_set) in medium.tracks.iter().enumerate() {
|
||||
let track_set_id = generate_id();
|
||||
|
||||
let track_set_row = TrackSetRow {
|
||||
id: track_set_id.clone(),
|
||||
medium: medium_id.to_owned(),
|
||||
index: index as i32,
|
||||
recording: track_set.recording.id.clone(),
|
||||
};
|
||||
|
||||
diesel::insert_into(track_sets::table)
|
||||
.values(track_set_row)
|
||||
.execute(&self.connection)?;
|
||||
|
||||
for (index, track) in track_set.tracks.iter().enumerate() {
|
||||
let work_parts = track
|
||||
.work_parts
|
||||
.iter()
|
||||
.map(|part_index| part_index.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
let track_row = TrackRow {
|
||||
id: generate_id(),
|
||||
track_set: track_set_id.clone(),
|
||||
index: index as i32,
|
||||
work_parts,
|
||||
};
|
||||
|
||||
diesel::insert_into(tracks::table)
|
||||
.values(track_row)
|
||||
.execute(&self.connection)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get an existing medium.
|
||||
pub fn get_medium(&self, id: &str) -> Result<Option<Medium>> {
|
||||
let row = mediums::table
|
||||
.filter(mediums::id.eq(id))
|
||||
.load::<MediumRow>(&self.connection)?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
let medium = match row {
|
||||
Some(row) => Some(self.get_medium_data(row)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(medium)
|
||||
}
|
||||
|
||||
/// Delete a medium and all of its tracks. This will fail, if the music
|
||||
/// library contains audio files referencing any of those tracks.
|
||||
pub fn delete_medium(&self, id: &str) -> Result<()> {
|
||||
diesel::delete(mediums::table.filter(mediums::id.eq(id))).execute(&self.connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve all available information on a medium from related tables.
|
||||
fn get_medium_data(&self, row: MediumRow) -> Result<Medium> {
|
||||
let track_set_rows = track_sets::table
|
||||
.filter(track_sets::medium.eq(&row.id))
|
||||
.order_by(track_sets::index)
|
||||
.load::<TrackSetRow>(&self.connection)?;
|
||||
|
||||
let mut track_sets = Vec::new();
|
||||
|
||||
for track_set_row in track_set_rows {
|
||||
let recording_id = &track_set_row.recording;
|
||||
|
||||
let recording = self
|
||||
.get_recording(recording_id)?
|
||||
.ok_or_else(|| anyhow!("No recording with ID: {}", recording_id))?;
|
||||
|
||||
let track_rows = tracks::table
|
||||
.filter(tracks::id.eq(&track_set_row.id))
|
||||
.order_by(tracks::index)
|
||||
.load::<TrackRow>(&self.connection)?;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
|
||||
for track_row in track_rows {
|
||||
let work_parts = track_row
|
||||
.work_parts
|
||||
.split(',')
|
||||
.map(|part_index| Ok(str::parse(part_index)?))
|
||||
.collect::<Result<Vec<usize>>>()?;
|
||||
|
||||
let track = Track { work_parts };
|
||||
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
let track_set = TrackSet { recording, tracks };
|
||||
|
||||
track_sets.push(track_set);
|
||||
}
|
||||
|
||||
let medium = Medium {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
discid: row.discid,
|
||||
tracks: track_sets,
|
||||
};
|
||||
|
||||
Ok(medium)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,9 @@ pub use ensembles::*;
|
|||
pub mod instruments;
|
||||
pub use instruments::*;
|
||||
|
||||
pub mod medium;
|
||||
pub use medium::*;
|
||||
|
||||
pub mod persons;
|
||||
pub use persons::*;
|
||||
|
||||
|
|
@ -16,8 +19,8 @@ pub use recordings::*;
|
|||
pub mod thread;
|
||||
pub use thread::*;
|
||||
|
||||
pub mod tracks;
|
||||
pub use tracks::*;
|
||||
pub mod files;
|
||||
pub use files::*;
|
||||
|
||||
pub mod works;
|
||||
pub use works::*;
|
||||
|
|
|
|||
|
|
@ -190,6 +190,22 @@ impl Database {
|
|||
Ok(exists)
|
||||
}
|
||||
|
||||
/// Get an existing recording.
|
||||
pub fn get_recording(&self, id: &str) -> Result<Option<Recording>> {
|
||||
let row = recordings::table
|
||||
.filter(recordings::id.eq(id))
|
||||
.load::<RecordingRow>(&self.connection)?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
let recording = match row {
|
||||
Some(row) => Some(self.get_recording_data(row)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(recording)
|
||||
}
|
||||
|
||||
/// Retrieve all available information on a recording from related tables.
|
||||
fn get_recording_data(&self, row: RecordingRow) -> Result<Recording> {
|
||||
let mut performance_descriptions: Vec<Performance> = Vec::new();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
files (file_name) {
|
||||
file_name -> Text,
|
||||
track -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
instrumentations (id) {
|
||||
id -> BigInt,
|
||||
|
|
@ -20,6 +27,14 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
mediums (id) {
|
||||
id -> Text,
|
||||
name -> Text,
|
||||
discid -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
performances (id) {
|
||||
id -> BigInt,
|
||||
|
|
@ -47,11 +62,19 @@ table! {
|
|||
}
|
||||
|
||||
table! {
|
||||
tracks (id) {
|
||||
id -> BigInt,
|
||||
file_name -> Text,
|
||||
track_sets (id) {
|
||||
id -> Text,
|
||||
medium -> Text,
|
||||
index -> Integer,
|
||||
recording -> Text,
|
||||
track_index -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
tracks (id) {
|
||||
id -> Text,
|
||||
track_set -> Text,
|
||||
index -> Integer,
|
||||
work_parts -> Text,
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +106,7 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
joinable!(files -> tracks (track));
|
||||
joinable!(instrumentations -> instruments (instrument));
|
||||
joinable!(instrumentations -> works (work));
|
||||
joinable!(performances -> ensembles (ensemble));
|
||||
|
|
@ -90,7 +114,9 @@ joinable!(performances -> instruments (role));
|
|||
joinable!(performances -> persons (person));
|
||||
joinable!(performances -> recordings (recording));
|
||||
joinable!(recordings -> works (work));
|
||||
joinable!(tracks -> recordings (recording));
|
||||
joinable!(track_sets -> mediums (medium));
|
||||
joinable!(track_sets -> recordings (recording));
|
||||
joinable!(tracks -> track_sets (track_set));
|
||||
joinable!(work_parts -> persons (composer));
|
||||
joinable!(work_parts -> works (work));
|
||||
joinable!(work_sections -> works (work));
|
||||
|
|
@ -98,11 +124,14 @@ joinable!(works -> persons (composer));
|
|||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
ensembles,
|
||||
files,
|
||||
instrumentations,
|
||||
instruments,
|
||||
mediums,
|
||||
performances,
|
||||
persons,
|
||||
recordings,
|
||||
track_sets,
|
||||
tracks,
|
||||
work_parts,
|
||||
work_sections,
|
||||
|
|
|
|||
|
|
@ -28,9 +28,12 @@ enum Action {
|
|||
GetRecordingsForEnsemble(String, Sender<Result<Vec<Recording>>>),
|
||||
GetRecordingsForWork(String, Sender<Result<Vec<Recording>>>),
|
||||
RecordingExists(String, Sender<Result<bool>>),
|
||||
UpdateTracks(String, Vec<Track>, Sender<Result<()>>),
|
||||
DeleteTracks(String, Sender<Result<()>>),
|
||||
GetTracks(String, Sender<Result<Vec<Track>>>),
|
||||
UpdateMedium(Medium, Sender<Result<()>>),
|
||||
GetMedium(String, Sender<Result<Option<Medium>>>),
|
||||
DeleteMedium(String, Sender<Result<()>>),
|
||||
UpdateFile(String, String, Sender<Result<()>>),
|
||||
DeleteFile(String, Sender<Result<()>>),
|
||||
GetFile(String, Sender<Result<Option<String>>>),
|
||||
Stop(Sender<()>),
|
||||
}
|
||||
|
||||
|
|
@ -124,16 +127,23 @@ impl DbThread {
|
|||
RecordingExists(id, sender) => {
|
||||
sender.send(db.recording_exists(&id)).unwrap();
|
||||
}
|
||||
UpdateTracks(recording_id, tracks, sender) => {
|
||||
sender
|
||||
.send(db.update_tracks(&recording_id, tracks))
|
||||
.unwrap();
|
||||
UpdateMedium(medium, sender) => {
|
||||
sender.send(db.update_medium(medium)).unwrap();
|
||||
}
|
||||
DeleteTracks(recording_id, sender) => {
|
||||
sender.send(db.delete_tracks(&recording_id)).unwrap();
|
||||
GetMedium(id, sender) => {
|
||||
sender.send(db.get_medium(&id)).unwrap();
|
||||
}
|
||||
GetTracks(recording_id, sender) => {
|
||||
sender.send(db.get_tracks(&recording_id)).unwrap();
|
||||
DeleteMedium(id, sender) => {
|
||||
sender.send(db.delete_medium(&id)).unwrap();
|
||||
}
|
||||
UpdateFile(file_name, track_id, sender) => {
|
||||
sender.send(db.update_file(&file_name, &track_id)).unwrap();
|
||||
}
|
||||
DeleteFile(file_name, sender) => {
|
||||
sender.send(db.delete_file(&file_name)).unwrap();
|
||||
}
|
||||
GetFile(track_id, sender) => {
|
||||
sender.send(db.get_file(&track_id)).unwrap();
|
||||
}
|
||||
Stop(sender) => {
|
||||
sender.send(()).unwrap();
|
||||
|
|
@ -312,28 +322,63 @@ impl DbThread {
|
|||
receiver.await?
|
||||
}
|
||||
|
||||
/// Add or change the tracks associated with a recording. This will fail, if there are still
|
||||
/// other items referencing this recording.
|
||||
pub async fn update_tracks(&self, recording_id: &str, tracks: Vec<Track>) -> Result<()> {
|
||||
/// Update an existing medium or insert a new one.
|
||||
pub async fn update_medium(&self, medium: Medium) -> Result<()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.action_sender
|
||||
.send(UpdateTracks(recording_id.to_string(), tracks, sender))?;
|
||||
self.action_sender.send(UpdateMedium(medium, sender))?;
|
||||
receiver.await?
|
||||
}
|
||||
|
||||
/// Delete all tracks associated with a recording.
|
||||
pub async fn delete_tracks(&self, recording_id: &str) -> Result<()> {
|
||||
/// Delete an existing medium. This will fail, if there are still other
|
||||
/// items referencing this medium.
|
||||
pub async fn delete_medium(&self, id: &str) -> Result<()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
self.action_sender
|
||||
.send(DeleteTracks(recording_id.to_string(), sender))?;
|
||||
.send(DeleteMedium(id.to_owned(), sender))?;
|
||||
|
||||
receiver.await?
|
||||
}
|
||||
|
||||
/// Get all tracks associated with a recording.
|
||||
pub async fn get_tracks(&self, recording_id: &str) -> Result<Vec<Track>> {
|
||||
/// Get an existing medium.
|
||||
pub async fn get_medium(&self, id: &str) -> Result<Option<Medium>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.action_sender.send(GetMedium(id.to_owned(), sender))?;
|
||||
receiver.await?
|
||||
}
|
||||
|
||||
/// Insert or update a file. This assumes that the track is already in the
|
||||
/// database.
|
||||
pub async fn update_file(&self, file_name: &str, track_id: &str) -> Result<()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
self.action_sender.send(UpdateFile(
|
||||
file_name.to_owned(),
|
||||
track_id.to_owned(),
|
||||
sender,
|
||||
))?;
|
||||
|
||||
receiver.await?
|
||||
}
|
||||
|
||||
/// Delete an existing file. This will not delete the file from the file
|
||||
/// system but just the representing row from the database.
|
||||
pub async fn delete_file(&self, file_name: &str) -> Result<()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
self.action_sender
|
||||
.send(GetTracks(recording_id.to_string(), sender))?;
|
||||
.send(DeleteFile(file_name.to_owned(), sender))?;
|
||||
|
||||
receiver.await?
|
||||
}
|
||||
|
||||
/// Get the file name of the audio file for the specified track.
|
||||
pub async fn get_file(&self, track_id: &str) -> Result<Option<String>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
self.action_sender
|
||||
.send(GetFile(track_id.to_owned(), sender))?;
|
||||
|
||||
receiver.await?
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
use super::schema::tracks;
|
||||
use super::Database;
|
||||
use anyhow::{Error, Result};
|
||||
use diesel::prelude::*;
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
/// Table row data for a track.
|
||||
#[derive(Insertable, Queryable, Debug, Clone)]
|
||||
#[table_name = "tracks"]
|
||||
struct TrackRow {
|
||||
pub id: i64,
|
||||
pub file_name: String,
|
||||
pub recording: String,
|
||||
pub track_index: i32,
|
||||
pub work_parts: String,
|
||||
}
|
||||
|
||||
/// A structure representing one playable audio file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Track {
|
||||
pub work_parts: Vec<usize>,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
impl TryFrom<TrackRow> for Track {
|
||||
type Error = Error;
|
||||
fn try_from(row: TrackRow) -> Result<Self> {
|
||||
let mut work_parts = Vec::<usize>::new();
|
||||
for part in row.work_parts.split(",") {
|
||||
if !part.is_empty() {
|
||||
work_parts.push(part.parse()?);
|
||||
}
|
||||
}
|
||||
|
||||
let track = Track {
|
||||
work_parts,
|
||||
file_name: row.file_name,
|
||||
};
|
||||
|
||||
Ok(track)
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Insert or update tracks for the specified recording.
|
||||
pub fn update_tracks(&self, recording_id: &str, tracks: Vec<Track>) -> Result<()> {
|
||||
self.delete_tracks(recording_id)?;
|
||||
|
||||
for (index, track) in tracks.iter().enumerate() {
|
||||
let row = TrackRow {
|
||||
id: rand::random(),
|
||||
file_name: track.file_name.clone(),
|
||||
recording: recording_id.to_string(),
|
||||
track_index: index.try_into()?,
|
||||
work_parts: track
|
||||
.work_parts
|
||||
.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(","),
|
||||
};
|
||||
|
||||
diesel::insert_into(tracks::table)
|
||||
.values(row)
|
||||
.execute(&self.connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all tracks for the specified recording.
|
||||
pub fn delete_tracks(&self, recording_id: &str) -> Result<()> {
|
||||
diesel::delete(tracks::table.filter(tracks::recording.eq(recording_id)))
|
||||
.execute(&self.connection)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all tracks of the specified recording.
|
||||
pub fn get_tracks(&self, recording_id: &str) -> Result<Vec<Track>> {
|
||||
let mut tracks = Vec::<Track>::new();
|
||||
|
||||
let rows = tracks::table
|
||||
.filter(tracks::recording.eq(recording_id))
|
||||
.order_by(tracks::track_index)
|
||||
.load::<TrackRow>(&self.connection)?;
|
||||
|
||||
for row in rows {
|
||||
tracks.push(row.try_into()?);
|
||||
}
|
||||
|
||||
Ok(tracks)
|
||||
}
|
||||
}
|
||||
211
musicus/src/dialogs/import_disc.rs
Normal file
211
musicus/src/dialogs/import_disc.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
use crate::backend::Backend;
|
||||
use crate::ripper::Ripper;
|
||||
use crate::widgets::{List, Navigator, NavigatorScreen};
|
||||
use anyhow::Result;
|
||||
use glib::clone;
|
||||
use gtk::prelude::*;
|
||||
use gtk_macros::get_widget;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The current status of a ripped track.
|
||||
#[derive(Debug, Clone)]
|
||||
enum RipStatus {
|
||||
None,
|
||||
Ripping,
|
||||
Ready,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Representation of a track on the ripped disc.
|
||||
#[derive(Debug, Clone)]
|
||||
struct RipTrack {
|
||||
pub status: RipStatus,
|
||||
pub index: u32,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
}
|
||||
|
||||
/// A dialog for importing tracks from a CD.
|
||||
pub struct ImportDiscDialog {
|
||||
backend: Rc<Backend>,
|
||||
widget: gtk::Box,
|
||||
stack: gtk::Stack,
|
||||
info_bar: gtk::InfoBar,
|
||||
list: Rc<List<RipTrack>>,
|
||||
ripper: Ripper,
|
||||
tracks: RefCell<Vec<RipTrack>>,
|
||||
navigator: RefCell<Option<Rc<Navigator>>>,
|
||||
}
|
||||
|
||||
impl ImportDiscDialog {
|
||||
/// Create a new import disc dialog.
|
||||
pub fn new(backend: Rc<Backend>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/import_disc_dialog.ui");
|
||||
|
||||
get_widget!(builder, gtk::Box, widget);
|
||||
get_widget!(builder, gtk::Button, back_button);
|
||||
get_widget!(builder, gtk::Stack, stack);
|
||||
get_widget!(builder, gtk::InfoBar, info_bar);
|
||||
get_widget!(builder, gtk::Button, import_button);
|
||||
get_widget!(builder, gtk::Frame, frame);
|
||||
|
||||
let list = List::<RipTrack>::new("No tracks found.");
|
||||
frame.add(&list.widget);
|
||||
|
||||
let mut tmp_dir = glib::get_tmp_dir().unwrap();
|
||||
let dir_name = format!("musicus-{}", rand::random::<u64>());
|
||||
tmp_dir.push(dir_name);
|
||||
|
||||
std::fs::create_dir(&tmp_dir).unwrap();
|
||||
|
||||
let ripper = Ripper::new(tmp_dir.to_str().unwrap());
|
||||
|
||||
let this = Rc::new(Self {
|
||||
backend,
|
||||
widget,
|
||||
stack,
|
||||
info_bar,
|
||||
list,
|
||||
ripper,
|
||||
tracks: RefCell::new(Vec::new()),
|
||||
navigator: RefCell::new(None),
|
||||
});
|
||||
|
||||
// Connect signals and callbacks
|
||||
|
||||
back_button.connect_clicked(clone!(@strong this => move |_| {
|
||||
let navigator = this.navigator.borrow().clone();
|
||||
if let Some(navigator) = navigator {
|
||||
navigator.pop();
|
||||
}
|
||||
}));
|
||||
|
||||
import_button.connect_clicked(clone!(@strong this => move |_| {
|
||||
this.stack.set_visible_child_name("loading");
|
||||
|
||||
let context = glib::MainContext::default();
|
||||
let clone = this.clone();
|
||||
context.spawn_local(async move {
|
||||
match clone.ripper.load_disc().await {
|
||||
Ok(disc) => {
|
||||
let mut tracks = Vec::<RipTrack>::new();
|
||||
for track in disc.first_track..=disc.last_track {
|
||||
tracks.push(RipTrack {
|
||||
status: RipStatus::None,
|
||||
index: track,
|
||||
title: "Track".to_string(),
|
||||
subtitle: "Unknown".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
clone.tracks.replace(tracks.clone());
|
||||
clone.list.show_items(tracks);
|
||||
clone.stack.set_visible_child_name("content");
|
||||
|
||||
clone.rip().await.unwrap();
|
||||
}
|
||||
Err(_) => {
|
||||
clone.info_bar.set_revealed(true);
|
||||
clone.stack.set_visible_child_name("start");
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.list.set_make_widget(|track| {
|
||||
let title = gtk::Label::new(Some(&format!("{}. {}", track.index, track.title)));
|
||||
title.set_ellipsize(pango::EllipsizeMode::End);
|
||||
title.set_halign(gtk::Align::Start);
|
||||
|
||||
let subtitle = gtk::Label::new(Some(&track.subtitle));
|
||||
subtitle.set_ellipsize(pango::EllipsizeMode::End);
|
||||
subtitle.set_opacity(0.5);
|
||||
subtitle.set_halign(gtk::Align::Start);
|
||||
|
||||
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
vbox.add(&title);
|
||||
vbox.add(&subtitle);
|
||||
vbox.set_hexpand(true);
|
||||
|
||||
use RipStatus::*;
|
||||
|
||||
let status: gtk::Widget = match track.status {
|
||||
None => {
|
||||
let placeholder = gtk::Label::new(Option::None);
|
||||
placeholder.set_property_width_request(16);
|
||||
placeholder.upcast()
|
||||
}
|
||||
Ripping => {
|
||||
let spinner = gtk::Spinner::new();
|
||||
spinner.start();
|
||||
spinner.upcast()
|
||||
}
|
||||
Ready => gtk::Image::from_icon_name(
|
||||
Some("object-select-symbolic"),
|
||||
gtk::IconSize::Button,
|
||||
)
|
||||
.upcast(),
|
||||
Error => {
|
||||
gtk::Image::from_icon_name(Some("dialog-error-symbolic"), gtk::IconSize::Dialog)
|
||||
.upcast()
|
||||
}
|
||||
};
|
||||
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
hbox.set_border_width(6);
|
||||
hbox.add(&vbox);
|
||||
hbox.add(&status);
|
||||
|
||||
hbox.upcast()
|
||||
});
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
/// Rip the disc in the background.
|
||||
async fn rip(&self) -> Result<()> {
|
||||
let mut current_track = 0;
|
||||
|
||||
while current_track < self.tracks.borrow().len() {
|
||||
{
|
||||
let mut tracks = self.tracks.borrow_mut();
|
||||
let mut track = &mut tracks[current_track];
|
||||
track.status = RipStatus::Ripping;
|
||||
self.list.show_items(tracks.clone());
|
||||
}
|
||||
|
||||
self.ripper
|
||||
.rip_track(self.tracks.borrow()[current_track].index)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let mut tracks = self.tracks.borrow_mut();
|
||||
let mut track = &mut tracks[current_track];
|
||||
track.status = RipStatus::Ready;
|
||||
self.list.show_items(tracks.clone());
|
||||
}
|
||||
|
||||
current_track += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl NavigatorScreen for ImportDiscDialog {
|
||||
fn attach_navigator(&self, navigator: Rc<Navigator>) {
|
||||
self.navigator.replace(Some(navigator));
|
||||
}
|
||||
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast()
|
||||
}
|
||||
|
||||
fn detach_navigator(&self) {
|
||||
self.navigator.replace(None);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
pub mod import_disc;
|
||||
pub use import_disc::*;
|
||||
|
||||
pub mod about;
|
||||
pub use about::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use std::cell::RefCell;
|
|||
use std::rc::Rc;
|
||||
|
||||
mod backend;
|
||||
mod ripper;
|
||||
mod config;
|
||||
mod database;
|
||||
mod dialogs;
|
||||
|
|
@ -31,6 +32,7 @@ fn main() {
|
|||
gettextrs::bindtextdomain("musicus", config::LOCALEDIR);
|
||||
gettextrs::textdomain("musicus");
|
||||
|
||||
gstreamer::init().expect("Failed to initialize GStreamer!");
|
||||
gtk::init().expect("Failed to initialize GTK!");
|
||||
libhandy::init();
|
||||
resources::init().expect("Failed to initialize resources!");
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ sources = files(
|
|||
'database/tracks.rs',
|
||||
'database/works.rs',
|
||||
'dialogs/about.rs',
|
||||
'dialogs/import_disc.rs',
|
||||
'dialogs/login_dialog.rs',
|
||||
'dialogs/mod.rs',
|
||||
'dialogs/preferences.rs',
|
||||
|
|
@ -93,6 +94,7 @@ sources = files(
|
|||
'player.rs',
|
||||
'resources.rs',
|
||||
'resources.rs.in',
|
||||
'ripper.rs',
|
||||
'window.rs',
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ use std::rc::Rc;
|
|||
|
||||
#[derive(Clone)]
|
||||
pub struct PlaylistItem {
|
||||
pub recording: Recording,
|
||||
pub tracks: Vec<Track>,
|
||||
pub tracks: TrackSet,
|
||||
pub indices: Vec<usize>,
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
|
|
@ -19,11 +19,11 @@ pub struct Player {
|
|||
current_item: Cell<Option<usize>>,
|
||||
current_track: Cell<Option<usize>>,
|
||||
playing: Cell<bool>,
|
||||
playlist_cbs: RefCell<Vec<Box<dyn Fn(Vec<PlaylistItem>) -> ()>>>,
|
||||
track_cbs: RefCell<Vec<Box<dyn Fn(usize, usize) -> ()>>>,
|
||||
duration_cbs: RefCell<Vec<Box<dyn Fn(u64) -> ()>>>,
|
||||
playing_cbs: RefCell<Vec<Box<dyn Fn(bool) -> ()>>>,
|
||||
position_cbs: RefCell<Vec<Box<dyn Fn(u64) -> ()>>>,
|
||||
playlist_cbs: RefCell<Vec<Box<dyn Fn(Vec<PlaylistItem>)>>>,
|
||||
track_cbs: RefCell<Vec<Box<dyn Fn(usize, usize)>>>,
|
||||
duration_cbs: RefCell<Vec<Box<dyn Fn(u64)>>>,
|
||||
playing_cbs: RefCell<Vec<Box<dyn Fn(bool)>>>,
|
||||
position_cbs: RefCell<Vec<Box<dyn Fn(u64)>>>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
|
|
@ -80,23 +80,23 @@ impl Player {
|
|||
result
|
||||
}
|
||||
|
||||
pub fn add_playlist_cb<F: Fn(Vec<PlaylistItem>) -> () + 'static>(&self, cb: F) {
|
||||
pub fn add_playlist_cb<F: Fn(Vec<PlaylistItem>) + 'static>(&self, cb: F) {
|
||||
self.playlist_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn add_track_cb<F: Fn(usize, usize) -> () + 'static>(&self, cb: F) {
|
||||
pub fn add_track_cb<F: Fn(usize, usize) + 'static>(&self, cb: F) {
|
||||
self.track_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn add_duration_cb<F: Fn(u64) -> () + 'static>(&self, cb: F) {
|
||||
pub fn add_duration_cb<F: Fn(u64) + 'static>(&self, cb: F) {
|
||||
self.duration_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn add_playing_cb<F: Fn(bool) -> () + 'static>(&self, cb: F) {
|
||||
pub fn add_playing_cb<F: Fn(bool) + 'static>(&self, cb: F) {
|
||||
self.playing_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
pub fn add_position_cb<F: Fn(u64) -> () + 'static>(&self, cb: F) {
|
||||
pub fn add_position_cb<F: Fn(u64) + 'static>(&self, cb: F) {
|
||||
self.position_cbs.borrow_mut().push(Box::new(cb));
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ impl Player {
|
|||
}
|
||||
|
||||
pub fn add_item(&self, item: PlaylistItem) -> Result<()> {
|
||||
if item.tracks.is_empty() {
|
||||
if item.indices.is_empty() {
|
||||
Err(anyhow!(
|
||||
"Tried to add playlist item without tracks to playlist!"
|
||||
))
|
||||
|
|
@ -199,7 +199,7 @@ impl Player {
|
|||
current_track -= 1;
|
||||
} else if current_item > 0 {
|
||||
current_item -= 1;
|
||||
current_track = playlist[current_item].tracks.len() - 1;
|
||||
current_track = playlist[current_item].indices.len() - 1;
|
||||
} else {
|
||||
return Err(anyhow!("No previous track!"));
|
||||
}
|
||||
|
|
@ -213,7 +213,7 @@ impl Player {
|
|||
let playlist = self.playlist.borrow();
|
||||
let item = &playlist[current_item];
|
||||
|
||||
current_track + 1 < item.tracks.len() || current_item + 1 < playlist.len()
|
||||
current_track + 1 < item.indices.len() || current_item + 1 < playlist.len()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
|
@ -231,7 +231,7 @@ impl Player {
|
|||
|
||||
let playlist = self.playlist.borrow();
|
||||
let item = &playlist[current_item];
|
||||
if current_track + 1 < item.tracks.len() {
|
||||
if current_track + 1 < item.indices.len() {
|
||||
current_track += 1;
|
||||
} else if current_item + 1 < playlist.len() {
|
||||
current_item += 1;
|
||||
|
|
|
|||
130
musicus/src/ripper.rs
Normal file
130
musicus/src/ripper.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use anyhow::{anyhow, bail, Result};
|
||||
use discid::DiscId;
|
||||
use futures_channel::oneshot;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer::{Element, ElementFactory, Pipeline};
|
||||
use std::cell::RefCell;
|
||||
use std::thread;
|
||||
|
||||
/// A disc that can be ripped.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RipDisc {
|
||||
pub discid: String,
|
||||
pub first_track: u32,
|
||||
pub last_track: u32,
|
||||
}
|
||||
|
||||
/// An interface for ripping an audio compact disc.
|
||||
pub struct Ripper {
|
||||
path: String,
|
||||
disc: RefCell<Option<RipDisc>>,
|
||||
}
|
||||
|
||||
impl Ripper {
|
||||
/// Create a new ripper that stores its tracks within the specified folder.
|
||||
pub fn new(path: &str) -> Self {
|
||||
Self {
|
||||
path: path.to_string(),
|
||||
disc: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the disc and return its metadata.
|
||||
pub async fn load_disc(&self) -> Result<RipDisc> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
thread::spawn(|| {
|
||||
let disc = Self::load_disc_priv();
|
||||
sender.send(disc).unwrap();
|
||||
});
|
||||
|
||||
let disc = receiver.await??;
|
||||
self.disc.replace(Some(disc.clone()));
|
||||
|
||||
Ok(disc)
|
||||
}
|
||||
|
||||
/// Rip one track.
|
||||
pub async fn rip_track(&self, track: u32) -> Result<()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
let path = self.path.clone();
|
||||
thread::spawn(move || {
|
||||
let result = Self::rip_track_priv(&path, track);
|
||||
sender.send(result).unwrap();
|
||||
});
|
||||
|
||||
receiver.await?
|
||||
}
|
||||
|
||||
/// Load the disc and return its metadata.
|
||||
fn load_disc_priv() -> Result<RipDisc> {
|
||||
let discid = DiscId::read(None)?;
|
||||
let id = discid.id();
|
||||
let first_track = discid.first_track_num() as u32;
|
||||
let last_track = discid.last_track_num() as u32;
|
||||
|
||||
let disc = RipDisc {
|
||||
discid: id,
|
||||
first_track,
|
||||
last_track,
|
||||
};
|
||||
|
||||
Ok(disc)
|
||||
}
|
||||
|
||||
/// Rip one track.
|
||||
fn rip_track_priv(path: &str, track: u32) -> Result<()> {
|
||||
let pipeline = Self::build_pipeline(path, track)?;
|
||||
|
||||
let bus = pipeline
|
||||
.get_bus()
|
||||
.ok_or(anyhow!("Failed to get bus from pipeline!"))?;
|
||||
|
||||
pipeline.set_state(gstreamer::State::Playing)?;
|
||||
|
||||
for msg in bus.iter_timed(gstreamer::CLOCK_TIME_NONE) {
|
||||
use gstreamer::MessageView::*;
|
||||
|
||||
match msg.view() {
|
||||
Eos(..) => break,
|
||||
Error(err) => {
|
||||
pipeline.set_state(gstreamer::State::Null)?;
|
||||
bail!("GStreamer error: {:?}!", err);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
pipeline.set_state(gstreamer::State::Null)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the GStreamer pipeline to rip a track.
|
||||
fn build_pipeline(path: &str, track: u32) -> Result<Pipeline> {
|
||||
let cdparanoiasrc = ElementFactory::make("cdparanoiasrc", None)?;
|
||||
|
||||
// // TODO: Remove.
|
||||
// cdparanoiasrc.set_property(
|
||||
// "device",
|
||||
// &String::from("/home/johrpan/Diverses/arrau_schumann.iso"),
|
||||
// )?;
|
||||
|
||||
cdparanoiasrc.set_property("track", &track)?;
|
||||
|
||||
let queue = ElementFactory::make("queue", None)?;
|
||||
let audioconvert = ElementFactory::make("audioconvert", None)?;
|
||||
let flacenc = ElementFactory::make("flacenc", None)?;
|
||||
|
||||
let filesink = gstreamer::ElementFactory::make("filesink", None)?;
|
||||
filesink.set_property("location", &format!("{}/track_{:02}.flac", path, track))?;
|
||||
|
||||
let pipeline = gstreamer::Pipeline::new(None);
|
||||
pipeline.add_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &filesink])?;
|
||||
|
||||
Element::link_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &filesink])?;
|
||||
|
||||
Ok(pipeline)
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +107,16 @@ impl Window {
|
|||
result.stack.set_visible_child_name("content");
|
||||
}));
|
||||
|
||||
action!(
|
||||
result.window,
|
||||
"import-disc",
|
||||
clone!(@strong result => move |_, _| {
|
||||
let dialog = ImportDiscDialog::new(result.backend.clone());
|
||||
let window = NavigatorWindow::new(dialog);
|
||||
window.show();
|
||||
})
|
||||
);
|
||||
|
||||
action!(
|
||||
result.window,
|
||||
"preferences",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue