Add support for importing an audio CD

This commit is contained in:
Elias Projahn 2020-12-20 11:47:27 +01:00
parent e2d36a88b8
commit 1bc79765be
21 changed files with 1190 additions and 203 deletions

View file

@ -7,6 +7,7 @@ edition = "2018"
anyhow = "1.0.33" anyhow = "1.0.33"
diesel = { version = "1.4.5", features = ["sqlite"] } diesel = { version = "1.4.5", features = ["sqlite"] }
diesel_migrations = "1.4.0" diesel_migrations = "1.4.0"
discid = "0.4.4"
fragile = "1.0.0" fragile = "1.0.0"
futures = "0.3.6" futures = "0.3.6"
futures-channel = "0.3.5" futures-channel = "0.3.5"

View file

@ -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;

View file

@ -1,64 +1,82 @@
CREATE TABLE persons ( CREATE TABLE "persons" (
id TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
first_name TEXT NOT NULL, "first_name" TEXT NOT NULL,
last_name TEXT NOT NULL "last_name" TEXT NOT NULL
); );
CREATE TABLE instruments ( CREATE TABLE "instruments" (
id TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL "name" TEXT NOT NULL
); );
CREATE TABLE works ( CREATE TABLE "works" (
id TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
composer TEXT NOT NULL REFERENCES persons(id), "composer" TEXT NOT NULL REFERENCES "persons"("id"),
title TEXT NOT NULL "title" TEXT NOT NULL
); );
CREATE TABLE instrumentations ( CREATE TABLE "instrumentations" (
id BIGINT NOT NULL PRIMARY KEY, "id" BIGINT NOT NULL PRIMARY KEY,
work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE, "work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
instrument TEXT NOT NULL REFERENCES instruments(id) ON DELETE CASCADE "instrument" TEXT NOT NULL REFERENCES "instruments"("id") ON DELETE CASCADE
); );
CREATE TABLE work_parts ( CREATE TABLE "work_parts" (
id BIGINT NOT NULL PRIMARY KEY, "id" BIGINT NOT NULL PRIMARY KEY,
work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE, "work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
part_index BIGINT NOT NULL, "part_index" BIGINT NOT NULL,
title TEXT NOT NULL, "title" TEXT NOT NULL,
composer TEXT REFERENCES persons(id) "composer" TEXT REFERENCES "persons"("id")
); );
CREATE TABLE work_sections ( CREATE TABLE "work_sections" (
id BIGINT NOT NULL PRIMARY KEY, "id" BIGINT NOT NULL PRIMARY KEY,
work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE, "work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
title TEXT NOT NULL, "title" TEXT NOT NULL,
before_index BIGINT NOT NULL "before_index" BIGINT NOT NULL
); );
CREATE TABLE ensembles ( CREATE TABLE "ensembles" (
id TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL "name" TEXT NOT NULL
); );
CREATE TABLE recordings ( CREATE TABLE "recordings" (
id TEXT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
work TEXT NOT NULL REFERENCES works(id), "work" TEXT NOT NULL REFERENCES "works"("id"),
comment TEXT NOT NULL "comment" TEXT NOT NULL
); );
CREATE TABLE performances ( CREATE TABLE "performances" (
id BIGINT NOT NULL PRIMARY KEY, "id" BIGINT NOT NULL PRIMARY KEY,
recording TEXT NOT NULL REFERENCES recordings(id) ON DELETE CASCADE, "recording" TEXT NOT NULL REFERENCES "recordings"("id") ON DELETE CASCADE,
person TEXT REFERENCES persons(id), "person" TEXT REFERENCES "persons"("id"),
ensemble TEXT REFERENCES ensembles(id), "ensemble" TEXT REFERENCES "ensembles"("id"),
role TEXT REFERENCES instruments(id) "role" TEXT REFERENCES "instruments"("id")
); );
CREATE TABLE tracks ( CREATE TABLE "mediums" (
id BIGINT NOT NULL PRIMARY KEY, "id" TEXT NOT NULL PRIMARY KEY,
file_name TEXT NOT NULL, "name" TEXT NOT NULL,
recording TEXT NOT NULL REFERENCES recordings(id), "discid" TEXT
track_index INTEGER NOT NULL,
work_parts TEXT NOT NULL
); );
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")
);

View file

@ -2,8 +2,13 @@
<gresources> <gresources>
<gresource prefix="/de/johrpan/musicus"> <gresource prefix="/de/johrpan/musicus">
<file preprocess="xml-stripblanks">ui/ensemble_editor.ui</file> <file preprocess="xml-stripblanks">ui/ensemble_editor.ui</file>
<file preprocess="xml-stripblanks">ui/ensemble_selector.ui</file>
<file preprocess="xml-stripblanks">ui/ensemble_screen.ui</file> <file preprocess="xml-stripblanks">ui/ensemble_screen.ui</file>
<file preprocess="xml-stripblanks">ui/ensemble_selector.ui</file>
<file preprocess="xml-stripblanks">ui/import_disc_dialog.ui</file>
<<<<<<< HEAD
<file preprocess="xml-stripblanks">ui/import_folder_dialog.ui</file>
=======
>>>>>>> wip/cd-ripping-old
<file preprocess="xml-stripblanks">ui/instrument_editor.ui</file> <file preprocess="xml-stripblanks">ui/instrument_editor.ui</file>
<file preprocess="xml-stripblanks">ui/instrument_selector.ui</file> <file preprocess="xml-stripblanks">ui/instrument_selector.ui</file>
<file preprocess="xml-stripblanks">ui/login_dialog.ui</file> <file preprocess="xml-stripblanks">ui/login_dialog.ui</file>

View file

@ -0,0 +1,247 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.1 -->
<interface>
<requires lib="gtk+" version="3.24"/>
<requires lib="libhandy" version="1.0"/>
<object class="GtkBox" id="widget">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="HdyHeaderBar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="title" translatable="yes">Import CD</property>
<child>
<object class="GtkButton" id="back_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkStack" id="stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-type">crossfade</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="message-type">error</property>
<property name="revealed">False</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can-focus">False</property>
<property name="spacing">6</property>
<property name="layout-style">end</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child internal-child="content_area">
<object class="GtkBox">
<property name="can-focus">False</property>
<property name="spacing">16</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Failed to load the CD. Make sure you have inserted it into your drive.</property>
<property name="wrap">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="border-width">18</property>
<property name="orientation">vertical</property>
<property name="spacing">18</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="opacity">0.5019607843137255</property>
<property name="pixel-size">80</property>
<property name="icon-name">media-optical-cd-audio-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="opacity">0.5019607843137255</property>
<property name="label" translatable="yes">Import from audio CD</property>
<attributes>
<attribute name="size" value="16384"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="opacity">0.5019607843137255</property>
<property name="label" translatable="yes">Insert an audio compact disc into your drive and click the button below. The disc will be copied in the background while you set up the metadata.</property>
<property name="justify">center</property>
<property name="wrap">True</property>
<property name="max-width-chars">40</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkButton" id="import_button">
<property name="label" translatable="yes">Import</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="halign">center</property>
<style>
<class name="suggested-action"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="name">start</property>
</packing>
</child>
<child>
<object class="GtkSpinner">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="active">True</property>
</object>
<packing>
<property name="name">loading</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can-focus">True</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="shadow-type">none</property>
<child>
<object class="HdyClamp">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="maximum-size">500</property>
<property name="tightening-threshold">300</property>
<child>
<object class="GtkFrame" id="frame">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<property name="label-xalign">0</property>
<property name="shadow-type">in</property>
<child>
<placeholder/>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="name">content</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</interface>

View file

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.24"/>
<requires lib="libhandy" version="1.0"/>
<object class="GtkBox" id="widget">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="HdyHeaderBar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="title" translatable="yes">Import folder</property>
<child>
<object class="GtkButton" id="back_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="vexpand">True</property>
<property name="border-width">18</property>
<property name="orientation">vertical</property>
<property name="spacing">18</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="opacity">0.50196078431372548</property>
<property name="pixel-size">80</property>
<property name="icon-name">folder-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="opacity">0.50196078431372548</property>
<property name="label" translatable="yes">Import from a folder</property>
<attributes>
<attribute name="size" value="16384"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="opacity">0.50196078431372548</property>
<property name="label" translatable="yes">Select a folder containing audio files with the button below. After adding the metdata in the next step, the folder will be copied to your music library.</property>
<property name="justify">center</property>
<property name="wrap">True</property>
<property name="max-width-chars">40</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkButton" id="import_button">
<property name="label" translatable="yes">Select</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="halign">center</property>
<style>
<class name="suggested-action"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</interface>

View file

@ -327,6 +327,10 @@
</object> </object>
<menu id="menu"> <menu id="menu">
<section> <section>
<item>
<attribute name="label" translatable="yes">Import CD</attribute>
<attribute name="action">win.import-disc</attribute>
</item>
<item> <item>
<attribute name="label" translatable="yes">Preferences</attribute> <attribute name="label" translatable="yes">Preferences</attribute>
<attribute name="action">win.preferences</attribute> <attribute name="action">win.preferences</attribute>

View file

@ -0,0 +1,54 @@
use super::schema::files;
use super::Database;
use anyhow::Result;
use diesel::prelude::*;
/// Table data to associate audio files with tracks.
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "files"]
struct FileRow {
pub file_name: String,
pub track: String,
}
impl Database {
/// Insert or update a file. This assumes that the track is already in the
/// database.
pub fn update_file(&self, file_name: &str, track_id: &str) -> Result<()> {
let row = FileRow {
file_name: file_name.to_owned(),
track: track_id.to_owned(),
};
diesel::insert_into(files::table)
.values(row)
.execute(&self.connection)?;
Ok(())
}
/// Delete an existing file. This will not delete the file from the file
/// system but just the representing row from the database.
pub fn delete_file(&self, file_name: &str) -> Result<()> {
diesel::delete(files::table.filter(files::file_name.eq(file_name)))
.execute(&self.connection)?;
Ok(())
}
/// Get the file name of the audio file for the specified track.
pub fn get_file(&self, track_id: &str) -> Result<Option<String>> {
let row = files::table
.filter(files::track.eq(track_id))
.load::<FileRow>(&self.connection)?
.into_iter()
.next();
let file_name = match row {
Some(row) => Some(row.file_name),
None => None,
};
Ok(file_name)
}
}

View file

@ -0,0 +1,187 @@
use super::generate_id;
use super::schema::{mediums, track_sets, tracks};
use super::{Database, Recording};
use anyhow::{anyhow, Error, Result};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
/// Representation of someting like a physical audio disc or a folder with
/// audio files (i.e. a collection of tracks for one or more recordings).
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Medium {
pub id: String,
pub name: String,
pub discid: Option<String>,
pub tracks: Vec<TrackSet>,
}
/// A set of tracks of one recording within a medium.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TrackSet {
pub recording: Recording,
pub tracks: Vec<Track>,
}
/// A track within a recording on a medium.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Track {
work_parts: Vec<usize>,
}
/// Table data for a [`Medium`].
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "mediums"]
struct MediumRow {
pub id: String,
pub name: String,
pub discid: Option<String>,
}
/// Table data for a [`TrackSet`].
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "track_sets"]
struct TrackSetRow {
pub id: String,
pub medium: String,
pub index: i32,
pub recording: String,
}
/// Table data for a [`Track`].
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "tracks"]
struct TrackRow {
pub id: String,
pub track_set: String,
pub index: i32,
pub work_parts: String,
}
impl Database {
/// Update an existing medium or insert a new one.
pub fn update_medium(&self, medium: Medium) -> Result<()> {
self.defer_foreign_keys()?;
self.connection.transaction::<(), Error, _>(|| {
let medium_id = &medium.id;
// This will also delete the track sets and tracks.
self.delete_medium(medium_id)?;
for (index, track_set) in medium.tracks.iter().enumerate() {
let track_set_id = generate_id();
let track_set_row = TrackSetRow {
id: track_set_id.clone(),
medium: medium_id.to_owned(),
index: index as i32,
recording: track_set.recording.id.clone(),
};
diesel::insert_into(track_sets::table)
.values(track_set_row)
.execute(&self.connection)?;
for (index, track) in track_set.tracks.iter().enumerate() {
let work_parts = track
.work_parts
.iter()
.map(|part_index| part_index.to_string())
.collect::<Vec<String>>()
.join(",");
let track_row = TrackRow {
id: generate_id(),
track_set: track_set_id.clone(),
index: index as i32,
work_parts,
};
diesel::insert_into(tracks::table)
.values(track_row)
.execute(&self.connection)?;
}
}
Ok(())
})?;
Ok(())
}
/// Get an existing medium.
pub fn get_medium(&self, id: &str) -> Result<Option<Medium>> {
let row = mediums::table
.filter(mediums::id.eq(id))
.load::<MediumRow>(&self.connection)?
.into_iter()
.next();
let medium = match row {
Some(row) => Some(self.get_medium_data(row)?),
None => None,
};
Ok(medium)
}
/// Delete a medium and all of its tracks. This will fail, if the music
/// library contains audio files referencing any of those tracks.
pub fn delete_medium(&self, id: &str) -> Result<()> {
diesel::delete(mediums::table.filter(mediums::id.eq(id))).execute(&self.connection)?;
Ok(())
}
/// Retrieve all available information on a medium from related tables.
fn get_medium_data(&self, row: MediumRow) -> Result<Medium> {
let track_set_rows = track_sets::table
.filter(track_sets::medium.eq(&row.id))
.order_by(track_sets::index)
.load::<TrackSetRow>(&self.connection)?;
let mut track_sets = Vec::new();
for track_set_row in track_set_rows {
let recording_id = &track_set_row.recording;
let recording = self
.get_recording(recording_id)?
.ok_or_else(|| anyhow!("No recording with ID: {}", recording_id))?;
let track_rows = tracks::table
.filter(tracks::id.eq(&track_set_row.id))
.order_by(tracks::index)
.load::<TrackRow>(&self.connection)?;
let mut tracks = Vec::new();
for track_row in track_rows {
let work_parts = track_row
.work_parts
.split(',')
.map(|part_index| Ok(str::parse(part_index)?))
.collect::<Result<Vec<usize>>>()?;
let track = Track { work_parts };
tracks.push(track);
}
let track_set = TrackSet { recording, tracks };
track_sets.push(track_set);
}
let medium = Medium {
id: row.id,
name: row.name,
discid: row.discid,
tracks: track_sets,
};
Ok(medium)
}
}

View file

@ -7,6 +7,9 @@ pub use ensembles::*;
pub mod instruments; pub mod instruments;
pub use instruments::*; pub use instruments::*;
pub mod medium;
pub use medium::*;
pub mod persons; pub mod persons;
pub use persons::*; pub use persons::*;
@ -16,8 +19,8 @@ pub use recordings::*;
pub mod thread; pub mod thread;
pub use thread::*; pub use thread::*;
pub mod tracks; pub mod files;
pub use tracks::*; pub use files::*;
pub mod works; pub mod works;
pub use works::*; pub use works::*;

