diff --git a/app/src/main/audio/native.ts b/app/src/main/audio/native.ts index 209a212..63a327e 100644 --- a/app/src/main/audio/native.ts +++ b/app/src/main/audio/native.ts @@ -128,8 +128,8 @@ export interface NativeLibrary { usbMedia(key: string): Promise usbImportPreview(volume: string, folder: string | null, policy: string | null): Promise usbImport(volume: string, folder: string | null, policy: string | null): Promise - usbSyncReview(volume: string, playlistIds: string[]): Promise - usbSync(volume: string, playlistIds: string[], review?: unknown): Promise + usbSyncReview(volume: string, playlistIds: string[], convert?: unknown): Promise + usbSync(volume: string, playlistIds: string[], review?: unknown, convert?: unknown): Promise usbRecoveryStatus(volume: string): Promise usbRecover(volume: string): Promise removeTracks(ids: string[]): Promise diff --git a/app/src/main/library.ts b/app/src/main/library.ts index 1d17dbd..1b9af1e 100644 --- a/app/src/main/library.ts +++ b/app/src/main/library.ts @@ -39,7 +39,7 @@ const READS: CommandHandlers = { 'usb.libraries': (lib, p) => lib.usbLibraries(text(p?.volumeId)), 'usb.selection': (lib, p) => lib.usbSelection(text(p?.volumeId)), 'usb.tracks': (lib, p) => lib.usbTracks(text(p?.volumeId), p?.playlistId == null ? null : text(p.playlistId)), - 'usb.syncReview': (lib, p) => lib.usbSyncReview(text(p?.volumeId), list(p?.playlistIds)), + 'usb.syncReview': (lib, p) => lib.usbSyncReview(text(p?.volumeId), list(p?.playlistIds), p?.convert), 'usb.recoveryStatus': (lib, p) => lib.usbRecoveryStatus(text(p?.volumeId)), 'usb.detail': (lib, p) => lib.usbDetail(text(p?.id)), 'usb.importPreview': (lib, p) => @@ -95,7 +95,7 @@ const WRITES: CommandHandlers = { 'track.relocate': (lib, p) => lib.trackRelocate(JSON.stringify(p?.moves ?? {})), 'usb.eject': (lib, p) => lib.usbEject(text(p?.volumeId)), 'usb.format': (lib, p) => lib.usbFormat(text(p?.volumeId)), - 'usb.sync': (lib, p) => lib.usbSync(text(p?.volumeId), list(p?.playlistIds), p?.review), + 'usb.sync': (lib, p) => lib.usbSync(text(p?.volumeId), list(p?.playlistIds), p?.review, p?.convert), 'usb.recover': (lib, p) => lib.usbRecover(text(p?.volumeId)), 'usb.import': (lib, p) => lib.usbImport( diff --git a/app/src/preload/engine.ts b/app/src/preload/engine.ts index 91a10ff..aa068ab 100644 --- a/app/src/preload/engine.ts +++ b/app/src/preload/engine.ts @@ -18,10 +18,32 @@ export interface SyncDiagnostic { allowedResolutions: SyncResolution[] } +export interface SyncPlan { + tracks: number + playlists: number + estimatedPeakBytes: number | null + approximate: boolean + /** Selected tracks copied as they are, converted, or left as the stick has them. */ + copy: number + convert: number + keep: number + /** Tracks the sync takes off the stick. */ + remove: number + /** The stick already holds exactly what the sync would write. */ + upToDate: boolean +} + export interface SyncInspection { token: string diagnostics: SyncDiagnostic[] - plan: { tracks: number; playlists: number; estimatedPeakBytes: number | null; approximate: boolean } | null + plan: SyncPlan | null +} + +export interface SyncConversion { + format: string + sampleRate?: number + bitDepth?: number + bitrate?: number } export interface SyncDecisions { @@ -118,8 +140,8 @@ export interface EngineCommandParams { 'track.relocate': { moves: Record } 'usb.eject': { volumeId: string } 'usb.format': { volumeId: string } - 'usb.syncReview': { volumeId: string; playlistIds: string[] } - 'usb.sync': { volumeId: string; playlistIds: string[]; review?: SyncDecisions } + 'usb.syncReview': { volumeId: string; playlistIds: string[]; convert?: SyncConversion } + 'usb.sync': { volumeId: string; playlistIds: string[]; review?: SyncDecisions; convert?: SyncConversion } 'usb.recoveryStatus': { volumeId: string } 'usb.recover': { volumeId: string } 'usb.import': { volumeId: string; folder?: string | null; policy?: PlaylistPolicy } diff --git a/es-rekordbox-sdk b/es-rekordbox-sdk index 862f571..4a79f93 160000 --- a/es-rekordbox-sdk +++ b/es-rekordbox-sdk @@ -1 +1 @@ -Subproject commit 862f571b571705a7ed55e62c17dac4272bcc0b9e +Subproject commit 4a79f937ccbacbf31e5c11b10c548580e26678e8 diff --git a/native/Cargo.lock b/native/Cargo.lock index 2ba48a9..b99af06 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -682,6 +682,9 @@ dependencies = [ name = "es-usb" version = "1.9.0" dependencies = [ + "es-convert", + "es-tags", + "rb-anlz", "rb-model", "rb-usb", "serde", diff --git a/native/crates/es-engine/src/library.rs b/native/crates/es-engine/src/library.rs index 1325591..69da2d4 100644 --- a/native/crates/es-engine/src/library.rs +++ b/native/crates/es-engine/src/library.rs @@ -709,6 +709,7 @@ impl LibraryService { volume: &Path, playlist_ids: &[String], decisions: Option<&rb_model::conflict::Decisions>, + convert: Option<&crate::convert::Preset>, emit: &impl Fn(ServiceEvent), ) -> Result { let database = self @@ -716,7 +717,7 @@ impl LibraryService { .as_ref() .ok_or_else(|| crate::Error::Other("Library not open".into()))? .database(); - crate::sync::sync_volume(database, volume, playlist_ids, decisions, &|progress| { + crate::sync::sync_volume(database, volume, playlist_ids, decisions, convert, &|progress| { emit(ServiceEvent::UsbSyncProgress(progress)); }) } @@ -725,13 +726,14 @@ impl LibraryService { &self, volume: &Path, playlist_ids: &[String], + convert: Option<&crate::convert::Preset>, ) -> Result { let database = self .session .as_ref() .ok_or_else(|| crate::Error::Other("Library not open".into()))? .database(); - crate::sync::inspect_volume(database, volume, playlist_ids) + crate::sync::inspect_volume(database, volume, playlist_ids, convert) } pub fn usb_recovery_required(&self, volume: &Path) -> bool { diff --git a/native/crates/es-engine/src/sync.rs b/native/crates/es-engine/src/sync.rs index 85ae9e8..dd38cdc 100644 --- a/native/crates/es-engine/src/sync.rs +++ b/native/crates/es-engine/src/sync.rs @@ -10,11 +10,12 @@ use rb_library::Library; use rb_model::conflict::{Decisions, Inspection}; use serde_json::{json, Value}; +use crate::convert::Preset; use crate::{Error, Result}; /// What rekordbox's own analysis marks a fully analysed track with. const ANALYSED: i64 = 105; -const JOB_SCHEMA_VERSION: u32 = 3; +const JOB_SCHEMA_VERSION: u32 = 4; #[derive(Debug, Clone)] pub enum Progress { @@ -126,7 +127,7 @@ fn track_job(share: &Path, track: &ExportTrack) -> Value { }) } -fn job(share: &Path, volume: &Path, bundle: &Bundle, operation: &str, decisions: Option<&Decisions>) -> Result { +fn job(share: &Path, volume: &Path, bundle: &Bundle, operation: &str, decisions: Option<&Decisions>, convert: Option<&Preset>) -> Result { let tracks: Vec = bundle.tracks.iter().map(|t| track_job(share, t)).collect(); let known: std::collections::HashSet = bundle.tracks.iter().map(|t| num(&t.id)).collect(); let mut playlists = Vec::new(); @@ -154,6 +155,9 @@ fn job(share: &Path, volume: &Path, bundle: &Bundle, operation: &str, decisions: "settingsDir": find_settings().map(|p| p.to_string_lossy().into_owned()), "myTags": tags, "tracks": tracks, "playlists": playlists, "decisions": decisions, + "conversion": convert.map(|p| json!({ + "format": p.format, "bitDepth": p.bit_depth, "sampleRate": p.sample_rate, "bitrate": p.bitrate, + })), })) } @@ -198,21 +202,24 @@ impl UsbPublisher for CommandPublisher { } } -/// Copy the bundle's playlists onto `volume` and publish both device databases. +/// Copy the bundle's playlists onto `volume` and publish both device databases. With `convert`, tracks not +/// already in its format go onto the stick converted; the library and its files are left as they are. pub fn sync_volume( lib: &Library, volume: &Path, playlist_ids: &[String], decisions: Option<&Decisions>, + convert: Option<&Preset>, on_progress: &dyn Fn(Progress), ) -> Result { let publisher = CommandPublisher::discover()?; - sync_volume_with_publisher(lib, volume, playlist_ids, decisions, &publisher, on_progress) + sync_volume_with_publisher(lib, volume, playlist_ids, decisions, convert, &publisher, on_progress) } -pub fn inspect_volume(lib: &Library, volume: &Path, playlist_ids: &[String]) -> Result { +/// What syncing would change. `convert` must be what the sync will be given. +pub fn inspect_volume(lib: &Library, volume: &Path, playlist_ids: &[String], convert: Option<&Preset>) -> Result { let publisher = CommandPublisher::discover()?; - inspect_volume_with_publisher(lib, volume, playlist_ids, &publisher) + inspect_volume_with_publisher(lib, volume, playlist_ids, convert, &publisher) } pub fn recovery_required(volume: &Path) -> bool { @@ -227,13 +234,14 @@ pub fn inspect_volume_with_publisher( lib: &Library, volume: &Path, playlist_ids: &[String], + convert: Option<&Preset>, publisher: &impl UsbPublisher, ) -> Result { let volume = volume.canonicalize() .map_err(|_| Error::Other(format!("Volume is not mounted: {}", volume.display())))?; if !volume.is_dir() { return Err(Error::Other(format!("Volume is not mounted: {}", volume.display()))); } let bundle = lib.export_bundle(playlist_ids)?; - let body = job(&lib.share_dir(), &volume, &bundle, "inspect", None)?; + let body = job(&lib.share_dir(), &volume, &bundle, "inspect", None, convert)?; let result = publisher.publish(&body, &|_| {})?; serde_json::from_value(result.get("inspection").cloned() .ok_or_else(|| Error::Other("USB inspection returned no diagnostics".into()))?) @@ -246,6 +254,7 @@ pub fn sync_volume_with_publisher( volume: &Path, playlist_ids: &[String], decisions: Option<&Decisions>, + convert: Option<&Preset>, publisher: &impl UsbPublisher, on_progress: &dyn Fn(Progress), ) -> Result { @@ -256,7 +265,7 @@ pub fn sync_volume_with_publisher( return Err(Error::Other(format!("Volume is not mounted: {}", volume.display()))); } let bundle = lib.export_bundle(playlist_ids)?; - let body = job(&lib.share_dir(), &volume, &bundle, "export", decisions)?; + let body = job(&lib.share_dir(), &volume, &bundle, "export", decisions, convert)?; let result = publisher.publish(&body, on_progress)?; let tracks = result.get("tracks").and_then(Value::as_i64).unwrap_or(0); let playlists = result.get("playlists").and_then(Value::as_i64).unwrap_or(0); @@ -337,11 +346,20 @@ mod tests { #[test] fn usb_job_declares_its_schema_version() { let directory = tempfile::tempdir().unwrap(); - let body = job(directory.path(), directory.path(), &Bundle::default(), "export", None).unwrap(); + let body = job(directory.path(), directory.path(), &Bundle::default(), "export", None, None).unwrap(); assert_eq!(body["schemaVersion"], JOB_SCHEMA_VERSION); assert_eq!(body["operation"], "export"); assert!(body.get("overwrite").is_none()); + assert!(body["conversion"].is_null()); + } + + #[test] + fn a_conversion_travels_as_a_preset_es_usb_can_check_again() { + let directory = tempfile::tempdir().unwrap(); + let preset = crate::convert::clean_preset(&json!({ "format": "wav", "bitDepth": 24, "sampleRate": 44100 })).unwrap(); + let body = job(directory.path(), directory.path(), &Bundle::default(), "inspect", None, Some(&preset)).unwrap(); + assert_eq!(crate::convert::clean_preset(&body["conversion"]).unwrap(), preset); } #[test] @@ -361,6 +379,7 @@ mod tests { &volume, &["playlist-1".into()], None, + None, &FakePublisher, &|_| {}, ) @@ -380,7 +399,7 @@ mod tests { let library = Library::open_live(&database_path, Guard::Off).unwrap(); let volume = directory.path().join("STICK"); std::fs::create_dir_all(&volume).unwrap(); - let review = inspect_volume_with_publisher(&library, &volume, &["playlist-1".into()], &FakePublisher).unwrap(); + let review = inspect_volume_with_publisher(&library, &volume, &["playlist-1".into()], None, &FakePublisher).unwrap(); assert_eq!(review.token, "fixture"); assert!(review.diagnostics.is_empty()); } diff --git a/native/crates/es-engine/tests/device_import.rs b/native/crates/es-engine/tests/device_import.rs index 2ab4ad9..67bc4a9 100644 --- a/native/crates/es-engine/tests/device_import.rs +++ b/native/crates/es-engine/tests/device_import.rs @@ -68,7 +68,7 @@ fn synced_world() -> Option { })); } let job = json!({ - "schemaVersion": 3, + "schemaVersion": 4, "destination": volume, "dbId": 4242, "tracks": tracks, "playlists": [ { "id": 1, "name": "Folder", "parentId": 0, "folder": true, "trackIds": [] }, diff --git a/native/crates/es-engine/tests/sync.rs b/native/crates/es-engine/tests/sync.rs index d18b868..77cd277 100644 --- a/native/crates/es-engine/tests/sync.rs +++ b/native/crates/es-engine/tests/sync.rs @@ -49,7 +49,7 @@ fn a_playlist_synced_to_a_stick_reads_back_with_its_tracks() { let volume = dir.path().join("STICK"); std::fs::create_dir_all(&volume).unwrap(); let phases = RefCell::new(Vec::new()); - let result = sync_volume(&lib, &volume, &["10".to_owned()], None, &|p| { + let result = sync_volume(&lib, &volume, &["10".to_owned()], None, None, &|p| { if let Progress::Step { phase, .. } = p { phases.borrow_mut().push(phase); } @@ -62,7 +62,7 @@ fn a_playlist_synced_to_a_stick_reads_back_with_its_tracks() { assert_eq!(device.tracks.len(), 2); let mix = device.playlists.iter().find(|p| p.name == "Mix").unwrap(); assert_eq!(mix.track_ids.len(), 2); - let review = es_engine::sync::inspect_volume(&lib, &volume, &["10".to_owned()]).unwrap(); + let review = es_engine::sync::inspect_volume(&lib, &volume, &["10".to_owned()], None).unwrap(); assert!(review.diagnostics.is_empty()); assert_eq!(review.plan.as_ref().unwrap().tracks, 2); @@ -78,7 +78,7 @@ fn a_playlist_synced_to_a_stick_reads_back_with_its_tracks() { let foreign_path = volume.join("PIONEER/rbxport/manifest.json"); std::fs::create_dir_all(foreign_path.parent().unwrap()).unwrap(); std::fs::write(&foreign_path, b"foreign application state").unwrap(); - sync_volume(&lib, &volume, &["10".to_owned()], None, &|_| {}).unwrap(); + sync_volume(&lib, &volume, &["10".to_owned()], None, None, &|_| {}).unwrap(); let after: serde_json::Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); for (before, after) in tracks_before.as_array().unwrap().iter().zip(after["tracks"].as_array().unwrap()) { assert_eq!(before["export_id"], after["export_id"]); @@ -96,5 +96,51 @@ fn syncing_nothing_is_refused() { let lib = Library::open_live(&path, Guard::Off).unwrap(); let volume = dir.path().join("STICK"); std::fs::create_dir_all(&volume).unwrap(); - assert!(sync_volume(&lib, &volume, &[], None, &|_| {}).is_err()); + assert!(sync_volume(&lib, &volume, &[], None, None, &|_| {}).is_err()); +} + +#[test] +fn a_converted_sync_leaves_the_library_alone_and_the_next_changes_nothing() { + es_engine::sync::exporter().expect("Build es-usb and set EXPORTSTUDIO_USB_EXPORT before running sync tests"); + if es_engine::convert::ffmpeg_path().is_err() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let path = fixture::build(dir.path(), 5).unwrap(); + { + let conn = fixture::connect(&path).unwrap(); + Rows(&conn).playlist("10", "Mix", 0).unwrap(); + } + let mut lib = Library::open_live(&path, Guard::Off).unwrap(); + let share = lib.share_dir(); + let music = dir.path().join("music"); + std::fs::create_dir_all(&music).unwrap(); + let file = music.join("One.wav"); + wav(&file, 1); + let out = import_files(&mut lib, &share, &[file.to_string_lossy().into_owned()], &Options { artwork: false, playlist: Some("10".into()) }).unwrap(); + let volume = dir.path().join("STICK"); + std::fs::create_dir_all(&volume).unwrap(); + let mp3 = es_engine::convert::clean_preset(&serde_json::json!({ "format": "mp3", "bitrate": 128 })).unwrap(); + let playlists = ["10".to_owned()]; + + sync_volume(&lib, &volume, &playlists, None, Some(&mp3), &|_| {}).unwrap(); + let device = rb_device::read::read(&volume).unwrap().unwrap(); + assert!(device.tracks[0].path.ends_with("/One.mp3"), "{}", device.tracks[0].path); + assert_eq!(device.tracks[0].bitrate, 128); + assert_eq!(lib.track_files(&out.ids[0]).unwrap().unwrap().folder_path, file.to_string_lossy().replace('\\', "/")); + assert_eq!(std::fs::read_dir(&music).unwrap().count(), 1, "nothing is written beside the library's file"); + + let plan = es_engine::sync::inspect_volume(&lib, &volume, &playlists, Some(&mp3)).unwrap().plan.unwrap(); + assert_eq!((plan.copy, plan.convert, plan.keep), (0, 0, 1)); + let phases = RefCell::new(Vec::new()); + sync_volume(&lib, &volume, &playlists, None, Some(&mp3), &|p| { + if let Progress::Step { phase, .. } = p { + phases.borrow_mut().push(phase); + } + }) + .unwrap(); + assert!(!phases.borrow().iter().any(|p| p == "convert" || p == "copy"), "{:?}", phases.borrow()); + + let plan = es_engine::sync::inspect_volume(&lib, &volume, &playlists, None).unwrap().plan.unwrap(); + assert_eq!((plan.copy, plan.convert), (1, 0), "without conversion the original goes back"); } diff --git a/native/crates/es-node/src/library.rs b/native/crates/es-node/src/library.rs index 7591d00..41c11a5 100644 --- a/native/crates/es-node/src/library.rs +++ b/native/crates/es-node/src/library.rs @@ -813,8 +813,8 @@ impl RekordboxLibrary { } #[napi(ts_return_type = "Promise")] - pub fn usb_sync_review(&self, volume: String, playlist_ids: Vec) -> AsyncTask { - self.run(Op::Usb(UsbOp::SyncReview { volume, playlists: playlist_ids })) + pub fn usb_sync_review(&self, volume: String, playlist_ids: Vec, convert: Option) -> AsyncTask { + self.run(Op::Usb(UsbOp::SyncReview { volume, playlists: playlist_ids, convert })) } #[napi(ts_return_type = "Promise")] @@ -828,8 +828,8 @@ impl RekordboxLibrary { } #[napi(ts_return_type = "Promise")] - pub fn usb_sync(&self, volume: String, playlist_ids: Vec, decisions: Option) -> AsyncTask { - self.run(Op::Usb(UsbOp::Sync { volume, playlists: playlist_ids, decisions })) + pub fn usb_sync(&self, volume: String, playlist_ids: Vec, decisions: Option, convert: Option) -> AsyncTask { + self.run(Op::Usb(UsbOp::Sync { volume, playlists: playlist_ids, decisions, convert })) } } diff --git a/native/crates/es-node/src/usb.rs b/native/crates/es-node/src/usb.rs index c96d2d6..f82a081 100644 --- a/native/crates/es-node/src/usb.rs +++ b/native/crates/es-node/src/usb.rs @@ -24,8 +24,8 @@ pub(crate) enum UsbOp { Media(String), ImportPreview { volume: String, folder: Option, policy: Option }, Import { volume: String, folder: Option, policy: Option }, - SyncReview { volume: String, playlists: Vec }, - Sync { volume: String, playlists: Vec, decisions: Option }, + SyncReview { volume: String, playlists: Vec, convert: Option }, + Sync { volume: String, playlists: Vec, decisions: Option, convert: Option }, RecoveryStatus(String), Recover(String), } @@ -100,6 +100,11 @@ fn sync_review_value(review: &impl serde::Serialize) -> napi::Result) -> napi::Result> { + raw.filter(|v| !v.is_null()).map(es_engine::convert::clean_preset).transpose().map_err(failure) +} + fn volume_value(volume: Volume) -> serde_json::Value { json!({ "id": volume.id, @@ -236,9 +241,10 @@ pub(crate) fn perform(core: &Core, service: &mut LibraryService, op: &UsbOp) -> "failed": outcome.failed.iter().map(|(title, error)| json!({ "title": title, "error": error })).collect::>(), })) } - UsbOp::SyncReview { volume, playlists } => { + UsbOp::SyncReview { volume, playlists, convert } => { let volume = listed(volume)?; - let review = service.usb_sync_review(&volume, playlists).map_err(failure)?; + let convert = preset(convert.as_ref())?; + let review = service.usb_sync_review(&volume, playlists, convert.as_ref()).map_err(failure)?; sync_review_value(&review) } UsbOp::RecoveryStatus(volume) => { @@ -252,11 +258,12 @@ pub(crate) fn perform(core: &Core, service: &mut LibraryService, op: &UsbOp) -> publish_volumes(core); Ok(json!({ "recovered": true })) } - UsbOp::Sync { volume, playlists, decisions } => { + UsbOp::Sync { volume, playlists, decisions, convert } => { let volume = listed(volume)?; let emit = |event| core.emit_service_event(event); let decisions = decisions.as_ref().map(|value| serde_json::from_value(value.clone())).transpose().map_err(failure)?; - let result = service.usb_sync(&volume, playlists, decisions.as_ref(), &emit); + let convert = preset(convert.as_ref())?; + let result = service.usb_sync(&volume, playlists, decisions.as_ref(), convert.as_ref(), &emit); core.devices.forget(&volume); let result = result.map_err(failure)?; publish_volumes(core); diff --git a/native/crates/es-usb/Cargo.toml b/native/crates/es-usb/Cargo.toml index a34ddc6..9755185 100644 --- a/native/crates/es-usb/Cargo.toml +++ b/native/crates/es-usb/Cargo.toml @@ -10,6 +10,9 @@ description = "Export Studio command for USB sync." workspace = true [dependencies] +es-convert.workspace = true +es-tags.workspace = true +rb-anlz.workspace = true rb-model.workspace = true rb-usb.workspace = true serde = { workspace = true } diff --git a/native/crates/es-usb/src/convert.rs b/native/crates/es-usb/src/convert.rs new file mode 100644 index 0000000..1d70814 --- /dev/null +++ b/native/crates/es-usb/src/convert.rs @@ -0,0 +1,99 @@ +//! Converting tracks with ffmpeg on their way to the stick. + +use std::io; +use std::path::Path; + +use es_convert::Preset; +use rb_usb::{AudioConverter, ConvertedAudio}; + +/// Raised when what an encode produces changes, so sticks converted before are converted again. +const ENCODER_VERSION: u32 = 1; + +pub struct Ffmpeg { + preset: Preset, + profile: String, +} + +impl Ffmpeg { + pub fn new(preset: Preset) -> Self { + let quality = match preset.bitrate { + Some(kbps) => format!("{kbps}k"), + None => format!("{}-{}", preset.bit_depth.unwrap_or(16), preset.sample_rate.unwrap_or(48_000)), + }; + Self { profile: format!("es-ffmpeg-v{ENCODER_VERSION}:{}-{quality}", preset.format), preset } + } +} + +impl AudioConverter for Ffmpeg { + fn profile(&self) -> &str { + &self.profile + } + + fn extension(&self) -> &str { + self.preset.extension.trim_start_matches('.') + } + + fn wants(&self, source: &Path) -> io::Result { + Ok(!es_convert::matches_format(source, &self.preset)) + } + + fn convert(&self, source: &Path, destination: &Path) -> io::Result { + es_convert::encode(source, destination, &self.preset).map_err(io::Error::other)?; + let info = es_tags::read(destination).map_err(io::Error::other)?; + let narrow = |value: i64| u32::try_from(value).unwrap_or(0); + let requested = narrow(self.preset.bitrate.unwrap_or(0)); + // Only MP3 is encoded at a constant bitrate; AAC's is an average the encoder may undershoot. + let bitrate = if self.preset.format == "mp3" { requested } else { Some(narrow(info.bitrate)).filter(|b| *b > 0).unwrap_or(requested) }; + Ok(ConvertedAudio { + offset_ms: es_convert::delay_offset_ms(source, destination).unwrap_or(0), + bitrate, + sample_rate: narrow(info.sample_rate), + bit_depth: u16::try_from(info.bit_depth).unwrap_or(0), + }) + } + + fn shift_analysis(&self, _extension: &str, bytes: &[u8], offset_ms: i64) -> io::Result> { + rb_anlz::write::shift_timeline(bytes, offset_ms) + } + + fn estimate_bytes(&self, duration_sec: u64) -> u64 { + let seconds = duration_sec.saturating_add(1); + let bytes_per_second = match (self.preset.bitrate, self.preset.format) { + (Some(kbps), _) => u64::try_from(kbps).unwrap_or(320) * 1000 / 8, + (None, format) => { + let depth = u64::try_from(self.preset.bit_depth.unwrap_or(16)).unwrap_or(16); + let rate = u64::try_from(self.preset.sample_rate.unwrap_or(48_000)).unwrap_or(48_000); + let pcm = rate * depth / 8 * 2; + // Lossless compression roughly halves music; an estimate only. + if matches!(format, "flac" | "alac") { pcm * 3 / 5 } else { pcm } + } + }; + seconds.saturating_mul(bytes_per_second).saturating_add(64 * 1024) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn preset(raw: &serde_json::Value) -> Option { + es_convert::clean_preset(raw).ok() + } + + #[test] + fn profiles_name_the_format_and_its_quality() { + let mp3 = preset(&serde_json::json!({ "format": "mp3", "bitrate": 320 })).map(Ffmpeg::new); + assert_eq!(mp3.as_ref().map(|c| (c.profile(), c.extension())), Some(("es-ffmpeg-v1:mp3-320k", "mp3"))); + let alac = preset(&serde_json::json!({ "format": "alac", "bitDepth": 24, "sampleRate": 96000 })).map(Ffmpeg::new); + assert_eq!(alac.as_ref().map(|c| (c.profile(), c.extension())), Some(("es-ffmpeg-v1:alac-24-96000", "m4a"))); + } + + #[test] + fn estimates_follow_the_quality() { + let mp3 = preset(&serde_json::json!({ "format": "mp3", "bitrate": 320 })).map(Ffmpeg::new); + let wav = preset(&serde_json::json!({ "format": "wav", "bitDepth": 16, "sampleRate": 44100 })).map(Ffmpeg::new); + let size = |c: Option| c.map_or(0, |c| c.estimate_bytes(59)); + assert_eq!(size(mp3), 60 * 40_000 + 65_536); + assert_eq!(size(wav), 60 * 176_400 + 65_536); + } +} diff --git a/native/crates/es-usb/src/main.rs b/native/crates/es-usb/src/main.rs index 1c62818..cda4794 100644 --- a/native/crates/es-usb/src/main.rs +++ b/native/crates/es-usb/src/main.rs @@ -1,14 +1,17 @@ //! Versioned JSON transport for the application-independent USB export service. +mod convert; + use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; +use std::sync::Arc; use rb_model::source::{Playlist, Source, Tag, Track}; use serde::Deserialize; -const JOB_SCHEMA_VERSION: u32 = 3; +const JOB_SCHEMA_VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] enum JobError { @@ -39,6 +42,9 @@ struct Job { playlists: Vec, #[serde(default)] decisions: Option, + /// The format tracks not already in it are converted to on the stick. + #[serde(default)] + conversion: Option, } fn default_operation() -> String { "export".into() } @@ -52,7 +58,8 @@ fn emit(value: &serde_json::Value) -> io::Result<()> { fn phase(stage: &str) -> &'static str { match stage { - "copying" | "converting" => "copy", + "copying" => "copy", + "converting" => "convert", "database" => "writing", "verifying" => "verifying", "publishing" => "publishing", @@ -72,8 +79,18 @@ fn parse_job(bytes: &[u8]) -> Result { Ok(job) } +fn converter(job: &Job) -> Result>, JobError> { + let Some(raw) = job.conversion.as_ref().filter(|v| !v.is_null()) else { return Ok(None) }; + let preset = es_convert::clean_preset(raw).map_err(|error| JobError::Message(error.to_string()))?; + if job.operation == "export" { + es_convert::ffmpeg_path().map_err(|error| JobError::Message(error.to_string()))?; + } + Ok(Some(Arc::new(convert::Ffmpeg::new(preset)))) +} + fn run(path: &Path) -> Result<(), JobError> { let job = parse_job(&fs::read(path)?)?; + let converter = converter(&job)?; let source = Source { db_id: job.db_id, automatic: job.automatic, @@ -84,6 +101,7 @@ fn run(path: &Path) -> Result<(), JobError> { let options = rb_usb::Options { settings_dir: job.settings_dir, decisions: job.decisions, + converter, }; if job.operation == "inspect" { let inspection = rb_usb::inspect(&job.destination, &source, &options)?; @@ -155,6 +173,18 @@ mod tests { assert!(parse_job(job.to_string().as_bytes()).is_ok()); } + #[test] + fn a_conversion_is_checked_like_the_library_s() { + let job = |conversion: serde_json::Value| { + let raw = serde_json::json!({ "schemaVersion": JOB_SCHEMA_VERSION, "operation": "inspect", "destination": "/tmp", "tracks": [], "playlists": [], "conversion": conversion }); + parse_job(raw.to_string().as_bytes()).and_then(|job| converter(&job)) + }; + assert!(job(serde_json::Value::Null).is_ok_and(|c| c.is_none())); + let wav = job(serde_json::json!({ "format": "wav", "bitDepth": 16, "sampleRate": 44100 })).ok().flatten(); + assert_eq!(wav.map(|c| (c.extension().to_owned(), c.profile().to_owned())), Some(("wav".into(), "es-ffmpeg-v1:wav-16-44100".into()))); + assert!(job(serde_json::json!({ "format": "ogg" })).is_err()); + } + #[test] fn rejects_missing_or_unsupported_job_schema() { assert!(parse_job(br#"{"destination":"/tmp","tracks":[],"playlists":[]}"#).is_err());