Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/src/main/audio/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ export interface NativeLibrary {
usbMedia(key: string): Promise<unknown>
usbImportPreview(volume: string, folder: string | null, policy: string | null): Promise<unknown>
usbImport(volume: string, folder: string | null, policy: string | null): Promise<unknown>
usbSyncReview(volume: string, playlistIds: string[]): Promise<unknown>
usbSync(volume: string, playlistIds: string[], review?: unknown): Promise<unknown>
usbSyncReview(volume: string, playlistIds: string[], convert?: unknown): Promise<unknown>
usbSync(volume: string, playlistIds: string[], review?: unknown, convert?: unknown): Promise<unknown>
usbRecoveryStatus(volume: string): Promise<unknown>
usbRecover(volume: string): Promise<unknown>
removeTracks(ids: string[]): Promise<unknown>
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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(
Expand Down
28 changes: 25 additions & 3 deletions app/src/preload/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -118,8 +140,8 @@ export interface EngineCommandParams {
'track.relocate': { moves: Record<string, string> }
'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 }
Expand Down
3 changes: 3 additions & 0 deletions native/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions native/crates/es-engine/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,14 +709,15 @@ impl LibraryService {
volume: &Path,
playlist_ids: &[String],
decisions: Option<&rb_model::conflict::Decisions>,
convert: Option<&crate::convert::Preset>,
emit: &impl Fn(ServiceEvent),
) -> Result<crate::sync::Outcome> {
let database = self
.session
.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));
})
}
Expand All @@ -725,13 +726,14 @@ impl LibraryService {
&self,
volume: &Path,
playlist_ids: &[String],
convert: Option<&crate::convert::Preset>,
) -> Result<rb_model::conflict::Inspection> {
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 {
Expand Down
39 changes: 29 additions & 10 deletions native/crates/es-engine/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Value> {
fn job(share: &Path, volume: &Path, bundle: &Bundle, operation: &str, decisions: Option<&Decisions>, convert: Option<&Preset>) -> Result<Value> {
let tracks: Vec<Value> = bundle.tracks.iter().map(|t| track_job(share, t)).collect();
let known: std::collections::HashSet<i64> = bundle.tracks.iter().map(|t| num(&t.id)).collect();
let mut playlists = Vec::new();
Expand Down Expand Up @@ -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,
})),
}))
}

Expand Down Expand Up @@ -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<Outcome> {
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<Inspection> {
/// 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<Inspection> {
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 {
Expand All @@ -227,13 +234,14 @@ pub fn inspect_volume_with_publisher(
lib: &Library,
volume: &Path,
playlist_ids: &[String],
convert: Option<&Preset>,
publisher: &impl UsbPublisher,
) -> Result<Inspection> {
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()))?)
Expand All @@ -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<Outcome> {
Expand All @@ -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);
Expand Down Expand Up @@ -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]
Expand All @@ -361,6 +379,7 @@ mod tests {
&volume,
&["playlist-1".into()],
None,
None,
&FakePublisher,
&|_| {},
)
Expand All @@ -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());
}
Expand Down
2 changes: 1 addition & 1 deletion native/crates/es-engine/tests/device_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ fn synced_world() -> Option<World> {
}));
}
let job = json!({
"schemaVersion": 3,
"schemaVersion": 4,
"destination": volume, "dbId": 4242, "tracks": tracks,
"playlists": [
{ "id": 1, "name": "Folder", "parentId": 0, "folder": true, "trackIds": [] },
Expand Down
54 changes: 50 additions & 4 deletions native/crates/es-engine/tests/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);

Expand All @@ -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"]);
Expand All @@ -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");
}
8 changes: 4 additions & 4 deletions native/crates/es-node/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -813,8 +813,8 @@ impl RekordboxLibrary {
}

#[napi(ts_return_type = "Promise<unknown>")]
pub fn usb_sync_review(&self, volume: String, playlist_ids: Vec<String>) -> AsyncTask<Run> {
self.run(Op::Usb(UsbOp::SyncReview { volume, playlists: playlist_ids }))
pub fn usb_sync_review(&self, volume: String, playlist_ids: Vec<String>, convert: Option<Value>) -> AsyncTask<Run> {
self.run(Op::Usb(UsbOp::SyncReview { volume, playlists: playlist_ids, convert }))
}

#[napi(ts_return_type = "Promise<unknown>")]
Expand All @@ -828,8 +828,8 @@ impl RekordboxLibrary {
}

#[napi(ts_return_type = "Promise<unknown>")]
pub fn usb_sync(&self, volume: String, playlist_ids: Vec<String>, decisions: Option<Value>) -> AsyncTask<Run> {
self.run(Op::Usb(UsbOp::Sync { volume, playlists: playlist_ids, decisions }))
pub fn usb_sync(&self, volume: String, playlist_ids: Vec<String>, decisions: Option<Value>, convert: Option<Value>) -> AsyncTask<Run> {
self.run(Op::Usb(UsbOp::Sync { volume, playlists: playlist_ids, decisions, convert }))
}
}

Expand Down
Loading
Loading