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..39e9f73 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,15 @@ -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 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..51c17f4 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,78 @@ -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, + "path" TEXT NOT NULL ); -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..acc5530 100644 --- a/musicus/res/musicus.gresource.xml +++ b/musicus/res/musicus.gresource.xml @@ -2,11 +2,12 @@ ui/ensemble_editor.ui - ui/ensemble_selector.ui ui/ensemble_screen.ui + ui/ensemble_selector.ui ui/instrument_editor.ui ui/instrument_selector.ui ui/login_dialog.ui + ui/medium_editor.ui ui/performance_editor.ui ui/person_editor.ui ui/person_list.ui @@ -22,8 +23,10 @@ ui/recording_selector_screen.ui ui/selector.ui ui/server_dialog.ui - ui/tracks_editor.ui + ui/source_selector.ui ui/track_editor.ui + ui/track_selector.ui + ui/track_set_editor.ui ui/window.ui ui/work_editor.ui ui/work_part_editor.ui diff --git a/musicus/res/ui/medium_editor.ui b/musicus/res/ui/medium_editor.ui new file mode 100644 index 0000000..0ad182e --- /dev/null +++ b/musicus/res/ui/medium_editor.ui @@ -0,0 +1,201 @@ + + + + + + True + crossfade + + + True + vertical + + + True + Import music + + + True + True + + + True + go-previous-symbolic + + + + + + + True + True + False + + + True + crossfade + + + True + True + + + + + True + object-select-symbolic + + + + + + + + end + + + + + + + True + False + False + + + + + True + True + True + + + True + + + True + 6 + 6 + 6 + vertical + + + True + start + 12 + 6 + Medium + + + + + + + + True + in + + + True + none + + + True + True + True + Name of the medium + name_entry + + + True + True + center + True + + + + + + + True + True + True + Publish to the server + publish_switch + + + True + True + center + True + + + + + + + + + + + True + horizontal + 12 + 6 + + + True + start + end + True + Recordings + + + + + + + + True + True + none + + + True + list-add-symbolic + + + + + + + + + True + in + + + + + + + + + + + content + + + + + True + True + + + loading + + + + diff --git a/musicus/res/ui/source_selector.ui b/musicus/res/ui/source_selector.ui new file mode 100644 index 0000000..876f366 --- /dev/null +++ b/musicus/res/ui/source_selector.ui @@ -0,0 +1,161 @@ + + + + + + True + False + vertical + + + True + False + Import music + + + True + True + True + + + True + False + go-previous-symbolic + + + + + + + + + True + False + crossfade + + + True + False + vertical + + + True + False + error + False + + + False + 16 + + + True + False + Failed to load the CD. Make sure you have inserted it into your drive. + True + + + + + + + + + True + False + True + 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 + + + + + + diff --git a/musicus/res/ui/track_editor.ui b/musicus/res/ui/track_editor.ui index 592b1af..9657d7d 100644 --- a/musicus/res/ui/track_editor.ui +++ b/musicus/res/ui/track_editor.ui @@ -1,107 +1,67 @@ - - + True - False vertical True - False Track True True - True True - False go-previous-symbolic - + True True - True - - - True - False - object-select-symbolic - - + + + True + object-select-symbolic + + end - 1 - - False - True - 0 - True True + True True - False none True - False - 500 - 300 - + True - False start + 12 6 6 - 12 6 - 0 in - - - True - False - none - - - True - False - 6 - 6 - 6 - 6 - Select a recording of a work with multiple parts. - end - - - - - - - @@ -109,11 +69,6 @@ - - True - True - 1 - diff --git a/musicus/res/ui/track_selector.ui b/musicus/res/ui/track_selector.ui new file mode 100644 index 0000000..9654441 --- /dev/null +++ b/musicus/res/ui/track_selector.ui @@ -0,0 +1,75 @@ + + + + + + True + vertical + + + True + Select tracks + + + True + True + + + True + go-previous-symbolic + + + + + + + True + True + False + + + + True + object-select-symbolic + + + + + end + + + + + + + True + True + True + + + True + none + + + True + + + True + start + 12 + 6 + 6 + 6 + in + + + + + + + + + + diff --git a/musicus/res/ui/track_set_editor.ui b/musicus/res/ui/track_set_editor.ui new file mode 100644 index 0000000..95f0c1a --- /dev/null +++ b/musicus/res/ui/track_set_editor.ui @@ -0,0 +1,153 @@ + + + + + + True + vertical + + + True + Tracks + + + True + True + + + True + go-previous-symbolic + + + + + + + True + True + False + + + + True + object-select-symbolic + + + + + end + + + + + + + True + True + True + + + True + none + + + True + + + True + 6 + 6 + 6 + vertical + + + True + start + 12 + 6 + Recording + + + + + + + + True + in + + + True + none + + + True + True + True + Select a recording + select_recording_button + + + Select + True + True + center + + + + + + + + + + + True + horizontal + 12 + 6 + + + True + start + end + True + Tracks + + + + + + + + True + True + none + + + True + document-edit-symbolic + + + + + + + + + True + in + + + + + + + + + + + + diff --git a/musicus/res/ui/tracks_editor.ui b/musicus/res/ui/tracks_editor.ui deleted file mode 100644 index 4a23e84..0000000 --- a/musicus/res/ui/tracks_editor.ui +++ /dev/null @@ -1,311 +0,0 @@ - - - - - - - True - False - vertical - - - True - False - Tracks - - - True - False - True - True - - - True - False - object-select-symbolic - - - - - - end - - - - - True - True - True - - - True - False - go-previous-symbolic - - - - - 1 - - - - - False - True - 0 - - - - - True - False - True - 800 - 300 - - - - True - False - True - 18 - 12 - 6 - - - True - False - end - Recording - - - 0 - 0 - - - - - True - True - True - True - - - True - False - False - crossfade - True - - - True - False - start - Select … - - - unselected - - - - - True - False - vertical - - - True - False - start - Work - end - - - - - - False - True - 0 - - - - - True - False - 0.5 - start - Performers - end - - - False - True - 1 - - - - - selected - 1 - - - - - - - 1 - 0 - - - - - True - False - True - 6 - - - True - True - in - - - - - - True - True - 0 - - - - - True - False - vertical - 6 - - - True - True - True - - - True - False - list-add-symbolic - - - - - False - True - 0 - - - - - True - True - True - - - True - False - edit-symbolic - - - - - False - True - 2 - - - - - True - True - True - - - True - False - list-remove-symbolic - - - - - False - True - 3 - - - - - True - True - True - - - True - False - go-down-symbolic - - - - - False - True - end - 4 - - - - - True - True - True - - - True - False - go-up-symbolic - - - - - False - True - end - 5 - - - - - False - True - 1 - - - - - 0 - 1 - 2 - - - - - - - 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/medium.rs b/musicus/src/database/medium.rs new file mode 100644 index 0000000..0512c22 --- /dev/null +++ b/musicus/src/database/medium.rs @@ -0,0 +1,254 @@ +use super::generate_id; +use super::schema::{mediums, recordings, 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 { + /// An unique ID for the medium. + pub id: String, + + /// The human identifier for the medium. + pub name: String, + + /// If applicable, the MusicBrainz DiscID. + pub discid: Option, + + /// The tracks of the medium, grouped by recording. + 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 { + /// The recording to which the tracks belong. + pub recording: Recording, + + /// The actual tracks. + pub tracks: Vec, +} + +/// A track within a recording on a medium. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Track { + /// The work parts that are played on this track. They are indices to the + /// work parts of the work that is associated with the recording. + pub work_parts: Vec, + + /// The path to the audio file containing this track. This will not be + /// included when communicating with the server. + #[serde(skip)] + pub path: String, +} + +/// 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, + pub path: 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)?; + + // Add the new medium. + + let medium_row = MediumRow { + id: medium_id.to_owned(), + name: medium.name.clone(), + discid: medium.discid.clone(), + }; + + diesel::insert_into(mediums::table) + .values(medium_row) + .execute(&self.connection)?; + + for (index, track_set) in medium.tracks.iter().enumerate() { + // Add associated items from the server, if they don't already + // exist. + + if self.get_recording(&track_set.recording.id)?.is_none() { + self.update_recording(track_set.recording.clone())?; + } + + // Add the actual track set data. + + 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, + path: track.path.clone(), + }; + + 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(()) + } + + /// Get all available track sets for a recording. + pub fn get_track_sets(&self, recording_id: &str) -> Result> { + let mut track_sets: Vec = Vec::new(); + + let rows = track_sets::table + .inner_join(recordings::table.on(recordings::id.eq(track_sets::recording))) + .filter(recordings::id.eq(recording_id)) + .select(track_sets::table::all_columns()) + .load::(&self.connection)?; + + for row in rows { + let track_set = self.get_track_set_from_row(row)?; + track_sets.push(track_set); + } + + Ok(track_sets) + } + + /// 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 track_set = self.get_track_set_from_row(track_set_row)?; + track_sets.push(track_set); + } + + let medium = Medium { + id: row.id, + name: row.name, + discid: row.discid, + tracks: track_sets, + }; + + Ok(medium) + } + + /// Convert a track set row from the database to an actual track set. + fn get_track_set_from_row(&self, row: TrackSetRow) -> Result { + let recording_id = 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::track_set.eq(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, + path: track_row.path, + }; + + tracks.push(track); + } + + let track_set = TrackSet { recording, tracks }; + + Ok(track_set) + } +} diff --git a/musicus/src/database/mod.rs b/musicus/src/database/mod.rs index a8e94ed..ef87e5a 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,9 +19,6 @@ pub use recordings::*; pub mod thread; pub use thread::*; -pub mod tracks; -pub use tracks::*; - 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..b4ea3c4 100644 --- a/musicus/src/database/schema.rs +++ b/musicus/src/database/schema.rs @@ -20,6 +20,14 @@ table! { } } +table! { + mediums (id) { + id -> Text, + name -> Text, + discid -> Nullable, + } +} + table! { performances (id) { id -> BigInt, @@ -47,12 +55,21 @@ 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, + path -> Text, } } @@ -90,7 +107,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)); @@ -100,9 +119,11 @@ allow_tables_to_appear_in_same_query!( ensembles, 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..2ce86fe 100644 --- a/musicus/src/database/thread.rs +++ b/musicus/src/database/thread.rs @@ -28,9 +28,10 @@ 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>), + GetTrackSets(String, Sender>>), Stop(Sender<()>), } @@ -124,16 +125,17 @@ 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(); + } + GetTrackSets(recording_id, sender) => { + sender.send(db.get_track_sets(&recording_id)).unwrap(); } Stop(sender) => { sender.send(()).unwrap(); @@ -312,28 +314,35 @@ 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(GetTracks(recording_id.to_string(), sender))?; + self.action_sender.send(GetMedium(id.to_owned(), sender))?; + receiver.await? + } + + /// Get all track sets for a recording. + pub async fn get_track_sets(&self, recording_id: &str) -> Result> { + let (sender, receiver) = oneshot::channel(); + self.action_sender.send(GetTrackSets(recording_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/editors/mod.rs b/musicus/src/editors/mod.rs index e171277..8974e76 100644 --- a/musicus/src/editors/mod.rs +++ b/musicus/src/editors/mod.rs @@ -10,13 +10,9 @@ pub use person::*; pub mod recording; pub use recording::*; -pub mod tracks; -pub use tracks::*; - pub mod work; pub use work::*; mod performance; -mod track; mod work_part; -mod work_section; \ No newline at end of file +mod work_section; diff --git a/musicus/src/editors/track.rs b/musicus/src/editors/track.rs deleted file mode 100644 index 5fdff61..0000000 --- a/musicus/src/editors/track.rs +++ /dev/null @@ -1,148 +0,0 @@ -use crate::database::*; -use crate::widgets::{Navigator, NavigatorScreen}; -use glib::clone; -use gtk::prelude::*; -use gtk_macros::get_widget; -use std::cell::RefCell; -use std::convert::TryInto; -use std::rc::Rc; - -/// A screen for editing a single track. -// TODO: Refactor. -pub struct TrackEditor { - widget: gtk::Box, - ready_cb: RefCell ()>>>, - navigator: RefCell>>, -} - -impl TrackEditor { - /// Create a new track editor. - pub fn new(track: Track, work: Work) -> Rc { - // Create UI - - let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_editor.ui"); - - get_widget!(builder, gtk::Box, widget); - get_widget!(builder, gtk::Button, back_button); - get_widget!(builder, gtk::Button, save_button); - get_widget!(builder, gtk::ListBox, list); - - let this = Rc::new(Self { - widget, - ready_cb: RefCell::new(None), - navigator: RefCell::new(None), - }); - - back_button.connect_clicked(clone!(@strong this => move |_| { - let navigator = this.navigator.borrow().clone(); - if let Some(navigator) = navigator { - navigator.pop(); - } - })); - - let work = Rc::new(work); - let work_parts = Rc::new(RefCell::new(track.work_parts)); - let file_name = track.file_name; - - save_button.connect_clicked(clone!(@strong this, @strong work_parts => move |_| { - let mut work_parts = work_parts.borrow_mut(); - work_parts.sort(); - - if let Some(cb) = &*this.ready_cb.borrow() { - cb(Track { - work_parts: work_parts.clone(), - file_name: file_name.clone(), - }); - } - - let navigator = this.navigator.borrow().clone(); - if let Some(navigator) = navigator { - navigator.pop(); - } - })); - - for (index, part) in work.parts.iter().enumerate() { - let check = gtk::CheckButton::new(); - check.set_active(work_parts.borrow().contains(&index)); - check.connect_toggled(clone!(@strong check, @strong work_parts => move |_| { - if check.get_active() { - let mut work_parts = work_parts.borrow_mut(); - work_parts.push(index); - } else { - let mut work_parts = work_parts.borrow_mut(); - if let Some(pos) = work_parts.iter().position(|part| *part == index) { - work_parts.remove(pos); - } - } - })); - - let label = gtk::Label::new(Some(&part.title)); - label.set_halign(gtk::Align::Start); - label.set_ellipsize(pango::EllipsizeMode::End); - - let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6); - hbox.set_border_width(6); - hbox.add(&check); - hbox.add(&label); - - let row = gtk::ListBoxRow::new(); - row.add(&hbox); - row.show_all(); - - list.add(&row); - list.connect_row_activated( - clone!(@strong row, @strong check => move |_, activated_row| { - if *activated_row == row { - check.activate(); - } - }), - ); - } - - let mut section_count = 0; - for section in &work.sections { - let attributes = pango::AttrList::new(); - attributes.insert(pango::Attribute::new_weight(pango::Weight::Bold).unwrap()); - - let label = gtk::Label::new(Some(§ion.title)); - label.set_halign(gtk::Align::Start); - label.set_ellipsize(pango::EllipsizeMode::End); - label.set_attributes(Some(&attributes)); - let wrap = gtk::Box::new(gtk::Orientation::Vertical, 0); - wrap.set_border_width(6); - wrap.add(&label); - - let row = gtk::ListBoxRow::new(); - row.set_activatable(false); - row.add(&wrap); - row.show_all(); - - list.insert( - &row, - (section.before_index + section_count).try_into().unwrap(), - ); - section_count += 1; - } - - this - } - - /// Set the closure to be called when the track was edited. - pub fn set_ready_cb () + 'static>(&self, cb: F) { - self.ready_cb.replace(Some(Box::new(cb))); - } -} - -impl NavigatorScreen for TrackEditor { - 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/editors/tracks.rs b/musicus/src/editors/tracks.rs deleted file mode 100644 index b373b40..0000000 --- a/musicus/src/editors/tracks.rs +++ /dev/null @@ -1,337 +0,0 @@ -use super::track::TrackEditor; -use crate::backend::Backend; -use crate::database::*; -use crate::widgets::{List, Navigator, NavigatorScreen}; -use crate::selectors::{PersonSelector, WorkSelector, RecordingSelector}; -use gettextrs::gettext; -use glib::clone; -use gtk::prelude::*; -use gtk_macros::get_widget; -use std::cell::RefCell; -use std::rc::Rc; - -/// A dialog for editing a set of tracks. -// TODO: Disable buttons if no track is selected. -pub struct TracksEditor { - backend: Rc, - widget: gtk::Box, - save_button: gtk::Button, - recording_stack: gtk::Stack, - work_label: gtk::Label, - performers_label: gtk::Label, - track_list: Rc>, - recording: RefCell>, - tracks: RefCell>, - callback: RefCell ()>>>, - navigator: RefCell>>, -} - -impl TracksEditor { - /// Create a new track editor an optionally initialize it with a recording and a list of - /// tracks. - pub fn new( - backend: Rc, - recording: Option, - tracks: Vec, - ) -> Rc { - // UI setup - - let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/tracks_editor.ui"); - - get_widget!(builder, gtk::Box, widget); - get_widget!(builder, gtk::Button, back_button); - get_widget!(builder, gtk::Button, save_button); - get_widget!(builder, gtk::Button, recording_button); - get_widget!(builder, gtk::Stack, recording_stack); - get_widget!(builder, gtk::Label, work_label); - get_widget!(builder, gtk::Label, performers_label); - get_widget!(builder, gtk::ScrolledWindow, scroll); - get_widget!(builder, gtk::Button, add_track_button); - get_widget!(builder, gtk::Button, edit_track_button); - get_widget!(builder, gtk::Button, remove_track_button); - get_widget!(builder, gtk::Button, move_track_up_button); - get_widget!(builder, gtk::Button, move_track_down_button); - - let track_list = List::new(&gettext("Add some tracks.")); - scroll.add(&track_list.widget); - - let this = Rc::new(Self { - backend, - widget, - save_button, - recording_stack, - work_label, - performers_label, - track_list, - recording: RefCell::new(recording), - tracks: RefCell::new(tracks), - callback: RefCell::new(None), - navigator: RefCell::new(None), - }); - - // Signals and callbacks - - back_button.connect_clicked(clone!(@strong this => move |_| { - let navigator = this.navigator.borrow().clone(); - if let Some(navigator) = navigator { - navigator.pop(); - } - })); - - this.save_button - .connect_clicked(clone!(@strong this => move |_| { - let context = glib::MainContext::default(); - let this = this.clone(); - context.spawn_local(async move { - let recording = this.recording.borrow().as_ref().unwrap().clone(); - - // Add the recording first, if it's from the server. - - if !this.backend.db().recording_exists(&recording.id).await.unwrap() { - this.backend.db().update_recording(recording.clone()).await.unwrap(); - } - - // Add the actual tracks. - - this.backend.db().update_tracks( - &recording.id, - this.tracks.borrow().clone(), - ).await.unwrap(); - - if let Some(callback) = &*this.callback.borrow() { - callback(); - } - - let navigator = this.navigator.borrow().clone(); - if let Some(navigator) = navigator { - navigator.pop(); - } - }); - - })); - - recording_button.connect_clicked(clone!(@strong this => move |_| { - let navigator = this.navigator.borrow().clone(); - if let Some(navigator) = navigator { - let person_selector = PersonSelector::new(this.backend.clone()); - - person_selector.set_selected_cb(clone!(@strong this, @strong navigator => move |person| { - let work_selector = WorkSelector::new(this.backend.clone(), person.clone()); - - work_selector.set_selected_cb(clone!(@strong this, @strong navigator => move |work| { - let recording_selector = RecordingSelector::new(this.backend.clone(), work.clone()); - - recording_selector.set_selected_cb(clone!(@strong this, @strong navigator => move |recording| { - this.recording_selected(recording); - this.recording.replace(Some(recording.clone())); - - navigator.clone().pop(); - navigator.clone().pop(); - navigator.clone().pop(); - })); - - navigator.clone().push(recording_selector); - })); - - navigator.clone().push(work_selector); - })); - - navigator.clone().push(person_selector); - } - })); - - this.track_list - .set_make_widget(clone!(@strong this => move |track| { - this.build_track_row(track) - })); - - add_track_button.connect_clicked(clone!(@strong this => move |_| { - let navigator = this.navigator.borrow().clone(); - if let Some(navigator) = navigator { - let music_library_path = this.backend.get_music_library_path().unwrap(); - - let dialog = gtk::FileChooserNative::new( - Some(&gettext("Select audio files")), - Some(&navigator.window), - gtk::FileChooserAction::Open, - None, - None, - ); - - dialog.set_select_multiple(true); - dialog.set_current_folder(&music_library_path); - - if let gtk::ResponseType::Accept = dialog.run() { - let mut index = match this.track_list.get_selected_index() { - Some(index) => index + 1, - None => this.tracks.borrow().len(), - }; - - { - let mut tracks = this.tracks.borrow_mut(); - for file_name in dialog.get_filenames() { - let file_name = file_name.strip_prefix(&music_library_path).unwrap(); - tracks.insert(index, Track { - work_parts: Vec::new(), - file_name: String::from(file_name.to_str().unwrap()), - }); - index += 1; - } - } - - this.track_list.show_items(this.tracks.borrow().clone()); - this.autofill_parts(); - this.track_list.select_index(index); - } - } - })); - - remove_track_button.connect_clicked(clone!(@strong this => move |_| { - match this.track_list.get_selected_index() { - Some(index) => { - let mut tracks = this.tracks.borrow_mut(); - tracks.remove(index); - this.track_list.show_items(tracks.clone()); - this.track_list.select_index(index); - } - None => (), - } - })); - - move_track_up_button.connect_clicked(clone!(@strong this => move |_| { - match this.track_list.get_selected_index() { - Some(index) => { - if index > 0 { - let mut tracks = this.tracks.borrow_mut(); - tracks.swap(index - 1, index); - this.track_list.show_items(tracks.clone()); - this.track_list.select_index(index - 1); - } - } - None => (), - } - })); - - move_track_down_button.connect_clicked(clone!(@strong this => move |_| { - match this.track_list.get_selected_index() { - Some(index) => { - let mut tracks = this.tracks.borrow_mut(); - if index < tracks.len() - 1 { - tracks.swap(index, index + 1); - this.track_list.show_items(tracks.clone()); - this.track_list.select_index(index + 1); - } - } - None => (), - } - })); - - edit_track_button.connect_clicked(clone!(@strong this => move |_| { - let navigator = this.navigator.borrow().clone(); - if let Some(navigator) = navigator { - if let Some(index) = this.track_list.get_selected_index() { - if let Some(recording) = &*this.recording.borrow() { - let editor = TrackEditor::new(this.tracks.borrow()[index].clone(), recording.work.clone()); - - editor.set_ready_cb(clone!(@strong this => move |track| { - let mut tracks = this.tracks.borrow_mut(); - tracks[index] = track; - this.track_list.show_items(tracks.clone()); - this.track_list.select_index(index); - })); - - navigator.push(editor); - } - } - } - })); - - // Initialization - - if let Some(recording) = &*this.recording.borrow() { - this.recording_selected(recording); - } - - this.track_list.show_items(this.tracks.borrow().clone()); - - this - } - - /// Set a callback to be called when the tracks are saved. - pub fn set_callback () + 'static>(&self, cb: F) { - self.callback.replace(Some(Box::new(cb))); - } - - /// Create a widget representing a track. - fn build_track_row(&self, track: &Track) -> gtk::Widget { - let mut title_parts = Vec::::new(); - for part in &track.work_parts { - if let Some(recording) = &*self.recording.borrow() { - title_parts.push(recording.work.parts[*part].title.clone()); - } - } - - let title = if title_parts.is_empty() { - gettext("Unknown") - } else { - title_parts.join(", ") - }; - - let title_label = gtk::Label::new(Some(&title)); - title_label.set_ellipsize(pango::EllipsizeMode::End); - title_label.set_halign(gtk::Align::Start); - - let file_name_label = gtk::Label::new(Some(&track.file_name)); - file_name_label.set_ellipsize(pango::EllipsizeMode::End); - file_name_label.set_opacity(0.5); - file_name_label.set_halign(gtk::Align::Start); - - let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0); - vbox.set_border_width(6); - vbox.add(&title_label); - vbox.add(&file_name_label); - - vbox.upcast() - } - - /// Set everything up after selecting a recording. - fn recording_selected(&self, recording: &Recording) { - self.work_label.set_text(&recording.work.get_title()); - self.performers_label.set_text(&recording.get_performers()); - self.recording_stack.set_visible_child_name("selected"); - self.save_button.set_sensitive(true); - self.autofill_parts(); - } - - /// Automatically try to put work part information from the selected recording into the - /// selected tracks. - fn autofill_parts(&self) { - if let Some(recording) = &*self.recording.borrow() { - let mut tracks = self.tracks.borrow_mut(); - - for (index, _) in recording.work.parts.iter().enumerate() { - if let Some(mut track) = tracks.get_mut(index) { - track.work_parts = vec![index]; - } else { - break; - } - } - - self.track_list.show_items(tracks.clone()); - } - } -} - -impl NavigatorScreen for TracksEditor { - 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/import/disc_source.rs b/musicus/src/import/disc_source.rs new file mode 100644 index 0000000..c496eb4 --- /dev/null +++ b/musicus/src/import/disc_source.rs @@ -0,0 +1,175 @@ +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::path::{Path, PathBuf}; +use std::thread; + +/// Representation of an audio CD being imported as a medium. +#[derive(Clone, Debug)] +pub struct DiscSource { + /// The MusicBrainz DiscID of the CD. + pub discid: String, + + /// The path to the temporary directory where the audio files will be. + pub path: PathBuf, + + /// The tracks on this disc. + pub tracks: Vec, +} + +/// Representation of a single track on an audio CD. +#[derive(Clone, Debug)] +pub struct TrackSource { + /// The track number. This is different from the index in the disc + /// source's tracks list, because it is not defined from which number the + /// the track numbers start. + pub number: u32, + + /// The path to the temporary file to which the track will be ripped. The + /// file will not exist until the track is actually ripped. + pub path: PathBuf, +} + +impl DiscSource { + /// Try to create a new disc source by asynchronously reading the + /// information from the default disc drive. + pub async fn load() -> Result { + let (sender, receiver) = oneshot::channel(); + + thread::spawn(|| { + let disc = Self::load_priv(); + sender.send(disc).unwrap(); + }); + + let disc = receiver.await??; + + Ok(disc) + } + + /// Rip the whole disc asynchronously. After this method has finished + /// successfully, the audio files will be available in the specified + /// location for each track source. + pub async fn rip(&self) -> Result<()> { + for track in &self.tracks { + let (sender, receiver) = oneshot::channel(); + + let number = track.number; + let path = track.path.clone(); + + thread::spawn(move || { + let result = Self::rip_track(&path, number); + sender.send(result).unwrap(); + }); + + receiver.await??; + } + + Ok(()) + } + + /// Load the disc from the default disc drive. + fn load_priv() -> Result { + let discid = DiscId::read(None)?; + let id = discid.id(); + + let mut tracks = Vec::new(); + + let first_track = discid.first_track_num() as u32; + let last_track = discid.last_track_num() as u32; + + let tmp_dir = Self::create_tmp_dir()?; + + for number in first_track..=last_track { + let file_name = format!("track_{:02}.flac", number); + + let mut path = tmp_dir.clone(); + path.push(file_name); + + let track = TrackSource { + number, + path, + }; + + tracks.push(track); + } + + let disc = DiscSource { + discid: id, + tracks, + path: tmp_dir, + }; + + Ok(disc) + } + + /// Create a new temporary directory and return its path. + // TODO: Move to a more appropriate place. + fn create_tmp_dir() -> Result { + let mut tmp_dir = glib::get_tmp_dir() + .ok_or_else(|| { + anyhow!("Failed to get temporary directory using glib::get_tmp_dir()!") + })?; + + let dir_name = format!("musicus-{}", rand::random::()); + tmp_dir.push(dir_name); + + std::fs::create_dir(&tmp_dir)?; + + Ok(tmp_dir) + } + + /// Rip one track. + fn rip_track(path: &Path, number: u32) -> Result<()> { + let pipeline = Self::build_pipeline(path, number)?; + + let bus = pipeline + .get_bus() + .ok_or_else(|| 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: &Path, number: u32) -> Result { + let cdparanoiasrc = ElementFactory::make("cdparanoiasrc", None)?; + cdparanoiasrc.set_property("track", &number)?; + + let queue = ElementFactory::make("queue", None)?; + let audioconvert = ElementFactory::make("audioconvert", None)?; + let flacenc = ElementFactory::make("flacenc", None)?; + + let path_str = path.to_str().ok_or_else(|| { + anyhow!("Failed to convert path '{:?}' to string!", path) + })?; + + let filesink = gstreamer::ElementFactory::make("filesink", None)?; + filesink.set_property("location", &path_str.to_owned())?; + + 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/import/medium_editor.rs b/musicus/src/import/medium_editor.rs new file mode 100644 index 0000000..52ebb13 --- /dev/null +++ b/musicus/src/import/medium_editor.rs @@ -0,0 +1,241 @@ +use super::disc_source::DiscSource; +use super::track_set_editor::{TrackSetData, TrackSetEditor}; +use crate::database::{generate_id, Medium, Track, TrackSet}; +use crate::backend::Backend; +use crate::widgets::{Navigator, NavigatorScreen}; +use crate::widgets::new_list::List; +use anyhow::Result; +use glib::clone; +use glib::prelude::*; +use gtk::prelude::*; +use gtk_macros::get_widget; +use libhandy::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; + +/// A dialog for editing metadata while importing music into the music library. +pub struct MediumEditor { + backend: Rc, + source: Rc, + widget: gtk::Stack, + done_button: gtk::Button, + done_stack: gtk::Stack, + done: gtk::Image, + name_entry: gtk::Entry, + publish_switch: gtk::Switch, + track_set_list: List, + track_sets: RefCell>, + navigator: RefCell>>, +} + +impl MediumEditor { + /// Create a new medium editor. + pub fn new(backend: Rc, source: DiscSource) -> Rc { + // Create UI + + let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/medium_editor.ui"); + + get_widget!(builder, gtk::Stack, widget); + get_widget!(builder, gtk::Button, back_button); + get_widget!(builder, gtk::Button, done_button); + get_widget!(builder, gtk::Stack, done_stack); + get_widget!(builder, gtk::Image, done); + get_widget!(builder, gtk::Entry, name_entry); + get_widget!(builder, gtk::Switch, publish_switch); + get_widget!(builder, gtk::Button, add_button); + get_widget!(builder, gtk::Frame, frame); + + let list = List::new("No recordings added."); + frame.add(&list.widget); + + let this = Rc::new(Self { + backend, + source: Rc::new(source), + widget, + done_button, + done_stack, + done, + name_entry, + publish_switch, + track_set_list: list, + track_sets: 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(); + } + })); + + this.done_button.connect_clicked(clone!(@strong this => move |_| { + let context = glib::MainContext::default(); + let clone = this.clone(); + context.spawn_local(async move { + clone.widget.set_visible_child_name("loading"); + match clone.clone().save().await { + Ok(_) => (), + Err(err) => { + println!("{:?}", err); + // clone.info_bar.set_revealed(true); + } + } + + }); + })); + + add_button.connect_clicked(clone!(@strong this => move |_| { + let navigator = this.navigator.borrow().clone(); + if let Some(navigator) = navigator { + let editor = TrackSetEditor::new(this.backend.clone(), Rc::clone(&this.source)); + + editor.set_done_cb(clone!(@strong this => move |track_set| { + let length = { + let mut track_sets = this.track_sets.borrow_mut(); + track_sets.push(track_set); + track_sets.len() + }; + + this.track_set_list.update(length); + })); + + navigator.push(editor); + } + })); + + this.track_set_list.set_make_widget(clone!(@strong this => move |index| { + let track_set = &this.track_sets.borrow()[index]; + + let title = track_set.recording.work.get_title(); + let subtitle = track_set.recording.get_performers(); + + let edit_image = gtk::Image::from_icon_name(Some("document-edit-symbolic"), gtk::IconSize::Button); + let edit_button = gtk::Button::new(); + edit_button.set_relief(gtk::ReliefStyle::None); + edit_button.set_valign(gtk::Align::Center); + edit_button.add(&edit_image); + + let row = libhandy::ActionRow::new(); + row.set_activatable(true); + row.set_title(Some(&title)); + row.set_subtitle(Some(&subtitle)); + row.add(&edit_button); + row.set_activatable_widget(Some(&edit_button)); + row.show_all(); + + edit_button.connect_clicked(clone!(@strong this => move |_| { + + })); + + row.upcast() + })); + + // Start ripping the CD in the background. + let context = glib::MainContext::default(); + let clone = this.clone(); + context.spawn_local(async move { + match clone.source.rip().await { + Err(error) => { + // TODO: Present error. + println!("Failed to rip: {}", error); + }, + Ok(_) => { + clone.done_stack.set_visible_child(&clone.done); + clone.done_button.set_sensitive(true); + } + } + }); + + this + } + + /// Save the medium and possibly upload it to the server. + async fn save(self: Rc) -> Result<()> { + let name = self.name_entry.get_text().to_string(); + + // Create a new directory in the music library path for the imported medium. + + let mut path = self.backend.get_music_library_path().unwrap().clone(); + path.push(&name); + std::fs::create_dir(&path)?; + + // Convert the track set data to real track sets. + + let mut track_sets = Vec::new(); + + for track_set_data in &*self.track_sets.borrow() { + let mut tracks = Vec::new(); + + for track_data in &track_set_data.tracks { + // Copy the corresponding audio file to the music library. + + let track_source = &self.source.tracks[track_data.track_source]; + let file_name = format!("track_{:02}.flac", track_source.number); + + let mut track_path = path.clone(); + track_path.push(&file_name); + + std::fs::copy(&track_source.path, &track_path)?; + + // Create the real track. + + let track = Track { + work_parts: track_data.work_parts.clone(), + path: track_path.to_str().unwrap().to_owned(), + }; + + tracks.push(track); + } + + let track_set = TrackSet { + recording: track_set_data.recording.clone(), + tracks, + }; + + track_sets.push(track_set); + } + + let medium = Medium { + id: generate_id(), + name: self.name_entry.get_text().to_string(), + discid: Some(self.source.discid.clone()), + tracks: track_sets, + }; + + let upload = self.publish_switch.get_active(); + if upload { + // self.backend.post_medium(&medium).await?; + } + + self.backend + .db() + .update_medium(medium.clone()) + .await?; + + self.backend.library_changed(); + + let navigator = self.navigator.borrow().clone(); + if let Some(navigator) = navigator { + navigator.clone().pop(); + } + + Ok(()) + } +} + +impl NavigatorScreen for MediumEditor { + 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/import/mod.rs b/musicus/src/import/mod.rs new file mode 100644 index 0000000..2744611 --- /dev/null +++ b/musicus/src/import/mod.rs @@ -0,0 +1,8 @@ +mod disc_source; +mod medium_editor; +mod source_selector; +mod track_editor; +mod track_selector; +mod track_set_editor; + +pub use source_selector::SourceSelector; diff --git a/musicus/src/import/source_selector.rs b/musicus/src/import/source_selector.rs new file mode 100644 index 0000000..121bc34 --- /dev/null +++ b/musicus/src/import/source_selector.rs @@ -0,0 +1,92 @@ +use super::medium_editor::MediumEditor; +use super::disc_source::DiscSource; +use crate::backend::Backend; +use crate::widgets::{Navigator, NavigatorScreen}; +use anyhow::Result; +use glib::clone; +use gtk::prelude::*; +use gtk_macros::get_widget; +use std::cell::RefCell; +use std::rc::Rc; + +/// A dialog for starting to import music. +pub struct SourceSelector { + backend: Rc, + widget: gtk::Box, + stack: gtk::Stack, + info_bar: gtk::InfoBar, + navigator: RefCell>>, +} + +impl SourceSelector { + /// Create a new source selector. + pub fn new(backend: Rc) -> Rc { + // Create UI + + let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/source_selector.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); + + let this = Rc::new(Self { + backend, + widget, + stack, + info_bar, + 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 DiscSource::load().await { + Ok(disc) => { + let navigator = clone.navigator.borrow().clone(); + if let Some(navigator) = navigator { + let editor = MediumEditor::new(clone.backend.clone(), disc); + navigator.push(editor); + } + + clone.info_bar.set_revealed(false); + clone.stack.set_visible_child_name("start"); + } + Err(_) => { + clone.info_bar.set_revealed(true); + clone.stack.set_visible_child_name("start"); + } + } + }); + })); + + this + } +} + +impl NavigatorScreen for SourceSelector { + 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/import/track_editor.rs b/musicus/src/import/track_editor.rs new file mode 100644 index 0000000..ff02c04 --- /dev/null +++ b/musicus/src/import/track_editor.rs @@ -0,0 +1,110 @@ +use crate::database::Recording; +use crate::widgets::{Navigator, NavigatorScreen}; +use crate::widgets::new_list::List; +use glib::clone; +use gtk::prelude::*; +use gtk_macros::get_widget; +use libhandy::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; + +/// A screen for editing a single track. +pub struct TrackEditor { + widget: gtk::Box, + selection: RefCell>, + selected_cb: RefCell)>>>, + navigator: RefCell>>, +} + +impl TrackEditor { + /// Create a new track editor. + pub fn new(recording: Recording, selection: Vec) -> Rc { + // Create UI + + let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_editor.ui"); + + get_widget!(builder, gtk::Box, widget); + get_widget!(builder, gtk::Button, back_button); + get_widget!(builder, gtk::Button, select_button); + get_widget!(builder, gtk::Frame, parts_frame); + + let parts_list = gtk::ListBox::new(); + parts_list.set_selection_mode(gtk::SelectionMode::None); + parts_list.set_vexpand(false); + parts_list.show(); + parts_frame.add(&parts_list); + + let this = Rc::new(Self { + widget, + selection: RefCell::new(selection), + selected_cb: RefCell::new(None), + 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(); + } + })); + + select_button.connect_clicked(clone!(@strong this => move |_| { + let navigator = this.navigator.borrow().clone(); + if let Some(navigator) = navigator { + navigator.pop(); + } + + if let Some(cb) = &*this.selected_cb.borrow() { + let selection = this.selection.borrow().clone(); + cb(selection); + } + })); + + for (index, part) in recording.work.parts.iter().enumerate() { + let check = gtk::CheckButton::new(); + check.set_active(this.selection.borrow().contains(&index)); + + check.connect_toggled(clone!(@strong this => move |check| { + let mut selection = this.selection.borrow_mut(); + if check.get_active() { + selection.push(index); + } else { + if let Some(pos) = selection.iter().position(|part| *part == index) { + selection.remove(pos); + } + } + })); + + let row = libhandy::ActionRow::new(); + row.add_prefix(&check); + row.set_activatable_widget(Some(&check)); + row.set_title(Some(&part.title)); + row.show_all(); + + parts_list.add(&row); + } + + this + } + + /// Set the closure to be called when the user has edited the track. + pub fn set_selected_cb) + 'static>(&self, cb: F) { + self.selected_cb.replace(Some(Box::new(cb))); + } +} + +impl NavigatorScreen for TrackEditor { + 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/import/track_selector.rs b/musicus/src/import/track_selector.rs new file mode 100644 index 0000000..3404593 --- /dev/null +++ b/musicus/src/import/track_selector.rs @@ -0,0 +1,122 @@ +use super::disc_source::DiscSource; +use crate::widgets::{Navigator, NavigatorScreen}; +use glib::clone; +use gtk::prelude::*; +use gtk_macros::get_widget; +use libhandy::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; + +/// A screen for selecting tracks from a medium. +pub struct TrackSelector { + source: Rc, + widget: gtk::Box, + select_button: gtk::Button, + selection: RefCell>, + selected_cb: RefCell)>>>, + navigator: RefCell>>, +} + +impl TrackSelector { + /// Create a new track selector. + pub fn new(source: Rc) -> Rc { + // Create UI + + let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_selector.ui"); + + get_widget!(builder, gtk::Box, widget); + get_widget!(builder, gtk::Button, back_button); + get_widget!(builder, gtk::Button, select_button); + get_widget!(builder, gtk::Frame, tracks_frame); + + let track_list = gtk::ListBox::new(); + track_list.set_selection_mode(gtk::SelectionMode::None); + track_list.set_vexpand(false); + track_list.show(); + tracks_frame.add(&track_list); + + let this = Rc::new(Self { + source, + widget, + select_button, + selection: RefCell::new(Vec::new()), + selected_cb: RefCell::new(None), + 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(); + } + })); + + this.select_button.connect_clicked(clone!(@strong this => move |_| { + let navigator = this.navigator.borrow().clone(); + if let Some(navigator) = navigator { + navigator.pop(); + } + + if let Some(cb) = &*this.selected_cb.borrow() { + let selection = this.selection.borrow().clone(); + cb(selection); + } + })); + + for (index, track) in this.source.tracks.iter().enumerate() { + let check = gtk::CheckButton::new(); + + check.connect_toggled(clone!(@strong this => move |check| { + let mut selection = this.selection.borrow_mut(); + if check.get_active() { + selection.push(index); + } else { + if let Some(pos) = selection.iter().position(|part| *part == index) { + selection.remove(pos); + } + } + + if selection.is_empty() { + this.select_button.set_sensitive(false); + } else { + this.select_button.set_sensitive(true); + } + })); + + let title = format!("Track {}", track.number); + + let row = libhandy::ActionRow::new(); + row.add_prefix(&check); + row.set_activatable_widget(Some(&check)); + row.set_title(Some(&title)); + row.show_all(); + + track_list.add(&row); + } + + this + } + + /// Set the closure to be called when the user has selected tracks. The + /// closure will be called with the indices of the selected tracks as its + /// argument. + pub fn set_selected_cb) + 'static>(&self, cb: F) { + self.selected_cb.replace(Some(Box::new(cb))); + } +} + +impl NavigatorScreen for TrackSelector { + 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/import/track_set_editor.rs b/musicus/src/import/track_set_editor.rs new file mode 100644 index 0000000..8b18856 --- /dev/null +++ b/musicus/src/import/track_set_editor.rs @@ -0,0 +1,283 @@ +use super::disc_source::DiscSource; +use super::track_editor::TrackEditor; +use super::track_selector::TrackSelector; +use crate::backend::Backend; +use crate::database::{Recording, Track, TrackSet}; +use crate::selectors::{PersonSelector, RecordingSelector, WorkSelector}; +use crate::widgets::{Navigator, NavigatorScreen}; +use crate::widgets::new_list::List; +use gettextrs::gettext; +use glib::clone; +use gtk::prelude::*; +use gtk_macros::get_widget; +use libhandy::prelude::*; +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use std::rc::Rc; + +/// A track set before being imported. +#[derive(Clone, Debug)] +pub struct TrackSetData { + pub recording: Recording, + pub tracks: Vec, +} + +/// A track before being imported. +#[derive(Clone, Debug)] +pub struct TrackData { + /// Index of the track source within the medium source's tracks. + pub track_source: usize, + + /// Actual track data. + pub work_parts: Vec, +} + +/// A screen for editing a set of tracks for one recording. +pub struct TrackSetEditor { + backend: Rc, + source: Rc, + widget: gtk::Box, + save_button: gtk::Button, + recording_row: libhandy::ActionRow, + track_list: List, + recording: RefCell>, + tracks: RefCell>, + done_cb: RefCell>>, + navigator: RefCell>>, +} + +impl TrackSetEditor { + /// Create a new track set editor. + pub fn new(backend: Rc, source: Rc) -> Rc { + // Create UI + + let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_set_editor.ui"); + + get_widget!(builder, gtk::Box, widget); + get_widget!(builder, gtk::Button, back_button); + get_widget!(builder, gtk::Button, save_button); + get_widget!(builder, libhandy::ActionRow, recording_row); + get_widget!(builder, gtk::Button, select_recording_button); + get_widget!(builder, gtk::Button, edit_tracks_button); + get_widget!(builder, gtk::Frame, tracks_frame); + + let track_list = List::new(&gettext!("No tracks added")); + tracks_frame.add(&track_list.widget); + + let this = Rc::new(Self { + backend, + source, + widget, + save_button, + recording_row, + track_list, + recording: RefCell::new(None), + tracks: RefCell::new(Vec::new()), + done_cb: RefCell::new(None), + 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(); + } + })); + + this.save_button.connect_clicked(clone!(@strong this => move |_| { + if let Some(cb) = &*this.done_cb.borrow() { + let data = TrackSetData { + recording: this.recording.borrow().clone().unwrap(), + tracks: this.tracks.borrow().clone(), + }; + + cb(data); + } + + let navigator = this.navigator.borrow().clone(); + if let Some(navigator) = navigator { + navigator.pop(); + } + })); + + select_recording_button.connect_clicked(clone!(@strong this => move |_| { + let navigator = this.navigator.borrow().clone(); + if let Some(navigator) = navigator { + let person_selector = PersonSelector::new(this.backend.clone()); + + person_selector.set_selected_cb(clone!(@strong this, @strong navigator => move |person| { + let work_selector = WorkSelector::new(this.backend.clone(), person.clone()); + + work_selector.set_selected_cb(clone!(@strong this, @strong navigator => move |work| { + let recording_selector = RecordingSelector::new(this.backend.clone(), work.clone()); + + recording_selector.set_selected_cb(clone!(@strong this, @strong navigator => move |recording| { + this.recording.replace(Some(recording.clone())); + this.recording_selected(); + + navigator.clone().pop(); + navigator.clone().pop(); + navigator.clone().pop(); + })); + + navigator.clone().push(recording_selector); + })); + + navigator.clone().push(work_selector); + })); + + navigator.clone().push(person_selector); + } + })); + + edit_tracks_button.connect_clicked(clone!(@strong this => move |_| { + let navigator = this.navigator.borrow().clone(); + if let Some(navigator) = navigator { + let selector = TrackSelector::new(Rc::clone(&this.source)); + + selector.set_selected_cb(clone!(@strong this => move |selection| { + let mut tracks = Vec::new(); + + for index in selection { + let data = TrackData { + track_source: index, + work_parts: Vec::new(), + }; + + tracks.push(data); + } + + let length = tracks.len(); + this.tracks.replace(tracks); + this.track_list.update(length); + this.autofill_parts(); + })); + + navigator.push(selector); + } + })); + + this.track_list.set_make_widget(clone!(@strong this => move |index| { + let track = &this.tracks.borrow()[index]; + + let mut title_parts = Vec::::new(); + + if let Some(recording) = &*this.recording.borrow() { + for part in &track.work_parts { + title_parts.push(recording.work.parts[*part].title.clone()); + } + } + + let title = if title_parts.is_empty() { + gettext("Unknown") + } else { + title_parts.join(", ") + }; + + let number = this.source.tracks[track.track_source].number; + let subtitle = format!("Track {}", number); + + let edit_image = gtk::Image::from_icon_name(Some("document-edit-symbolic"), gtk::IconSize::Button); + let edit_button = gtk::Button::new(); + edit_button.set_relief(gtk::ReliefStyle::None); + edit_button.set_valign(gtk::Align::Center); + edit_button.add(&edit_image); + + let row = libhandy::ActionRow::new(); + row.set_activatable(true); + row.set_title(Some(&title)); + row.set_subtitle(Some(&subtitle)); + row.add(&edit_button); + row.set_activatable_widget(Some(&edit_button)); + row.show_all(); + + edit_button.connect_clicked(clone!(@strong this => move |_| { + let recording = this.recording.borrow().clone(); + let navigator = this.navigator.borrow().clone(); + + if let (Some(recording), Some(navigator)) = (recording, navigator) { + let track = &this.tracks.borrow()[index]; + + let editor = TrackEditor::new(recording, track.work_parts.clone()); + + editor.set_selected_cb(clone!(@strong this => move |selection| { + { + let mut tracks = this.tracks.borrow_mut(); + let mut track = &mut tracks[index]; + track.work_parts = selection; + }; + + this.update_tracks(); + })); + + navigator.push(editor); + } + })); + + row.upcast() + })); + + this + } + + /// Set the closure to be called when the user has created the track set. + pub fn set_done_cb(&self, cb: F) { + self.done_cb.replace(Some(Box::new(cb))); + } + + /// Set everything up after selecting a recording. + fn recording_selected(&self) { + if let Some(recording) = &*self.recording.borrow() { + self.recording_row.set_title(Some(&recording.work.get_title())); + self.recording_row.set_subtitle(Some(&recording.get_performers())); + self.save_button.set_sensitive(true); + } + + self.autofill_parts(); + } + + /// Automatically try to put work part information from the selected recording into the + /// selected tracks. + fn autofill_parts(&self) { + if let Some(recording) = &*self.recording.borrow() { + let mut tracks = self.tracks.borrow_mut(); + + for (index, _) in recording.work.parts.iter().enumerate() { + if let Some(mut track) = tracks.get_mut(index) { + track.work_parts = vec![index]; + } else { + break; + } + } + } + + self.update_tracks(); + } + + /// Update the track list. + fn update_tracks(&self) { + let length = self.tracks.borrow().len(); + self.track_list.update(length); + } +} + +impl NavigatorScreen for TrackSetEditor { + 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/main.rs b/musicus/src/main.rs index da2d64f..49be0f5 100644 --- a/musicus/src/main.rs +++ b/musicus/src/main.rs @@ -16,6 +16,7 @@ mod config; mod database; mod dialogs; mod editors; +mod import; mod player; mod screens; mod selectors; @@ -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..60d0bcf 100644 --- a/musicus/src/meson.build +++ b/musicus/src/meson.build @@ -33,9 +33,9 @@ run_command( ) sources = files( - 'backend/client/mod.rs', 'backend/client/ensembles.rs', 'backend/client/instruments.rs', + 'backend/client/mod.rs', 'backend/client/persons.rs', 'backend/client/recordings.rs', 'backend/client/works.rs', @@ -44,12 +44,12 @@ sources = files( 'backend/secure.rs', 'database/ensembles.rs', 'database/instruments.rs', + 'database/medium.rs', 'database/mod.rs', 'database/persons.rs', 'database/recordings.rs', 'database/schema.rs', 'database/thread.rs', - 'database/tracks.rs', 'database/works.rs', 'dialogs/about.rs', 'dialogs/login_dialog.rs', @@ -62,8 +62,6 @@ sources = files( 'editors/performance.rs', 'editors/person.rs', 'editors/recording.rs', - 'editors/track.rs', - 'editors/tracks.rs', 'editors/work.rs', 'editors/work_part.rs', 'editors/work_section.rs', diff --git a/musicus/src/player.rs b/musicus/src/player.rs index 56d1e7d..31ba92e 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 track_set: 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; @@ -248,15 +248,7 @@ impl Player { "file://{}", self.music_library_path .join( - self.playlist - .borrow() - .get(current_item) - .ok_or(anyhow!("Playlist item doesn't exist!"))? - .tracks - .get(current_track) - .ok_or(anyhow!("Track doesn't exist!"))? - .file_name - .clone(), + self.playlist.borrow()[current_item].track_set.tracks[current_track].path.clone(), ) .to_str() .unwrap(), diff --git a/musicus/src/screens/player_screen.rs b/musicus/src/screens/player_screen.rs index 3264e7d..fe3ae81 100644 --- a/musicus/src/screens/player_screen.rs +++ b/musicus/src/screens/player_screen.rs @@ -215,15 +215,17 @@ impl PlayerScreen { elements.push(PlaylistElement { item: item_index, track: 0, - title: item.recording.work.get_title(), - subtitle: Some(item.recording.get_performers()), + title: item.track_set.recording.work.get_title(), + subtitle: Some(item.track_set.recording.get_performers()), playable: false, }); - for (track_index, track) in item.tracks.iter().enumerate() { + for track_index in &item.indices { + let track = &item.track_set.tracks[*track_index]; + let mut parts = Vec::::new(); for part in &track.work_parts { - parts.push(item.recording.work.parts[*part].title.clone()); + parts.push(item.track_set.recording.work.parts[*part].title.clone()); } let title = if parts.is_empty() { @@ -234,7 +236,7 @@ impl PlayerScreen { elements.push(PlaylistElement { item: item_index, - track: track_index, + track: *track_index, title: title, subtitle: None, playable: true, @@ -262,20 +264,20 @@ impl PlayerScreen { next_button.set_sensitive(player.has_next()); let item = &playlist.borrow()[current_item]; - let track = &item.tracks[current_track]; + let track = &item.track_set.tracks[current_track]; let mut parts = Vec::::new(); for part in &track.work_parts { - parts.push(item.recording.work.parts[*part].title.clone()); + parts.push(item.track_set.recording.work.parts[*part].title.clone()); } - let mut title = item.recording.work.get_title(); + let mut title = item.track_set.recording.work.get_title(); if !parts.is_empty() { title = format!("{}: {}", title, parts.join(", ")); } title_label.set_text(&title); - subtitle_label.set_text(&item.recording.get_performers()); + subtitle_label.set_text(&item.track_set.recording.get_performers()); position_label.set_text("0:00"); self_item.replace(current_item); diff --git a/musicus/src/screens/recording_screen.rs b/musicus/src/screens/recording_screen.rs index fa0789e..4d33aa9 100644 --- a/musicus/src/screens/recording_screen.rs +++ b/musicus/src/screens/recording_screen.rs @@ -1,6 +1,6 @@ use crate::backend::*; use crate::database::*; -use crate::editors::{RecordingEditor, TracksEditor}; +use crate::editors::RecordingEditor; use crate::player::*; use crate::widgets::{List, Navigator, NavigatorScreen, NavigatorWindow}; use gettextrs::gettext; @@ -17,7 +17,7 @@ pub struct RecordingScreen { recording: Recording, widget: gtk::Box, stack: gtk::Stack, - tracks: RefCell>, + track_sets: RefCell>, navigator: RefCell>>, } @@ -56,35 +56,43 @@ impl RecordingScreen { recording, widget, stack, - tracks: RefCell::new(Vec::new()), + track_sets: RefCell::new(Vec::new()), navigator: RefCell::new(None), }); - list.set_make_widget(clone!(@strong result => move |track: &Track| { - let mut title_parts = Vec::::new(); - for part in &track.work_parts { - title_parts.push(result.recording.work.parts[*part].title.clone()); - } - - let title = if title_parts.is_empty() { - gettext("Unknown") - } else { - title_parts.join(", ") - }; - - let title_label = gtk::Label::new(Some(&title)); - title_label.set_ellipsize(pango::EllipsizeMode::End); - title_label.set_halign(gtk::Align::Start); - - let file_name_label = gtk::Label::new(Some(&track.file_name)); - file_name_label.set_ellipsize(pango::EllipsizeMode::End); - file_name_label.set_opacity(0.5); - file_name_label.set_halign(gtk::Align::Start); - + list.set_make_widget(clone!(@strong result => move |track_set: &TrackSet| { let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0); vbox.set_border_width(6); - vbox.add(&title_label); - vbox.add(&file_name_label); + vbox.set_spacing(6); + + for track in &track_set.tracks { + let track_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + + let mut title_parts = Vec::::new(); + for part in &track.work_parts { + title_parts.push(result.recording.work.parts[*part].title.clone()); + } + + let title = if title_parts.is_empty() { + gettext("Unknown") + } else { + title_parts.join(", ") + }; + + let title_label = gtk::Label::new(Some(&title)); + title_label.set_ellipsize(pango::EllipsizeMode::End); + title_label.set_halign(gtk::Align::Start); + + let file_name_label = gtk::Label::new(Some(&track.path)); + file_name_label.set_ellipsize(pango::EllipsizeMode::End); + file_name_label.set_opacity(0.5); + file_name_label.set_halign(gtk::Align::Start); + + track_box.add(&title_label); + track_box.add(&file_name_label); + + vbox.add(&track_box); + } vbox.upcast() })); @@ -97,12 +105,12 @@ impl RecordingScreen { })); add_to_playlist_button.connect_clicked(clone!(@strong result => move |_| { - if let Some(player) = result.backend.get_player() { - player.add_item(PlaylistItem { - recording: result.recording.clone(), - tracks: result.tracks.borrow().clone(), - }).unwrap(); - } + // if let Some(player) = result.backend.get_player() { + // player.add_item(PlaylistItem { + // track_set: result.track_sets.get(0).unwrap().clone(), + // indices: result.tracks.borrow().clone(), + // }).unwrap(); + // } })); edit_action.connect_activate(clone!(@strong result => move |_, _| { @@ -121,33 +129,33 @@ impl RecordingScreen { })); edit_tracks_action.connect_activate(clone!(@strong result => move |_, _| { - let editor = TracksEditor::new(result.backend.clone(), Some(result.recording.clone()), result.tracks.borrow().clone()); - let window = NavigatorWindow::new(editor); - window.show(); + // let editor = TracksEditor::new(result.backend.clone(), Some(result.recording.clone()), result.tracks.borrow().clone()); + // let window = NavigatorWindow::new(editor); + // window.show(); })); delete_tracks_action.connect_activate(clone!(@strong result => move |_, _| { let context = glib::MainContext::default(); let clone = result.clone(); context.spawn_local(async move { - clone.backend.db().delete_tracks(&clone.recording.id).await.unwrap(); - clone.backend.library_changed(); + // clone.backend.db().delete_tracks(&clone.recording.id).await.unwrap(); + // clone.backend.library_changed(); }); })); let context = glib::MainContext::default(); let clone = result.clone(); context.spawn_local(async move { - let tracks = clone + let track_sets = clone .backend .db() - .get_tracks(&clone.recording.id) + .get_track_sets(&clone.recording.id) .await .unwrap(); - list.show_items(tracks.clone()); + list.show_items(track_sets.clone()); clone.stack.set_visible_child_name("content"); - clone.tracks.replace(tracks); + clone.track_sets.replace(track_sets); }); result diff --git a/musicus/src/widgets/list.rs b/musicus/src/widgets/list.rs index e781f17..890f866 100644 --- a/musicus/src/widgets/list.rs +++ b/musicus/src/widgets/list.rs @@ -63,6 +63,16 @@ where this } + pub fn set_selectable(&self, selectable: bool) { + let mode = if selectable { + gtk::SelectionMode::Single + } else { + gtk::SelectionMode::None + }; + + self.widget.set_selection_mode(mode); + } + pub fn set_make_widget gtk::Widget + 'static>(&self, make_widget: F) { self.make_widget.replace(Some(Box::new(make_widget))); } diff --git a/musicus/src/widgets/mod.rs b/musicus/src/widgets/mod.rs index 9e59253..089a3ec 100644 --- a/musicus/src/widgets/mod.rs +++ b/musicus/src/widgets/mod.rs @@ -7,6 +7,8 @@ pub use navigator::*; pub mod navigator_window; pub use navigator_window::*; +pub mod new_list; + pub mod player_bar; pub use player_bar::*; diff --git a/musicus/src/widgets/new_list.rs b/musicus/src/widgets/new_list.rs new file mode 100644 index 0000000..98261e6 --- /dev/null +++ b/musicus/src/widgets/new_list.rs @@ -0,0 +1,50 @@ +use gtk::prelude::*; +use std::cell::RefCell; + +/// A simple list of widgets. +pub struct List { + pub widget: gtk::ListBox, + make_widget: RefCell gtk::Widget>>>, +} + +impl List { + /// Create a new list. The list will be empty. + pub fn new(placeholder_text: &str) -> Self { + let placeholder_label = gtk::Label::new(Some(placeholder_text)); + placeholder_label.set_margin_top(6); + placeholder_label.set_margin_bottom(6); + placeholder_label.set_margin_start(6); + placeholder_label.set_margin_end(6); + placeholder_label.show(); + + let widget = gtk::ListBox::new(); + widget.set_selection_mode(gtk::SelectionMode::None); + widget.set_placeholder(Some(&placeholder_label)); + widget.show(); + + Self { + widget, + make_widget: RefCell::new(None), + } + } + + /// Set the closure to be called to construct widgets for the items. + pub fn set_make_widget gtk::Widget + 'static>(&self, make_widget: F) { + self.make_widget.replace(Some(Box::new(make_widget))); + } + + /// Call the make_widget function for each item. This will automatically + /// show all children by indices 0..length. + pub fn update(&self, length: usize) { + for child in self.widget.get_children() { + self.widget.remove(&child); + } + + if let Some(make_widget) = &*self.make_widget.borrow() { + for index in 0..length { + let row = make_widget(index); + self.widget.insert(&row, -1); + } + } + } +} diff --git a/musicus/src/widgets/player_bar.rs b/musicus/src/widgets/player_bar.rs index 9d2cf90..1e61df6 100644 --- a/musicus/src/widgets/player_bar.rs +++ b/musicus/src/widgets/player_bar.rs @@ -112,20 +112,20 @@ impl PlayerBar { next_button.set_sensitive(player.has_next()); let item = &playlist.borrow()[current_item]; - let track = &item.tracks[current_track]; + let track = &item.track_set.tracks[current_track]; let mut parts = Vec::::new(); for part in &track.work_parts { - parts.push(item.recording.work.parts[*part].title.clone()); + parts.push(item.track_set.recording.work.parts[*part].title.clone()); } - let mut title = item.recording.work.get_title(); + let mut title = item.track_set.recording.work.get_title(); if !parts.is_empty() { title = format!("{}: {}", title, parts.join(", ")); } title_label.set_text(&title); - subtitle_label.set_text(&item.recording.get_performers()); + subtitle_label.set_text(&item.track_set.recording.get_performers()); position_label.set_text("0:00"); } )); diff --git a/musicus/src/window.rs b/musicus/src/window.rs index 066cab4..429a870 100644 --- a/musicus/src/window.rs +++ b/musicus/src/window.rs @@ -1,6 +1,6 @@ use crate::backend::*; use crate::dialogs::*; -use crate::editors::TracksEditor; +use crate::import::SourceSelector; use crate::screens::*; use crate::widgets::*; use futures::prelude::*; @@ -85,13 +85,17 @@ impl Window { })); add_button.connect_clicked(clone!(@strong result => move |_| { - let editor = TracksEditor::new(result.backend.clone(), None, Vec::new()); + // let editor = TracksEditor::new(result.backend.clone(), None, Vec::new()); - editor.set_callback(clone!(@strong result => move || { - result.reload(); - })); + // editor.set_callback(clone!(@strong result => move || { + // result.reload(); + // })); - let window = NavigatorWindow::new(editor); + // let window = NavigatorWindow::new(editor); + // window.show(); + + let dialog = SourceSelector::new(result.backend.clone()); + let window = NavigatorWindow::new(dialog); window.show(); })); @@ -107,6 +111,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",