View file

@ -190,6 +190,22 @@ impl Database {
Ok(exists) Ok(exists)
} }
/// Get an existing recording.
pub fn get_recording(&self, id: &str) -> Result<Option<Recording>> {
let row = recordings::table
.filter(recordings::id.eq(id))
.load::<RecordingRow>(&self.connection)?
.into_iter()
.next();
let recording = match row {
Some(row) => Some(self.get_recording_data(row)?),
None => None,
};
Ok(recording)
}
/// Retrieve all available information on a recording from related tables. /// Retrieve all available information on a recording from related tables.
fn get_recording_data(&self, row: RecordingRow) -> Result<Recording> { fn get_recording_data(&self, row: RecordingRow) -> Result<Recording> {
let mut performance_descriptions: Vec<Performance> = Vec::new(); let mut performance_descriptions: Vec<Performance> = Vec::new();

View file

@ -5,6 +5,13 @@ table! {
} }
} }
table! {
files (file_name) {
file_name -> Text,
track -> Text,
}
}
table! { table! {
instrumentations (id) { instrumentations (id) {
id -> BigInt, id -> BigInt,
@ -20,6 +27,14 @@ table! {
} }
} }
table! {
mediums (id) {
id -> Text,
name -> Text,
discid -> Nullable<Text>,
}
}
table! { table! {
performances (id) { performances (id) {
id -> BigInt, id -> BigInt,
@ -47,11 +62,19 @@ table! {
} }
table! { table! {
tracks (id) { track_sets (id) {
id -> BigInt, id -> Text,
file_name -> Text, medium -> Text,
index -> Integer,
recording -> Text, recording -> Text,
track_index -> Integer, }
}
table! {
tracks (id) {
id -> Text,
track_set -> Text,
index -> Integer,
work_parts -> Text, work_parts -> Text,
} }
} }
@ -83,6 +106,7 @@ table! {
} }
} }
joinable!(files -> tracks (track));
joinable!(instrumentations -> instruments (instrument)); joinable!(instrumentations -> instruments (instrument));
joinable!(instrumentations -> works (work)); joinable!(instrumentations -> works (work));
joinable!(performances -> ensembles (ensemble)); joinable!(performances -> ensembles (ensemble));
@ -90,7 +114,9 @@ joinable!(performances -> instruments (role));
joinable!(performances -> persons (person)); joinable!(performances -> persons (person));
joinable!(performances -> recordings (recording)); joinable!(performances -> recordings (recording));
joinable!(recordings -> works (work)); 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 -> persons (composer));
joinable!(work_parts -> works (work)); joinable!(work_parts -> works (work));
joinable!(work_sections -> works (work)); joinable!(work_sections -> works (work));
@ -98,11 +124,14 @@ joinable!(works -> persons (composer));
allow_tables_to_appear_in_same_query!( allow_tables_to_appear_in_same_query!(
ensembles, ensembles,
files,
instrumentations, instrumentations,
instruments, instruments,
mediums,
performances, performances,
persons, persons,
recordings, recordings,
track_sets,
tracks, tracks,
work_parts, work_parts,
work_sections, work_sections,

View file

@ -28,9 +28,12 @@ enum Action {
GetRecordingsForEnsemble(String, Sender<Result<Vec<Recording>>>), GetRecordingsForEnsemble(String, Sender<Result<Vec<Recording>>>),
GetRecordingsForWork(String, Sender<Result<Vec<Recording>>>), GetRecordingsForWork(String, Sender<Result<Vec<Recording>>>),
RecordingExists(String, Sender<Result<bool>>), RecordingExists(String, Sender<Result<bool>>),
UpdateTracks(String, Vec<Track>, Sender<Result<()>>), UpdateMedium(Medium, Sender<Result<()>>),
DeleteTracks(String, Sender<Result<()>>), GetMedium(String, Sender<Result<Option<Medium>>>),
GetTracks(String, Sender<Result<Vec<Track>>>), DeleteMedium(String, Sender<Result<()>>),
UpdateFile(String, String, Sender<Result<()>>),
DeleteFile(String, Sender<Result<()>>),
GetFile(String, Sender<Result<Option<String>>>),
Stop(Sender<()>), Stop(Sender<()>),
} }
@ -124,16 +127,23 @@ impl DbThread {
RecordingExists(id, sender) => { RecordingExists(id, sender) => {
sender.send(db.recording_exists(&id)).unwrap(); sender.send(db.recording_exists(&id)).unwrap();
} }
UpdateTracks(recording_id, tracks, sender) => { UpdateMedium(medium, sender) => {
sender sender.send(db.update_medium(medium)).unwrap();
.send(db.update_tracks(&recording_id, tracks))
.unwrap();
} }
DeleteTracks(recording_id, sender) => { GetMedium(id, sender) => {
sender.send(db.delete_tracks(&recording_id)).unwrap(); sender.send(db.get_medium(&id)).unwrap();
} }
GetTracks(recording_id, sender) => { DeleteMedium(id, sender) => {
sender.send(db.get_tracks(&recording_id)).unwrap(); 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) => { Stop(sender) => {
sender.send(()).unwrap(); sender.send(()).unwrap();
@ -312,28 +322,63 @@ impl DbThread {
receiver.await? receiver.await?
} }
/// Add or change the tracks associated with a recording. This will fail, if there are still /// Update an existing medium or insert a new one.
/// other items referencing this recording. pub async fn update_medium(&self, medium: Medium) -> Result<()> {
pub async fn update_tracks(&self, recording_id: &str, tracks: Vec<Track>) -> Result<()> {
let (sender, receiver) = oneshot::channel(); let (sender, receiver) = oneshot::channel();
self.action_sender self.action_sender.send(UpdateMedium(medium, sender))?;
.send(UpdateTracks(recording_id.to_string(), tracks, sender))?;
receiver.await? receiver.await?
} }
/// Delete all tracks associated with a recording. /// Delete an existing medium. This will fail, if there are still other
pub async fn delete_tracks(&self, recording_id: &str) -> Result<()> { /// items referencing this medium.
pub async fn delete_medium(&self, id: &str) -> Result<()> {
let (sender, receiver) = oneshot::channel(); let (sender, receiver) = oneshot::channel();
self.action_sender self.action_sender
.send(DeleteTracks(recording_id.to_string(), sender))?; .send(DeleteMedium(id.to_owned(), sender))?;
receiver.await? receiver.await?
} }
/// Get all tracks associated with a recording. /// Get an existing medium.
pub async fn get_tracks(&self, recording_id: &str) -> Result<Vec<Track>> { pub async fn get_medium(&self, id: &str) -> Result<Option<Medium>> {
let (sender, receiver) = oneshot::channel(); 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 self.action_sender
.send(GetTracks(recording_id.to_string(), sender))?; .send(DeleteFile(file_name.to_owned(), sender))?;
receiver.await?
}
/// Get the file name of the audio file for the specified track.
pub async fn get_file(&self, track_id: &str) -> Result<Option<String>> {
let (sender, receiver) = oneshot::channel();
self.action_sender
.send(GetFile(track_id.to_owned(), sender))?;
receiver.await? receiver.await?
} }

View file

@ -1,94 +0,0 @@
use super::schema::tracks;
use super::Database;
use anyhow::{Error, Result};
use diesel::prelude::*;
use std::convert::{TryFrom, TryInto};
/// Table row data for a track.
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "tracks"]
struct TrackRow {
pub id: i64,
pub file_name: String,
pub recording: String,
pub track_index: i32,
pub work_parts: String,
}
/// A structure representing one playable audio file.
#[derive(Debug, Clone)]
pub struct Track {
pub work_parts: Vec<usize>,
pub file_name: String,
}
impl TryFrom<TrackRow> for Track {
type Error = Error;
fn try_from(row: TrackRow) -> Result<Self> {
let mut work_parts = Vec::<usize>::new();
for part in row.work_parts.split(",") {
if !part.is_empty() {
work_parts.push(part.parse()?);
}
}
let track = Track {
work_parts,
file_name: row.file_name,
};
Ok(track)
}
}
impl Database {
/// Insert or update tracks for the specified recording.
pub fn update_tracks(&self, recording_id: &str, tracks: Vec<Track>) -> Result<()> {
self.delete_tracks(recording_id)?;
for (index, track) in tracks.iter().enumerate() {
let row = TrackRow {
id: rand::random(),
file_name: track.file_name.clone(),
recording: recording_id.to_string(),
track_index: index.try_into()?,
work_parts: track
.work_parts
.iter()
.map(|i| i.to_string())
.collect::<Vec<String>>()
.join(","),
};
diesel::insert_into(tracks::table)
.values(row)
.execute(&self.connection)?;
}
Ok(())
}
/// Delete all tracks for the specified recording.
pub fn delete_tracks(&self, recording_id: &str) -> Result<()> {
diesel::delete(tracks::table.filter(tracks::recording.eq(recording_id)))
.execute(&self.connection)?;
Ok(())
}
/// Get all tracks of the specified recording.
pub fn get_tracks(&self, recording_id: &str) -> Result<Vec<Track>> {
let mut tracks = Vec::<Track>::new();
let rows = tracks::table
.filter(tracks::recording.eq(recording_id))
.order_by(tracks::track_index)
.load::<TrackRow>(&self.connection)?;
for row in rows {
tracks.push(row.try_into()?);
}
Ok(tracks)
}
}

View file

@ -0,0 +1,211 @@
use crate::backend::Backend;
use crate::ripper::Ripper;
use crate::widgets::{List, Navigator, NavigatorScreen};
use anyhow::Result;
use glib::clone;
use gtk::prelude::*;
use gtk_macros::get_widget;
use std::cell::RefCell;
use std::rc::Rc;
/// The current status of a ripped track.
#[derive(Debug, Clone)]
enum RipStatus {
None,
Ripping,
Ready,
Error,
}
/// Representation of a track on the ripped disc.
#[derive(Debug, Clone)]
struct RipTrack {
pub status: RipStatus,
pub index: u32,
pub title: String,
pub subtitle: String,
}
/// A dialog for importing tracks from a CD.
pub struct ImportDiscDialog {
backend: Rc<Backend>,
widget: gtk::Box,
stack: gtk::Stack,
info_bar: gtk::InfoBar,
list: Rc<List<RipTrack>>,
ripper: Ripper,
tracks: RefCell<Vec<RipTrack>>,
navigator: RefCell<Option<Rc<Navigator>>>,
}
impl ImportDiscDialog {
/// Create a new import disc dialog.
pub fn new(backend: Rc<Backend>) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/import_disc_dialog.ui");
get_widget!(builder, gtk::Box, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Stack, stack);
get_widget!(builder, gtk::InfoBar, info_bar);
get_widget!(builder, gtk::Button, import_button);
get_widget!(builder, gtk::Frame, frame);
let list = List::<RipTrack>::new("No tracks found.");
frame.add(&list.widget);
let mut tmp_dir = glib::get_tmp_dir().unwrap();
let dir_name = format!("musicus-{}", rand::random::<u64>());
tmp_dir.push(dir_name);
std::fs::create_dir(&tmp_dir).unwrap();
let ripper = Ripper::new(tmp_dir.to_str().unwrap());
let this = Rc::new(Self {
backend,
widget,
stack,
info_bar,
list,
ripper,
tracks: RefCell::new(Vec::new()),
navigator: RefCell::new(None),
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@strong this => move |_| {
let navigator = this.navigator.borrow().clone();
if let Some(navigator) = navigator {
navigator.pop();
}
}));
import_button.connect_clicked(clone!(@strong this => move |_| {
this.stack.set_visible_child_name("loading");
let context = glib::MainContext::default();
let clone = this.clone();
context.spawn_local(async move {
match clone.ripper.load_disc().await {
Ok(disc) => {
let mut tracks = Vec::<RipTrack>::new();
for track in disc.first_track..=disc.last_track {
tracks.push(RipTrack {
status: RipStatus::None,
index: track,
title: "Track".to_string(),
subtitle: "Unknown".to_string(),
});
}
clone.tracks.replace(tracks.clone());
clone.list.show_items(tracks);
clone.stack.set_visible_child_name("content");
clone.rip().await.unwrap();
}
Err(_) => {
clone.info_bar.set_revealed(true);
clone.stack.set_visible_child_name("start");
}
}
});
}));
this.list.set_make_widget(|track| {
let title = gtk::Label::new(Some(&format!("{}. {}", track.index, track.title)));
title.set_ellipsize(pango::EllipsizeMode::End);
title.set_halign(gtk::Align::Start);
let subtitle = gtk::Label::new(Some(&track.subtitle));
subtitle.set_ellipsize(pango::EllipsizeMode::End);
subtitle.set_opacity(0.5);
subtitle.set_halign(gtk::Align::Start);
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
vbox.add(&title);
vbox.add(&subtitle);
vbox.set_hexpand(true);
use RipStatus::*;
let status: gtk::Widget = match track.status {
None => {
let placeholder = gtk::Label::new(Option::None);
placeholder.set_property_width_request(16);
placeholder.upcast()
}
Ripping => {
let spinner = gtk::Spinner::new();
spinner.start();
spinner.upcast()
}
Ready => gtk::Image::from_icon_name(
Some("object-select-symbolic"),
gtk::IconSize::Button,
)
.upcast(),
Error => {
gtk::Image::from_icon_name(Some("dialog-error-symbolic"), gtk::IconSize::Dialog)
.upcast()
}
};
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
hbox.set_border_width(6);
hbox.add(&vbox);
hbox.add(&status);
hbox.upcast()
});
this
}
/// Rip the disc in the background.
async fn rip(&self) -> Result<()> {
let mut current_track = 0;
while current_track < self.tracks.borrow().len() {
{
let mut tracks = self.tracks.borrow_mut();
let mut track = &mut tracks[current_track];
track.status = RipStatus::Ripping;
self.list.show_items(tracks.clone());
}
self.ripper
.rip_track(self.tracks.borrow()[current_track].index)
.await
.unwrap();
{
let mut tracks = self.tracks.borrow_mut();
let mut track = &mut tracks[current_track];
track.status = RipStatus::Ready;
self.list.show_items(tracks.clone());
}
current_track += 1;
}
Ok(())
}
}
impl NavigatorScreen for ImportDiscDialog {
fn attach_navigator(&self, navigator: Rc<Navigator>) {
self.navigator.replace(Some(navigator));
}
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
fn detach_navigator(&self) {
self.navigator.replace(None);
}
}

View file

@ -1,3 +1,6 @@
pub mod import_disc;
pub use import_disc::*;
pub mod about; pub mod about;
pub use about::*; pub use about::*;

View file

@ -12,6 +12,7 @@ use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
mod backend; mod backend;
mod ripper;
mod config; mod config;
mod database; mod database;
mod dialogs; mod dialogs;
@ -31,6 +32,7 @@ fn main() {
gettextrs::bindtextdomain("musicus", config::LOCALEDIR); gettextrs::bindtextdomain("musicus", config::LOCALEDIR);
gettextrs::textdomain("musicus"); gettextrs::textdomain("musicus");
gstreamer::init().expect("Failed to initialize GStreamer!");
gtk::init().expect("Failed to initialize GTK!"); gtk::init().expect("Failed to initialize GTK!");
libhandy::init(); libhandy::init();
resources::init().expect("Failed to initialize resources!"); resources::init().expect("Failed to initialize resources!");

View file

@ -52,6 +52,7 @@ sources = files(
'database/tracks.rs', 'database/tracks.rs',
'database/works.rs', 'database/works.rs',
'dialogs/about.rs', 'dialogs/about.rs',
'dialogs/import_disc.rs',
'dialogs/login_dialog.rs', 'dialogs/login_dialog.rs',
'dialogs/mod.rs', 'dialogs/mod.rs',
'dialogs/preferences.rs', 'dialogs/preferences.rs',
@ -93,6 +94,7 @@ sources = files(
'player.rs', 'player.rs',
'resources.rs', 'resources.rs',
'resources.rs.in', 'resources.rs.in',
'ripper.rs',
'window.rs', 'window.rs',
) )

View file

@ -8,8 +8,8 @@ use std::rc::Rc;
#[derive(Clone)] #[derive(Clone)]
pub struct PlaylistItem { pub struct PlaylistItem {
pub recording: Recording, pub tracks: TrackSet,
pub tracks: Vec<Track>, pub indices: Vec<usize>,
} }
pub struct Player { pub struct Player {
@ -19,11 +19,11 @@ pub struct Player {
current_item: Cell<Option<usize>>, current_item: Cell<Option<usize>>,
current_track: Cell<Option<usize>>, current_track: Cell<Option<usize>>,
playing: Cell<bool>, playing: Cell<bool>,
playlist_cbs: RefCell<Vec<Box<dyn Fn(Vec<PlaylistItem>) -> ()>>>, playlist_cbs: RefCell<Vec<Box<dyn Fn(Vec<PlaylistItem>)>>>,
track_cbs: RefCell<Vec<Box<dyn Fn(usize, usize) -> ()>>>, track_cbs: RefCell<Vec<Box<dyn Fn(usize, usize)>>>,
duration_cbs: RefCell<Vec<Box<dyn Fn(u64) -> ()>>>, duration_cbs: RefCell<Vec<Box<dyn Fn(u64)>>>,
playing_cbs: RefCell<Vec<Box<dyn Fn(bool) -> ()>>>, playing_cbs: RefCell<Vec<Box<dyn Fn(bool)>>>,
position_cbs: RefCell<Vec<Box<dyn Fn(u64) -> ()>>>, position_cbs: RefCell<Vec<Box<dyn Fn(u64)>>>,
} }
impl Player { impl Player {
@ -80,23 +80,23 @@ impl Player {
result result
} }
pub fn add_playlist_cb<F: Fn(Vec<PlaylistItem>) -> () + 'static>(&self, cb: F) { pub fn add_playlist_cb<F: Fn(Vec<PlaylistItem>) + 'static>(&self, cb: F) {
self.playlist_cbs.borrow_mut().push(Box::new(cb)); self.playlist_cbs.borrow_mut().push(Box::new(cb));
} }
pub fn add_track_cb<F: Fn(usize, usize) -> () + 'static>(&self, cb: F) { pub fn add_track_cb<F: Fn(usize, usize) + 'static>(&self, cb: F) {
self.track_cbs.borrow_mut().push(Box::new(cb)); self.track_cbs.borrow_mut().push(Box::new(cb));
} }
pub fn add_duration_cb<F: Fn(u64) -> () + 'static>(&self, cb: F) { pub fn add_duration_cb<F: Fn(u64) + 'static>(&self, cb: F) {
self.duration_cbs.borrow_mut().push(Box::new(cb)); self.duration_cbs.borrow_mut().push(Box::new(cb));
} }
pub fn add_playing_cb<F: Fn(bool) -> () + 'static>(&self, cb: F) { pub fn add_playing_cb<F: Fn(bool) + 'static>(&self, cb: F) {
self.playing_cbs.borrow_mut().push(Box::new(cb)); self.playing_cbs.borrow_mut().push(Box::new(cb));
} }
pub fn add_position_cb<F: Fn(u64) -> () + 'static>(&self, cb: F) { pub fn add_position_cb<F: Fn(u64) + 'static>(&self, cb: F) {
self.position_cbs.borrow_mut().push(Box::new(cb)); self.position_cbs.borrow_mut().push(Box::new(cb));
} }
@ -121,7 +121,7 @@ impl Player {
} }
pub fn add_item(&self, item: PlaylistItem) -> Result<()> { pub fn add_item(&self, item: PlaylistItem) -> Result<()> {
if item.tracks.is_empty() { if item.indices.is_empty() {
Err(anyhow!( Err(anyhow!(
"Tried to add playlist item without tracks to playlist!" "Tried to add playlist item without tracks to playlist!"
)) ))
@ -199,7 +199,7 @@ impl Player {
current_track -= 1; current_track -= 1;
} else if current_item > 0 { } else if current_item > 0 {
current_item -= 1; current_item -= 1;
current_track = playlist[current_item].tracks.len() - 1; current_track = playlist[current_item].indices.len() - 1;
} else { } else {
return Err(anyhow!("No previous track!")); return Err(anyhow!("No previous track!"));
} }
@ -213,7 +213,7 @@ impl Player {
let playlist = self.playlist.borrow(); let playlist = self.playlist.borrow();
let item = &playlist[current_item]; 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 { } else {
false false
} }
@ -231,7 +231,7 @@ impl Player {
let playlist = self.playlist.borrow(); let playlist = self.playlist.borrow();
let item = &playlist[current_item]; let item = &playlist[current_item];
if current_track + 1 < item.tracks.len() { if current_track + 1 < item.indices.len() {
current_track += 1; current_track += 1;
} else if current_item + 1 < playlist.len() { } else if current_item + 1 < playlist.len() {
current_item += 1; current_item += 1;

130
musicus/src/ripper.rs Normal file
View file

@ -0,0 +1,130 @@
use anyhow::{anyhow, bail, Result};
use discid::DiscId;
use futures_channel::oneshot;
use gstreamer::prelude::*;
use gstreamer::{Element, ElementFactory, Pipeline};
use std::cell::RefCell;
use std::thread;
/// A disc that can be ripped.
#[derive(Debug, Clone)]
pub struct RipDisc {
pub discid: String,
pub first_track: u32,
pub last_track: u32,
}
/// An interface for ripping an audio compact disc.
pub struct Ripper {
path: String,
disc: RefCell<Option<RipDisc>>,
}
impl Ripper {
/// Create a new ripper that stores its tracks within the specified folder.
pub fn new(path: &str) -> Self {
Self {
path: path.to_string(),
disc: RefCell::new(None),
}
}
/// Load the disc and return its metadata.
pub async fn load_disc(&self) -> Result<RipDisc> {
let (sender, receiver) = oneshot::channel();
thread::spawn(|| {
let disc = Self::load_disc_priv();
sender.send(disc).unwrap();
});
let disc = receiver.await??;
self.disc.replace(Some(disc.clone()));
Ok(disc)
}
/// Rip one track.
pub async fn rip_track(&self, track: u32) -> Result<()> {
let (sender, receiver) = oneshot::channel();
let path = self.path.clone();
thread::spawn(move || {
let result = Self::rip_track_priv(&path, track);
sender.send(result).unwrap();
});
receiver.await?
}
/// Load the disc and return its metadata.
fn load_disc_priv() -> Result<RipDisc> {
let discid = DiscId::read(None)?;
let id = discid.id();
let first_track = discid.first_track_num() as u32;
let last_track = discid.last_track_num() as u32;
let disc = RipDisc {
discid: id,
first_track,
last_track,
};
Ok(disc)
}
/// Rip one track.
fn rip_track_priv(path: &str, track: u32) -> Result<()> {
let pipeline = Self::build_pipeline(path, track)?;
let bus = pipeline
.get_bus()
.ok_or(anyhow!("Failed to get bus from pipeline!"))?;
pipeline.set_state(gstreamer::State::Playing)?;
for msg in bus.iter_timed(gstreamer::CLOCK_TIME_NONE) {
use gstreamer::MessageView::*;
match msg.view() {
Eos(..) => break,
Error(err) => {
pipeline.set_state(gstreamer::State::Null)?;
bail!("GStreamer error: {:?}!", err);
}
_ => (),
}
}
pipeline.set_state(gstreamer::State::Null)?;
Ok(())
}
/// Build the GStreamer pipeline to rip a track.
fn build_pipeline(path: &str, track: u32) -> Result<Pipeline> {
let cdparanoiasrc = ElementFactory::make("cdparanoiasrc", None)?;
// // TODO: Remove.
// cdparanoiasrc.set_property(
// "device",
// &String::from("/home/johrpan/Diverses/arrau_schumann.iso"),
// )?;
cdparanoiasrc.set_property("track", &track)?;
let queue = ElementFactory::make("queue", None)?;
let audioconvert = ElementFactory::make("audioconvert", None)?;
let flacenc = ElementFactory::make("flacenc", None)?;
let filesink = gstreamer::ElementFactory::make("filesink", None)?;
filesink.set_property("location", &format!("{}/track_{:02}.flac", path, track))?;
let pipeline = gstreamer::Pipeline::new(None);
pipeline.add_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &filesink])?;
Element::link_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &filesink])?;
Ok(pipeline)
}
}

View file

@ -107,6 +107,16 @@ impl Window {
result.stack.set_visible_child_name("content"); 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!( action!(
result.window, result.window,
"preferences", "preferences",