From 1bc79765be1abca94fa9a247d40949c5675605b3 Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Sun, 20 Dec 2020 11:47:27 +0100 Subject: [PATCH] Add support for importing an audio CD --- musicus/Cargo.toml | 1 + .../2020-09-27-201047_initial_schema/down.sql | 31 +-- .../2020-09-27-201047_initial_schema/up.sql | 110 ++++---- musicus/res/musicus.gresource.xml | 7 +- musicus/res/ui/import_disc_dialog.ui | 247 ++++++++++++++++++ musicus/res/ui/import_folder_dialg.ui | 117 +++++++++ musicus/res/ui/window.ui | 4 + musicus/src/database/files.rs | 54 ++++ musicus/src/database/medium.rs | 187 +++++++++++++ musicus/src/database/mod.rs | 7 +- musicus/src/database/recordings.rs | 16 ++ musicus/src/database/schema.rs | 39 ++- musicus/src/database/thread.rs | 89 +++++-- musicus/src/database/tracks.rs | 94 ------- musicus/src/dialogs/import_disc.rs | 211 +++++++++++++++ musicus/src/dialogs/mod.rs | 3 + musicus/src/main.rs | 2 + musicus/src/meson.build | 2 + musicus/src/player.rs | 32 +-- musicus/src/ripper.rs | 130 +++++++++ musicus/src/window.rs | 10 + 21 files changed, 1190 insertions(+), 203 deletions(-) create mode 100644 musicus/res/ui/import_disc_dialog.ui create mode 100644 musicus/res/ui/import_folder_dialg.ui create mode 100644 musicus/src/database/files.rs create mode 100644 musicus/src/database/medium.rs delete mode 100644 musicus/src/database/tracks.rs create mode 100644 musicus/src/dialogs/import_disc.rs create mode 100644 musicus/src/ripper.rs diff --git a/musicus/Cargo.toml b/musicus/Cargo.toml index 5eb4561..2467bf0 100644 --- a/musicus/Cargo.toml +++ b/musicus/Cargo.toml @@ -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" diff --git a/musicus/migrations/2020-09-27-201047_initial_schema/down.sql b/musicus/migrations/2020-09-27-201047_initial_schema/down.sql index 4d21111..0a31654 100644 --- a/musicus/migrations/2020-09-27-201047_initial_schema/down.sql +++ b/musicus/migrations/2020-09-27-201047_initial_schema/down.sql @@ -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; \ No newline at end of file diff --git a/musicus/migrations/2020-09-27-201047_initial_schema/up.sql b/musicus/migrations/2020-09-27-201047_initial_schema/up.sql index 27b97a6..1983a84 100644 --- a/musicus/migrations/2020-09-27-201047_initial_schema/up.sql +++ b/musicus/migrations/2020-09-27-201047_initial_schema/up.sql @@ -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 -); \ No newline at end of file diff --git a/musicus/res/musicus.gresource.xml b/musicus/res/musicus.gresource.xml index 2a32a46..4ab573a 100644 --- a/musicus/res/musicus.gresource.xml +++ b/musicus/res/musicus.gresource.xml @@ -2,8 +2,13 @@ ui/ensemble_editor.ui - ui/ensemble_selector.ui ui/ensemble_screen.ui + ui/ensemble_selector.ui + ui/import_disc_dialog.ui +<<<<<<< HEAD + ui/import_folder_dialog.ui +======= +>>>>>>> wip/cd-ripping-old ui/instrument_editor.ui ui/instrument_selector.ui ui/login_dialog.ui diff --git a/musicus/res/ui/import_disc_dialog.ui b/musicus/res/ui/import_disc_dialog.ui new file mode 100644 index 0000000..70bea77 --- /dev/null +++ b/musicus/res/ui/import_disc_dialog.ui @@ -0,0 +1,247 @@ + + + + + + + True + False + vertical + + + True + False + Import CD + + + True + True + True + + + True + False + go-previous-symbolic + + + + + + + False + True + 0 + + + + + True + False + crossfade + + + True + False + vertical + + + True + False + error + False + + + False + 6 + end + + + + + + False + False + 0 + + + + + False + 16 + + + True + False + Failed to load the CD. Make sure you have inserted it into your drive. + True + + + False + True + 0 + + + + + False + False + 0 + + + + + + + + False + True + 0 + + + + + True + False + center + center + 18 + vertical + 18 + + + True + False + 0.5019607843137255 + 80 + media-optical-cd-audio-symbolic + + + False + True + 0 + + + + + True + False + 0.5019607843137255 + Import from audio CD + + + + + + False + True + 1 + + + + + True + False + 0.5019607843137255 + 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. + center + True + 40 + + + False + True + 2 + + + + + Import + True + True + True + center + + + + False + True + 3 + + + + + True + True + 1 + + + + + start + + + + + True + False + True + + + loading + 1 + + + + + True + True + + + True + False + none + + + True + False + 500 + 300 + + + True + False + 6 + 6 + 12 + 6 + 0 + in + + + + + + + + + + + + + + + content + 2 + + + + + True + True + 1 + + + + diff --git a/musicus/res/ui/import_folder_dialg.ui b/musicus/res/ui/import_folder_dialg.ui new file mode 100644 index 0000000..6d38f09 --- /dev/null +++ b/musicus/res/ui/import_folder_dialg.ui @@ -0,0 +1,117 @@ + + + + + + + True + False + vertical + + + True + False + Import folder + + + True + True + True + + + True + False + go-previous-symbolic + + + + + + + False + True + 0 + + + + + True + False + center + center + True + 18 + vertical + 18 + + + True + False + 0.50196078431372548 + 80 + folder-symbolic + + + False + True + 0 + + + + + True + False + 0.50196078431372548 + Import from a folder + + + + + + False + True + 1 + + + + + True + False + 0.50196078431372548 + 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. + center + True + 40 + + + False + True + 2 + + + + + Select + True + True + True + center + + + + False + True + 3 + + + + + False + True + 1 + + + + diff --git a/musicus/res/ui/window.ui b/musicus/res/ui/window.ui index daf26ef..633308c 100644 --- a/musicus/res/ui/window.ui +++ b/musicus/res/ui/window.ui @@ -327,6 +327,10 @@
+ + Import CD + win.import-disc + Preferences win.preferences diff --git a/musicus/src/database/files.rs b/musicus/src/database/files.rs new file mode 100644 index 0000000..bc3a254 --- /dev/null +++ b/musicus/src/database/files.rs @@ -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> { + let row = files::table + .filter(files::track.eq(track_id)) + .load::(&self.connection)? + .into_iter() + .next(); + + let file_name = match row { + Some(row) => Some(row.file_name), + None => None, + }; + + Ok(file_name) + } +} diff --git a/musicus/src/database/medium.rs b/musicus/src/database/medium.rs new file mode 100644 index 0000000..de0bb38 --- /dev/null +++ b/musicus/src/database/medium.rs @@ -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, + pub tracks: Vec, +} + +/// 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, +} + +/// A track within a recording on a medium. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Track { + work_parts: Vec, +} + +/// Table data for a [`Medium`]. +#[derive(Insertable, Queryable, Debug, Clone)] +#[table_name = "mediums"] +struct MediumRow { + pub id: String, + pub name: String, + pub discid: Option, +} + +/// 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::>() + .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> { + let row = mediums::table + .filter(mediums::id.eq(id)) + .load::(&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 { + let track_set_rows = track_sets::table + .filter(track_sets::medium.eq(&row.id)) + .order_by(track_sets::index) + .load::(&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::(&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::>>()?; + + 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) + } +} diff --git a/musicus/src/database/mod.rs b/musicus/src/database/mod.rs index a8e94ed..952750a 100644 --- a/musicus/src/database/mod.rs +++ b/musicus/src/database/mod.rs @@ -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::*; diff --git a/musicus/src/database/recordings.rs b/musicus/src/database/recordings.rs index 3097d2d..b2ccf00 100644 --- a/musicus/src/database/recordings.rs +++ b/musicus/src/database/recordings.rs @@ -190,6 +190,22 @@ impl Database { Ok(exists) } + /// Get an existing recording. + pub fn get_recording(&self, id: &str) -> Result> { + let row = recordings::table + .filter(recordings::id.eq(id)) + .load::(&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 { let mut performance_descriptions: Vec = Vec::new(); diff --git a/musicus/src/database/schema.rs b/musicus/src/database/schema.rs index 55a0457..d079f6c 100644 --- a/musicus/src/database/schema.rs +++ b/musicus/src/database/schema.rs @@ -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, + } +} + 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, diff --git a/musicus/src/database/thread.rs b/musicus/src/database/thread.rs index 29cd043..79b8125 100644 --- a/musicus/src/database/thread.rs +++ b/musicus/src/database/thread.rs @@ -28,9 +28,12 @@ enum Action { GetRecordingsForEnsemble(String, Sender>>), GetRecordingsForWork(String, Sender>>), RecordingExists(String, Sender>), - UpdateTracks(String, Vec, Sender>), - DeleteTracks(String, Sender>), - GetTracks(String, Sender>>), + UpdateMedium(Medium, Sender>), + GetMedium(String, Sender>>), + DeleteMedium(String, Sender>), + UpdateFile(String, String, Sender>), + DeleteFile(String, Sender>), + GetFile(String, Sender>>), 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) -> 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> { + /// Get an existing medium. + pub async fn get_medium(&self, id: &str) -> Result> { 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> { + let (sender, receiver) = oneshot::channel(); + + self.action_sender + .send(GetFile(track_id.to_owned(), sender))?; + receiver.await? } diff --git a/musicus/src/database/tracks.rs b/musicus/src/database/tracks.rs deleted file mode 100644 index f0f8375..0000000 --- a/musicus/src/database/tracks.rs +++ /dev/null @@ -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, - pub file_name: String, -} - -impl TryFrom for Track { - type Error = Error; - fn try_from(row: TrackRow) -> Result { - let mut work_parts = Vec::::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) -> 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::>() - .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> { - let mut tracks = Vec::::new(); - - let rows = tracks::table - .filter(tracks::recording.eq(recording_id)) - .order_by(tracks::track_index) - .load::(&self.connection)?; - - for row in rows { - tracks.push(row.try_into()?); - } - - Ok(tracks) - } -} diff --git a/musicus/src/dialogs/import_disc.rs b/musicus/src/dialogs/import_disc.rs new file mode 100644 index 0000000..38ff99d --- /dev/null +++ b/musicus/src/dialogs/import_disc.rs @@ -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, + widget: gtk::Box, + stack: gtk::Stack, + info_bar: gtk::InfoBar, + list: Rc>, + ripper: Ripper, + tracks: RefCell>, + navigator: RefCell>>, +} + +impl ImportDiscDialog { + /// Create a new import disc dialog. + pub fn new(backend: Rc) -> Rc { + // 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::::new("No tracks found."); + frame.add(&list.widget); + + let mut tmp_dir = glib::get_tmp_dir().unwrap(); + let dir_name = format!("musicus-{}", rand::random::()); + 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::::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) { + self.navigator.replace(Some(navigator)); + } + + fn get_widget(&self) -> gtk::Widget { + self.widget.clone().upcast() + } + + fn detach_navigator(&self) { + self.navigator.replace(None); + } +} diff --git a/musicus/src/dialogs/mod.rs b/musicus/src/dialogs/mod.rs index b244bda..2b363ef 100644 --- a/musicus/src/dialogs/mod.rs +++ b/musicus/src/dialogs/mod.rs @@ -1,3 +1,6 @@ +pub mod import_disc; +pub use import_disc::*; + pub mod about; pub use about::*; diff --git a/musicus/src/main.rs b/musicus/src/main.rs index da2d64f..53a9fcb 100644 --- a/musicus/src/main.rs +++ b/musicus/src/main.rs @@ -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!"); diff --git a/musicus/src/meson.build b/musicus/src/meson.build index 72ff3cd..f91c62e 100644 --- a/musicus/src/meson.build +++ b/musicus/src/meson.build @@ -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', ) diff --git a/musicus/src/player.rs b/musicus/src/player.rs index 56d1e7d..6d1a294 100644 --- a/musicus/src/player.rs +++ b/musicus/src/player.rs @@ -8,8 +8,8 @@ use std::rc::Rc; #[derive(Clone)] pub struct PlaylistItem { - pub recording: Recording, - pub tracks: Vec, + pub tracks: TrackSet, + pub indices: Vec, } pub struct Player { @@ -19,11 +19,11 @@ pub struct Player { current_item: Cell>, current_track: Cell>, playing: Cell, - playlist_cbs: RefCell) -> ()>>>, - track_cbs: RefCell ()>>>, - duration_cbs: RefCell ()>>>, - playing_cbs: RefCell ()>>>, - position_cbs: RefCell ()>>>, + playlist_cbs: RefCell)>>>, + track_cbs: RefCell>>, + duration_cbs: RefCell>>, + playing_cbs: RefCell>>, + position_cbs: RefCell>>, } impl Player { @@ -80,23 +80,23 @@ impl Player { result } - pub fn add_playlist_cb) -> () + 'static>(&self, cb: F) { + pub fn add_playlist_cb) + 'static>(&self, cb: F) { self.playlist_cbs.borrow_mut().push(Box::new(cb)); } - pub fn add_track_cb () + 'static>(&self, cb: F) { + pub fn add_track_cb(&self, cb: F) { self.track_cbs.borrow_mut().push(Box::new(cb)); } - pub fn add_duration_cb () + 'static>(&self, cb: F) { + pub fn add_duration_cb(&self, cb: F) { self.duration_cbs.borrow_mut().push(Box::new(cb)); } - pub fn add_playing_cb () + 'static>(&self, cb: F) { + pub fn add_playing_cb(&self, cb: F) { self.playing_cbs.borrow_mut().push(Box::new(cb)); } - pub fn add_position_cb () + 'static>(&self, cb: F) { + pub fn add_position_cb(&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; diff --git a/musicus/src/ripper.rs b/musicus/src/ripper.rs new file mode 100644 index 0000000..2b3f22f --- /dev/null +++ b/musicus/src/ripper.rs @@ -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>, +} + +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 { + 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 { + 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 { + 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) + } +} diff --git a/musicus/src/window.rs b/musicus/src/window.rs index 066cab4..9cd4bc7 100644 --- a/musicus/src/window.rs +++ b/musicus/src/window.rs @@ -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",