mirror of
https://github.com/johrpan/musicus.git
synced 2025-10-26 11:47:25 +01:00
Import in separate crate and change source ID calculation
This commit is contained in:
parent
c2c811e321
commit
aeb7da73c9
20 changed files with 479 additions and 379 deletions
|
|
@ -1,2 +1,2 @@
|
|||
[workspace]
|
||||
members = ["backend", "client", "database", "musicus"]
|
||||
members = ["backend", "client", "database", "import", "musicus"]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ gstreamer-player = "0.16.3"
|
|||
log = "0.4.14"
|
||||
musicus_client = { version = "0.1.0", path = "../client" }
|
||||
musicus_database = { version = "0.1.0", path = "../database" }
|
||||
musicus_import = { version = "0.1.0", path = "../import" }
|
||||
thiserror = "1.0.23"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use std::rc::Rc;
|
|||
|
||||
pub use musicus_client as client;
|
||||
pub use musicus_database as db;
|
||||
pub use musicus_import as import;
|
||||
|
||||
pub mod error;
|
||||
pub use error::*;
|
||||
|
|
|
|||
|
|
@ -43,22 +43,6 @@
|
|||
"*.a"
|
||||
],
|
||||
"modules" : [
|
||||
{
|
||||
"name": "libdiscid",
|
||||
"buildsystem": "cmake-ninja",
|
||||
"builddir" : true,
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/metabrainz/libdiscid/archive/v0.6.2.tar.gz",
|
||||
"sha256": "a9b73b030603ce707941a3aab4f46649fa5029025e7e2bfbbe0c3c93a86d7b20"
|
||||
}
|
||||
],
|
||||
"cleanup": [
|
||||
"/include",
|
||||
"/lib/pkgconfig"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cdparanoia",
|
||||
"buildsystem": "simple",
|
||||
|
|
|
|||
16
import/Cargo.toml
Normal file
16
import/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "musicus_import"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.13.0"
|
||||
futures-channel = "0.3.5"
|
||||
glib = { git = "https://github.com/gtk-rs/gtk-rs/", features = ["v2_64"] }
|
||||
gstreamer = "0.16.5"
|
||||
gstreamer-pbutils = "0.16.5"
|
||||
log = "0.4.14"
|
||||
once_cell = "1.5.2"
|
||||
rand = "0.7.3"
|
||||
thiserror = "1.0.23"
|
||||
sha2 = "0.9.3"
|
||||
165
import/src/disc.rs
Normal file
165
import/src/disc.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
use crate::error::{Error, Result};
|
||||
use crate::session::{ImportSession, ImportTrack};
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer::{ClockTime, ElementFactory, MessageType, MessageView, TocEntryType};
|
||||
use gstreamer::tags::{Duration, TrackNumber};
|
||||
use sha2::{Sha256, Digest};
|
||||
use std::path::PathBuf;
|
||||
use log::info;
|
||||
|
||||
/// Create a new import session for the default disc drive.
|
||||
pub(super) fn new() -> Result<ImportSession> {
|
||||
let mut tracks = Vec::new();
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
// Build the GStreamer pipeline. It will contain a fakesink initially to be able to run it
|
||||
// forward to the paused state without specifying a file name before knowing the tracks.
|
||||
|
||||
let cdparanoiasrc = ElementFactory::make("cdparanoiasrc", None)?;
|
||||
let queue = ElementFactory::make("queue", None)?;
|
||||
let audioconvert = ElementFactory::make("audioconvert", None)?;
|
||||
let flacenc = ElementFactory::make("flacenc", None)?;
|
||||
let fakesink = gstreamer::ElementFactory::make("fakesink", None)?;
|
||||
|
||||
let pipeline = gstreamer::Pipeline::new(None);
|
||||
pipeline.add_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &fakesink])?;
|
||||
gstreamer::Element::link_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &fakesink])?;
|
||||
|
||||
let bus = pipeline.get_bus().ok_or(Error::u(String::from("Failed to get bus from pipeline.")))?;
|
||||
|
||||
// Run the pipeline into the paused state and wait for the resulting TOC message on the bus.
|
||||
|
||||
pipeline.set_state(gstreamer::State::Paused)?;
|
||||
|
||||
let msg = bus.timed_pop_filtered(ClockTime::from_seconds(5),
|
||||
&vec![MessageType::Toc, MessageType::Error]);
|
||||
|
||||
let toc = match msg {
|
||||
Some(msg) => match msg.view() {
|
||||
MessageView::Error(err) => Err(Error::os(err.get_error())),
|
||||
MessageView::Toc(toc) => Ok(toc.get_toc().0),
|
||||
_ => Err(Error::u(format!("Unexpected message from GStreamer: {:?}", msg))),
|
||||
},
|
||||
None => Err(Error::Timeout(
|
||||
format!("Timeout while waiting for first message from GStreamer."))),
|
||||
}?;
|
||||
|
||||
pipeline.set_state(gstreamer::State::Ready)?;
|
||||
|
||||
// Replace the fakesink with the real filesink. This won't need to be synced to the pipeline
|
||||
// state, because we will set the whole pipeline's state to playing later.
|
||||
|
||||
gstreamer::Element::unlink(&flacenc, &fakesink);
|
||||
fakesink.set_state(gstreamer::State::Null)?;
|
||||
pipeline.remove(&fakesink)?;
|
||||
|
||||
let filesink = gstreamer::ElementFactory::make("filesink", None)?;
|
||||
pipeline.add(&filesink)?;
|
||||
gstreamer::Element::link(&flacenc, &filesink)?;
|
||||
|
||||
// Get track data from the toc message that was received above.
|
||||
|
||||
let tmp_dir = create_tmp_dir()?;
|
||||
|
||||
for entry in toc.get_entries() {
|
||||
if entry.get_entry_type() == TocEntryType::Track {
|
||||
let duration = entry.get_tags()
|
||||
.ok_or(Error::u(String::from("No tags in TOC entry.")))?
|
||||
.get::<Duration>()
|
||||
.ok_or(Error::u(String::from("No duration tag found in TOC entry.")))?
|
||||
.get()
|
||||
.ok_or(Error::u(String::from("Failed to unwrap duration tag from TOC entry.")))?
|
||||
.mseconds()
|
||||
.ok_or(Error::u(String::from("Failed to unwrap track duration.")))?;
|
||||
|
||||
let number = entry.get_tags()
|
||||
.ok_or(Error::u(String::from("No tags in TOC entry.")))?
|
||||
.get::<TrackNumber>()
|
||||
.ok_or(Error::u(String::from("No track number tag found in TOC entry.")))?
|
||||
.get()
|
||||
.ok_or(Error::u(
|
||||
String::from("Failed to unwrap track number tag from TOC entry.")))?;
|
||||
|
||||
hasher.update(duration.to_le_bytes());
|
||||
|
||||
let name = format!("Track {}", number);
|
||||
|
||||
let file_name = format!("track_{:02}.flac", number);
|
||||
let mut path = tmp_dir.clone();
|
||||
path.push(file_name);
|
||||
|
||||
let track = ImportTrack {
|
||||
number,
|
||||
name,
|
||||
path,
|
||||
duration,
|
||||
};
|
||||
|
||||
tracks.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
let source_id = base64::encode_config(hasher.finalize(), base64::URL_SAFE);
|
||||
|
||||
info!("Successfully loaded audio CD with {} tracks.", tracks.len());
|
||||
info!("Source ID: {}", source_id);
|
||||
|
||||
let tracks_clone = tracks.clone();
|
||||
let copy = move || {
|
||||
for track in &tracks_clone {
|
||||
info!("Starting to rip track {}.", track.number);
|
||||
|
||||
cdparanoiasrc.set_property("track", &track.number)?;
|
||||
|
||||
// The filesink needs to be reset to be able to change the file location.
|
||||
filesink.set_state(gstreamer::State::Null)?;
|
||||
|
||||
let path = track.path.to_str().unwrap();
|
||||
filesink.set_property("location", &path)?;
|
||||
|
||||
// This will also affect the filesink as expected.
|
||||
pipeline.set_state(gstreamer::State::Playing)?;
|
||||
|
||||
for msg in bus.iter_timed(ClockTime::none()) {
|
||||
match msg.view() {
|
||||
MessageView::Eos(..) => {
|
||||
info!("Finished ripping track {}.", track.number);
|
||||
pipeline.set_state(gstreamer::State::Ready)?;
|
||||
break;
|
||||
},
|
||||
MessageView::Error(err) => {
|
||||
pipeline.set_state(gstreamer::State::Null)?;
|
||||
Err(Error::os(err.get_error()))?;
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pipeline.set_state(gstreamer::State::Null)?;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let session = ImportSession {
|
||||
source_id,
|
||||
tracks,
|
||||
copy: Some(Box::new(copy)),
|
||||
};
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Create a new temporary directory and return its path.
|
||||
fn create_tmp_dir() -> Result<PathBuf> {
|
||||
let mut tmp_dir = glib::get_tmp_dir().ok_or(Error::u(
|
||||
String::from("Failed to get temporary directory using glib::get_tmp_dir().")))?;
|
||||
|
||||
let dir_name = format!("musicus-{}", rand::random::<u64>());
|
||||
tmp_dir.push(dir_name);
|
||||
|
||||
std::fs::create_dir(&tmp_dir)?;
|
||||
|
||||
Ok(tmp_dir)
|
||||
}
|
||||
|
||||
96
import/src/error.rs
Normal file
96
import/src/error.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use std::error;
|
||||
|
||||
/// An error within an import session.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
/// A timeout was reached.
|
||||
#[error("{0}")]
|
||||
Timeout(String),
|
||||
|
||||
/// Some common error.
|
||||
#[error("{msg}")]
|
||||
Other {
|
||||
/// The error message.
|
||||
msg: String,
|
||||
|
||||
#[source]
|
||||
source: Option<Box<dyn error::Error + Send + Sync>>,
|
||||
},
|
||||
|
||||
/// Something unexpected happened.
|
||||
#[error("{msg}")]
|
||||
Unexpected {
|
||||
/// The error message.
|
||||
msg: String,
|
||||
|
||||
#[source]
|
||||
source: Option<Box<dyn error::Error + Send + Sync>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Create a new error without an explicit source.
|
||||
pub(super) fn o(msg: String) -> Self {
|
||||
Self::Unexpected {
|
||||
msg,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new error with an explicit source.
|
||||
pub(super) fn os(source: impl error::Error + Send + Sync + 'static) -> Self {
|
||||
Self::Unexpected {
|
||||
msg: format!("An error has happened: {}", source),
|
||||
source: Some(Box::new(source)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new unexpected error without an explicit source.
|
||||
pub(super) fn u(msg: String) -> Self {
|
||||
Self::Unexpected {
|
||||
msg,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new unexpected error with an explicit source.
|
||||
pub(super) fn us(source: impl error::Error + Send + Sync + 'static) -> Self {
|
||||
Self::Unexpected {
|
||||
msg: format!("An unexpected error has happened: {}", source),
|
||||
source: Some(Box::new(source)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<futures_channel::oneshot::Canceled> for Error {
|
||||
fn from(err: futures_channel::oneshot::Canceled) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gstreamer::glib::Error> for Error {
|
||||
fn from(err: gstreamer::glib::Error) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gstreamer::glib::BoolError> for Error {
|
||||
fn from(err: gstreamer::glib::BoolError) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gstreamer::StateChangeError> for Error {
|
||||
fn from(err: gstreamer::StateChangeError) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::us(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
64
import/src/folder.rs
Normal file
64
import/src/folder.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use crate::error::{Error, Result};
|
||||
use crate::session::{ImportSession, ImportTrack};
|
||||
use gstreamer::ClockTime;
|
||||
use gstreamer_pbutils::Discoverer;
|
||||
use log::{warn, info};
|
||||
use sha2::{Sha256, Digest};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Create a new import session for the specified folder.
|
||||
pub(super) fn new(path: PathBuf) -> Result<ImportSession> {
|
||||
let mut tracks = Vec::new();
|
||||
let mut number: u32 = 1;
|
||||
let mut hasher = Sha256::new();
|
||||
let discoverer = Discoverer::new(ClockTime::from_seconds(1))?;
|
||||
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
|
||||
if entry.file_type()?.is_file() {
|
||||
let path = entry.path();
|
||||
|
||||
let uri = glib::filename_to_uri(&path, None)
|
||||
.or(Err(Error::u(format!("Failed to create URI from path: {:?}", path))))?;
|
||||
|
||||
let info = discoverer.discover_uri(&uri)?;
|
||||
|
||||
if !info.get_audio_streams().is_empty() {
|
||||
let duration = info.get_duration().mseconds()
|
||||
.ok_or(Error::u(format!("Failed to get duration for {}.", uri)))?;
|
||||
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.into_string()
|
||||
.or(Err(Error::u(format!(
|
||||
"Failed to convert OsString to String: {:?}", entry.file_name()))))?;
|
||||
|
||||
hasher.update(duration.to_le_bytes());
|
||||
|
||||
let track = ImportTrack {
|
||||
number,
|
||||
name,
|
||||
path,
|
||||
duration,
|
||||
};
|
||||
|
||||
tracks.push(track);
|
||||
number += 1;
|
||||
} else {
|
||||
warn!("File {} skipped, because it doesn't contain any audio streams.", uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let source_id = base64::encode_config(hasher.finalize(), base64::URL_SAFE);
|
||||
|
||||
info!("Source ID: {}", source_id);
|
||||
|
||||
let session = ImportSession {
|
||||
source_id,
|
||||
tracks,
|
||||
copy: None,
|
||||
};
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
8
import/src/lib.rs
Normal file
8
import/src/lib.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
pub use session::{ImportSession, ImportTrack};
|
||||
pub use error::{Error, Result};
|
||||
|
||||
pub mod error;
|
||||
pub mod session;
|
||||
|
||||
mod disc;
|
||||
mod folder;
|
||||
91
import/src/session.rs
Normal file
91
import/src/session.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
use crate::{disc, folder};
|
||||
use crate::error::Result;
|
||||
use futures_channel::oneshot;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Interface for importing audio tracks from a medium or folder.
|
||||
pub struct ImportSession {
|
||||
/// A string identifying the source as specific as possible across platforms and formats.
|
||||
pub(super) source_id: String,
|
||||
|
||||
/// The tracks that are available on the source.
|
||||
pub(super) tracks: Vec<ImportTrack>,
|
||||
|
||||
/// A closure that has to be called to copy the tracks if set.
|
||||
pub(super) copy: Option<Box<dyn Fn() -> Result<()> + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ImportSession {
|
||||
/// Create a new import session for a audio CD.
|
||||
pub async fn audio_cd() -> Result<Arc<Self>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
thread::spawn(move || {
|
||||
let result = disc::new();
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
|
||||
Ok(Arc::new(receiver.await??))
|
||||
}
|
||||
|
||||
/// Create a new import session for a folder.
|
||||
pub async fn folder(path: PathBuf) -> Result<Arc<Self>> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
thread::spawn(move || {
|
||||
let result = folder::new(path);
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
|
||||
Ok(Arc::new(receiver.await??))
|
||||
}
|
||||
|
||||
/// Get a string identifying the source as specific as possible across platforms and mediums.
|
||||
pub fn source_id(&self) -> &str {
|
||||
&self.source_id
|
||||
}
|
||||
|
||||
/// Get the tracks that are available on the source.
|
||||
pub fn tracks(&self) -> &[ImportTrack] {
|
||||
&self.tracks
|
||||
}
|
||||
|
||||
/// Copy the tracks to their advertised locations, if neccessary.
|
||||
pub async fn copy(self: &Arc<Self>) -> Result<()> {
|
||||
if self.copy.is_some() {
|
||||
let clone = Arc::clone(self);
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
thread::spawn(move || {
|
||||
let copy = clone.copy.as_ref().unwrap();
|
||||
sender.send(copy()).unwrap();
|
||||
});
|
||||
|
||||
receiver.await?
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A track on an import source.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImportTrack {
|
||||
/// The track number.
|
||||
pub number: u32,
|
||||
|
||||
/// A human readable identifier for the track. This will be used to present the track for
|
||||
/// selection.
|
||||
pub name: String,
|
||||
|
||||
/// The path to the file where the corresponding audio file is. This file is only required to
|
||||
/// exist, once the import was successfully completed. This will not be the actual file within
|
||||
/// the user's music library, but the temporary location from which it can be copied to the
|
||||
/// music library.
|
||||
pub path: PathBuf,
|
||||
|
||||
/// The track's duration in milliseconds.
|
||||
pub duration: u64,
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ dependency('glib-2.0', version: '>= 2.56')
|
|||
dependency('gio-2.0', version: '>= 2.56')
|
||||
dependency('gstreamer-1.0', version: '>= 1.12')
|
||||
dependency('gtk4', version: '>= 4.0')
|
||||
dependency('libdiscid', version: '>= 0.6.2')
|
||||
dependency('libadwaita-1', version: '>= 1.0')
|
||||
dependency('pango', version: '>= 1.0')
|
||||
dependency('sqlite3', version: '>= 3.20')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ edition = "2018"
|
|||
[dependencies]
|
||||
anyhow = "1.0.33"
|
||||
async-trait = "0.1.42"
|
||||
discid = "0.4.4"
|
||||
futures-channel = "0.3.5"
|
||||
gettext-rs = { version = "0.5.0", features = ["gettext-system"] }
|
||||
gstreamer = "0.16.4"
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
use super::source::{Source, SourceTrack};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use async_trait::async_trait;
|
||||
use discid::DiscId;
|
||||
use futures_channel::oneshot;
|
||||
use gettextrs::gettext;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer::{Element, ElementFactory, Pipeline};
|
||||
use once_cell::sync::OnceCell;
|
||||
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: OnceCell<String>,
|
||||
|
||||
/// The tracks on this disc.
|
||||
tracks: OnceCell<Vec<SourceTrack>>,
|
||||
}
|
||||
|
||||
impl DiscSource {
|
||||
/// Create a new disc source. The source has to be initialized by calling
|
||||
/// load() afterwards.
|
||||
pub fn new() -> Result<Self> {
|
||||
let result = Self {
|
||||
discid: OnceCell::new(),
|
||||
tracks: OnceCell::new(),
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Load the disc from the default disc drive and return the MusicBrainz
|
||||
/// DiscID as well as descriptions of the contained tracks.
|
||||
fn load_priv() -> Result<(String, Vec<SourceTrack>)> {
|
||||
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 name = gettext!("Track {}", number);
|
||||
|
||||
let file_name = format!("track_{:02}.flac", number);
|
||||
|
||||
let mut path = tmp_dir.clone();
|
||||
path.push(file_name);
|
||||
|
||||
let track = SourceTrack {
|
||||
number,
|
||||
name,
|
||||
path,
|
||||
};
|
||||
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
Ok((id, tracks))
|
||||
}
|
||||
|
||||
/// Create a new temporary directory and return its path.
|
||||
// TODO: Move to a more appropriate place.
|
||||
fn create_tmp_dir() -> Result<PathBuf> {
|
||||
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::<u64>());
|
||||
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<Pipeline> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Source for DiscSource {
|
||||
async fn load(&self) -> Result<()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
thread::spawn(|| {
|
||||
let result = Self::load_priv();
|
||||
sender.send(result).unwrap();
|
||||
});
|
||||
|
||||
let (discid, tracks) = receiver.await??;
|
||||
|
||||
self.discid.set(discid).unwrap();
|
||||
self.tracks.set(tracks).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tracks(&self) -> Option<&[SourceTrack]> {
|
||||
match self.tracks.get() {
|
||||
Some(tracks) => Some(tracks.as_slice()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn discid(&self) -> Option<String> {
|
||||
match self.discid.get() {
|
||||
Some(discid) => Some(discid.to_owned()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn copy(&self) -> Result<()> {
|
||||
let tracks = self.tracks.get()
|
||||
.ok_or_else(|| anyhow!("Tried to copy disc before loading has finished!"))?;
|
||||
|
||||
for track in 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
use super::source::{Source, SourceTrack};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use futures_channel::oneshot;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
|
||||
/// A folder outside of the music library that contains tracks to import.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FolderSource {
|
||||
/// The path to the folder.
|
||||
path: PathBuf,
|
||||
|
||||
/// The tracks within the folder.
|
||||
tracks: OnceCell<Vec<SourceTrack>>,
|
||||
}
|
||||
|
||||
impl FolderSource {
|
||||
/// Create a new folder source.
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self {
|
||||
path,
|
||||
tracks: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the contents of the folder as tracks.
|
||||
fn load_priv(path: &Path) -> Result<Vec<SourceTrack>> {
|
||||
let mut tracks = Vec::new();
|
||||
let mut number = 1;
|
||||
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
|
||||
if entry.file_type()?.is_file() {
|
||||
let name = entry
|
||||
.file_name()
|
||||
.into_string()
|
||||
.or_else(|_| Err(anyhow!("Failed to convert OsString to String!")))?;
|
||||
|
||||
let path = entry.path();
|
||||
|
||||
let track = SourceTrack {
|
||||
number,
|
||||
name,
|
||||
path,
|
||||
};
|
||||
|
||||
tracks.push(track);
|
||||
number += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tracks)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Source for FolderSource {
|
||||
async fn load(&self) -> Result<()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
|
||||
let path = self.path.clone();
|
||||
thread::spawn(move || {
|
||||
let result = Self::load_priv(&path);
|
||||
sender.send(result).unwrap();
|
||||
});
|
||||
|
||||
let tracks = receiver.await??;
|
||||
self.tracks.set(tracks).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tracks(&self) -> Option<&[SourceTrack]> {
|
||||
match self.tracks.get() {
|
||||
Some(tracks) => Some(tracks.as_slice()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn discid(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn copy(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,22 @@
|
|||
use super::source::Source;
|
||||
use super::track_set_editor::{TrackSetData, TrackSetEditor};
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::{List, Widget};
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::Result;
|
||||
use glib::clone;
|
||||
use glib::prelude::*;
|
||||
use gtk::prelude::*;
|
||||
use gtk_macros::get_widget;
|
||||
use libadwaita::prelude::*;
|
||||
use musicus_backend::db::{generate_id, Medium, Track, TrackSet};
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A dialog for editing metadata while importing music into the music library.
|
||||
pub struct MediumEditor {
|
||||
handle: NavigationHandle<()>,
|
||||
source: Rc<Box<dyn Source>>,
|
||||
session: Arc<ImportSession>,
|
||||
widget: gtk::Stack,
|
||||
done_button: gtk::Button,
|
||||
done_stack: gtk::Stack,
|
||||
|
|
@ -28,9 +29,9 @@ pub struct MediumEditor {
|
|||
track_sets: RefCell<Vec<TrackSetData>>,
|
||||
}
|
||||
|
||||
impl Screen<Rc<Box<dyn Source>>, ()> for MediumEditor {
|
||||
impl Screen<Arc<ImportSession>, ()> for MediumEditor {
|
||||
/// Create a new medium editor.
|
||||
fn new(source: Rc<Box<dyn Source>>, handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
fn new(session: Arc<ImportSession>, handle: NavigationHandle<()>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/medium_editor.ui");
|
||||
|
|
@ -54,7 +55,7 @@ impl Screen<Rc<Box<dyn Source>>, ()> for MediumEditor {
|
|||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
source,
|
||||
session,
|
||||
widget,
|
||||
done_button,
|
||||
done_stack,
|
||||
|
|
@ -88,7 +89,7 @@ impl Screen<Rc<Box<dyn Source>>, ()> for MediumEditor {
|
|||
|
||||
add_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(track_set) = push!(this.handle, TrackSetEditor, Rc::clone(&this.source)).await {
|
||||
if let Some(track_set) = push!(this.handle, TrackSetEditor, Arc::clone(&this.session)).await {
|
||||
let length = {
|
||||
let mut track_sets = this.track_sets.borrow_mut();
|
||||
track_sets.push(track_set);
|
||||
|
|
@ -135,7 +136,7 @@ impl Screen<Rc<Box<dyn Source>>, ()> for MediumEditor {
|
|||
}));
|
||||
|
||||
spawn!(@clone this, async move {
|
||||
match this.source.copy().await {
|
||||
match this.session.copy().await {
|
||||
Err(err) => {
|
||||
this.disc_status_page.set_description(Some(&err.to_string()));
|
||||
this.widget.set_visible_child_name("disc_error");
|
||||
|
|
@ -165,7 +166,7 @@ impl MediumEditor {
|
|||
// Convert the track set data to real track sets.
|
||||
|
||||
let mut track_sets = Vec::new();
|
||||
let source_tracks = self.source.tracks().ok_or_else(|| anyhow!("Tracks not loaded!"))?;
|
||||
let import_tracks = self.session.tracks();
|
||||
|
||||
for track_set_data in &*self.track_sets.borrow() {
|
||||
let mut tracks = Vec::new();
|
||||
|
|
@ -173,12 +174,12 @@ impl MediumEditor {
|
|||
for track_data in &track_set_data.tracks {
|
||||
// Copy the corresponding audio file to the music library.
|
||||
|
||||
let track_source = &source_tracks[track_data.track_source];
|
||||
let import_track = &import_tracks[track_data.track_source];
|
||||
|
||||
let mut track_path = path.clone();
|
||||
track_path.push(track_source.path.file_name().unwrap());
|
||||
track_path.push(import_track.path.file_name().unwrap());
|
||||
|
||||
std::fs::copy(&track_source.path, &track_path)?;
|
||||
std::fs::copy(&import_track.path, &track_path)?;
|
||||
|
||||
// Create the real track.
|
||||
|
||||
|
|
@ -201,7 +202,7 @@ impl MediumEditor {
|
|||
let medium = Medium {
|
||||
id: generate_id(),
|
||||
name: self.name_entry.get_text().to_string(),
|
||||
discid: self.source.discid(),
|
||||
discid: Some(self.session.source_id().to_owned()),
|
||||
tracks: track_sets,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
mod disc_source;
|
||||
mod folder_source;
|
||||
mod medium_editor;
|
||||
mod source;
|
||||
mod source_selector;
|
||||
mod track_editor;
|
||||
mod track_selector;
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A source for tracks that can be imported into the music library.
|
||||
#[async_trait]
|
||||
pub trait Source {
|
||||
/// Load the source and discover the contained tracks.
|
||||
async fn load(&self) -> Result<()>;
|
||||
|
||||
/// Get a reference to the tracks within this source, if they are ready.
|
||||
fn tracks(&self) -> Option<&[SourceTrack]>;
|
||||
|
||||
/// Get the DiscID of the corresponging medium, if possible.
|
||||
fn discid(&self) -> Option<String>;
|
||||
|
||||
/// Asynchronously copy the tracks to the files that are advertised within
|
||||
/// their corresponding objects.
|
||||
async fn copy(&self) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Representation of a single track on a source.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SourceTrack {
|
||||
/// 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,
|
||||
|
||||
/// A human readable identifier for the track. This will be used to
|
||||
/// present the track for selection.
|
||||
pub name: String,
|
||||
|
||||
/// The path to the file where the corresponding audio file is. This file
|
||||
/// is only required to exist, once the source's copy method has finished.
|
||||
/// This will not be the actual file within the user's music library, but
|
||||
/// the location from which it can be copied to the music library.
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
use super::medium_editor::MediumEditor;
|
||||
use super::disc_source::DiscSource;
|
||||
use super::folder_source::FolderSource;
|
||||
use super::source::Source;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
use gtk::prelude::*;
|
||||
use gtk_macros::get_widget;
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
|
|
@ -65,11 +63,9 @@ impl Screen<(), ()> for SourceSelector {
|
|||
this.widget.set_visible_child_name("loading");
|
||||
|
||||
spawn!(@clone this, async move {
|
||||
let folder = FolderSource::new(PathBuf::from(path));
|
||||
match folder.load().await {
|
||||
Ok(_) => {
|
||||
let source = Rc::new(Box::new(folder) as Box<dyn Source>);
|
||||
push!(this.handle, MediumEditor, source).await;
|
||||
match ImportSession::folder(PathBuf::from(path)).await {
|
||||
Ok(session) => {
|
||||
push!(this.handle, MediumEditor, session).await;
|
||||
this.handle.pop(Some(()));
|
||||
}
|
||||
Err(err) => {
|
||||
|
|
@ -90,11 +86,9 @@ impl Screen<(), ()> for SourceSelector {
|
|||
this.widget.set_visible_child_name("loading");
|
||||
|
||||
spawn!(@clone this, async move {
|
||||
let disc = DiscSource::new().unwrap();
|
||||
match disc.load().await {
|
||||
Ok(_) => {
|
||||
let source = Rc::new(Box::new(disc) as Box<dyn Source>);
|
||||
push!(this.handle, MediumEditor, source).await;
|
||||
match ImportSession::audio_cd().await {
|
||||
Ok(session) => {
|
||||
push!(this.handle, MediumEditor, session).await;
|
||||
this.handle.pop(Some(()));
|
||||
}
|
||||
Err(err) => {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
use super::source::Source;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
use crate::widgets::Widget;
|
||||
use glib::clone;
|
||||
use gtk::prelude::*;
|
||||
use gtk_macros::get_widget;
|
||||
use libadwaita::prelude::*;
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A screen for selecting tracks from a source.
|
||||
pub struct TrackSelector {
|
||||
handle: NavigationHandle<Vec<usize>>,
|
||||
source: Rc<Box<dyn Source>>,
|
||||
session: Arc<ImportSession>,
|
||||
widget: gtk::Box,
|
||||
select_button: gtk::Button,
|
||||
selection: RefCell<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Screen<Rc<Box<dyn Source>>, Vec<usize>> for TrackSelector {
|
||||
impl Screen<Arc<ImportSession>, Vec<usize>> for TrackSelector {
|
||||
/// Create a new track selector.
|
||||
fn new(source: Rc<Box<dyn Source>>, handle: NavigationHandle<Vec<usize>>) -> Rc<Self> {
|
||||
fn new(session: Arc<ImportSession>, handle: NavigationHandle<Vec<usize>>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_selector.ui");
|
||||
|
|
@ -37,7 +38,7 @@ impl Screen<Rc<Box<dyn Source>>, Vec<usize>> for TrackSelector {
|
|||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
source,
|
||||
session,
|
||||
widget,
|
||||
select_button,
|
||||
selection: RefCell::new(Vec::new()),
|
||||
|
|
@ -54,7 +55,7 @@ impl Screen<Rc<Box<dyn Source>>, Vec<usize>> for TrackSelector {
|
|||
this.handle.pop(Some(selection));
|
||||
}));
|
||||
|
||||
let tracks = this.source.tracks().unwrap();
|
||||
let tracks = this.session.tracks();
|
||||
|
||||
for (index, track) in tracks.iter().enumerate() {
|
||||
let check = gtk::CheckButton::new();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use super::source::Source;
|
||||
use super::track_editor::TrackEditor;
|
||||
use super::track_selector::TrackSelector;
|
||||
use crate::navigator::{NavigationHandle, Screen};
|
||||
|
|
@ -10,8 +9,10 @@ use gtk::prelude::*;
|
|||
use gtk_macros::get_widget;
|
||||
use libadwaita::prelude::*;
|
||||
use musicus_backend::db::Recording;
|
||||
use musicus_backend::import::ImportSession;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A track set before being imported.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -33,7 +34,7 @@ pub struct TrackData {
|
|||
/// A screen for editing a set of tracks for one recording.
|
||||
pub struct TrackSetEditor {
|
||||
handle: NavigationHandle<TrackSetData>,
|
||||
source: Rc<Box<dyn Source>>,
|
||||
session: Arc<ImportSession>,
|
||||
widget: gtk::Box,
|
||||
save_button: gtk::Button,
|
||||
recording_row: libadwaita::ActionRow,
|
||||
|
|
@ -42,9 +43,9 @@ pub struct TrackSetEditor {
|
|||
tracks: RefCell<Vec<TrackData>>,
|
||||
}
|
||||
|
||||
impl Screen<Rc<Box<dyn Source>>, TrackSetData> for TrackSetEditor {
|
||||
impl Screen<Arc<ImportSession>, TrackSetData> for TrackSetEditor {
|
||||
/// Create a new track set editor.
|
||||
fn new(source: Rc<Box<dyn Source>>, handle: NavigationHandle<TrackSetData>) -> Rc<Self> {
|
||||
fn new(session: Arc<ImportSession>, handle: NavigationHandle<TrackSetData>) -> Rc<Self> {
|
||||
// Create UI
|
||||
|
||||
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_set_editor.ui");
|
||||
|
|
@ -62,7 +63,7 @@ impl Screen<Rc<Box<dyn Source>>, TrackSetData> for TrackSetEditor {
|
|||
|
||||
let this = Rc::new(Self {
|
||||
handle,
|
||||
source,
|
||||
session,
|
||||
widget,
|
||||
save_button,
|
||||
recording_row,
|
||||
|
|
@ -97,7 +98,7 @@ impl Screen<Rc<Box<dyn Source>>, TrackSetData> for TrackSetEditor {
|
|||
|
||||
edit_tracks_button.connect_clicked(clone!(@weak this => move |_| {
|
||||
spawn!(@clone this, async move {
|
||||
if let Some(selection) = push!(this.handle, TrackSelector, Rc::clone(&this.source)).await {
|
||||
if let Some(selection) = push!(this.handle, TrackSelector, Arc::clone(&this.session)).await {
|
||||
let mut tracks = Vec::new();
|
||||
|
||||
for index in selection {
|
||||
|
|
@ -134,7 +135,7 @@ impl Screen<Rc<Box<dyn Source>>, TrackSetData> for TrackSetEditor {
|
|||
title_parts.join(", ")
|
||||
};
|
||||
|
||||
let tracks = this.source.tracks().unwrap();
|
||||
let tracks = this.session.tracks();
|
||||
let track_name = &tracks[track.track_source].name;
|
||||
|
||||
let edit_image = gtk::Image::from_icon_name(Some("document-edit-symbolic"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue