diff --git a/crates/sc-cli/src/export_plan.rs b/crates/sc-cli/src/export_plan.rs index f81a658..faea256 100644 --- a/crates/sc-cli/src/export_plan.rs +++ b/crates/sc-cli/src/export_plan.rs @@ -181,7 +181,9 @@ fn what_to_do(reason: ExportSkip) -> &'static str { ExportSkip::UnsupportedChannels { .. } => "export a stereo version first", ExportSkip::NothingToWrite { .. } => "run it again without --no-xml", ExportSkip::OtherFileInFolder => "move that file away, or export into another folder", - ExportSkip::CopyOfOtherTrack => "rename one of the two, or export into another folder", + ExportSkip::CopyOfOtherTrack | ExportSkip::SameNameInBatch => { + "rename one of the two, or export into another folder" + } ExportSkip::Unsupported { .. } => "convert it to AIFF, WAV or FLAC first", ExportSkip::Silent => "check that it is the right file", ExportSkip::NoGrid => "set the grid in the app, or export without --grid-only", diff --git a/crates/sc-core/src/export.rs b/crates/sc-core/src/export.rs index 25b8edb..5c84723 100644 --- a/crates/sc-core/src/export.rs +++ b/crates/sc-core/src/export.rs @@ -362,9 +362,13 @@ pub enum ExportSkip { /// The folder already holds a file of this name that is not a copy SoundCheck wrote, or that /// changed since: it is left as it is. OtherFileInFolder, - /// A copy of another track has this name in the folder, or would have it first in this batch: - /// it is left as it is, since it is not this file's to replace. + /// The folder holds a copy of this name that SoundCheck made from another file, or from this + /// one before it was moved and changed: it is left as it is, since it is not this file's to + /// replace. CopyOfOtherTrack, + /// A file earlier in this batch is written under this name, and nothing of that name need be + /// in the folder yet: the first in the list is written. + SameNameInBatch, /// An MP3 or AAC file, which only the rekordbox XML carries, without a grid: the XML would /// list nothing for it. XmlNoGrid { @@ -401,7 +405,8 @@ pub enum SkipCure { Grid, /// Other export settings. Settings, - /// What is in the folder, which can change with no word to the app. + /// What is in the folder or goes along in the batch, which can change with no word to the + /// row. Disk, } @@ -422,7 +427,9 @@ impl ExportSkip { Self::SeratoInPlaceCut { .. } | Self::SeratoUnknownInPlaceCut { .. } | Self::NothingToWrite { .. } => SkipCure::Settings, - Self::OtherFileInFolder | Self::CopyOfOtherTrack => SkipCure::Disk, + Self::OtherFileInFolder | Self::CopyOfOtherTrack | Self::SameNameInBatch => { + SkipCure::Disk + } } } } diff --git a/crates/sc-core/src/export/tests.rs b/crates/sc-core/src/export/tests.rs index 8c87564..3f3c85f 100644 --- a/crates/sc-core/src/export/tests.rs +++ b/crates/sc-core/src/export/tests.rs @@ -145,6 +145,7 @@ fn a_skip_says_what_could_cure_it() { ), (ExportSkip::OtherFileInFolder, SkipCure::Disk), (ExportSkip::CopyOfOtherTrack, SkipCure::Disk), + (ExportSkip::SameNameInBatch, SkipCure::Disk), ]; for (skip, cure) in cases { assert_eq!(skip.cure(), cure, "{skip:?}"); diff --git a/crates/sc-core/src/ipc/export.rs b/crates/sc-core/src/ipc/export.rs index 47efa7e..e455df3 100644 --- a/crates/sc-core/src/ipc/export.rs +++ b/crates/sc-core/src/ipc/export.rs @@ -61,6 +61,9 @@ pub struct ConfirmedFile { pub file_id: u32, /// Its identity then (length, modification time, inode): opaque, compared whole. pub identity: String, + /// The plan skipped it because a file earlier in the batch takes its name: it stays skipped, + /// whatever else drops out of the job. + pub same_name: bool, } /// What a row's export was made with, so the row can say when the settings have moved on. @@ -123,8 +126,8 @@ pub struct ExportPreviewRow { /// What its row would show: `stage` `processing` for a file that would be written, `skipped` /// for one that would not, with the plan's notes worded. `None` when it could not be planned. pub row: Option>, - /// Why it could not be planned: not analysed yet, changed since it was analysed, or its start - /// could not be read. + /// Why it could not be planned: not analysed yet, changed since it was analysed, its start + /// could not be read, or an export in place would refuse it. pub error: Option, /// The file as it was planned, to send back ([`ConfirmedFile`]); `None` without a plan. pub identity: Option, diff --git a/crates/sc-core/src/ipc/mod.rs b/crates/sc-core/src/ipc/mod.rs index aa7d9ec..ef522fd 100644 --- a/crates/sc-core/src/ipc/mod.rs +++ b/crates/sc-core/src/ipc/mod.rs @@ -426,6 +426,8 @@ pub enum JobEvent { plan: Plan, /// The settings revision `plan` was decided with; a newer replan wins. revision: u32, + /// The file's export no longer stands for it, as the session now says. + export_stale: bool, }, /// The file could not be analysed; the job goes on. Failed { diff --git a/crates/sc-core/src/ipc/tests.rs b/crates/sc-core/src/ipc/tests.rs index 1316477..2509cbb 100644 --- a/crates/sc-core/src/ipc/tests.rs +++ b/crates/sc-core/src/ipc/tests.rs @@ -99,6 +99,7 @@ fn a_worst_case_analysed_row_stays_within_budget() { row: Box::new(row), plan, revision: u32::MAX, + export_stale: false, }; assert!(json_len(&ev) <= MAX_ROW_BYTES, "{} bytes", json_len(&ev)); } diff --git a/crates/sc-engine/src/export/preview.rs b/crates/sc-engine/src/export/preview.rs index 1a20fc9..ca187aa 100644 --- a/crates/sc-engine/src/export/preview.rs +++ b/crates/sc-engine/src/export/preview.rs @@ -1,12 +1,12 @@ //! What exporting would do with one analysed file, for the confirm step: the same //! [`plan_export_snapped`] the job plans with, on the tags the file holds now, turned into the row -//! the job would show. Nothing is written. +//! the job would show, with the refusals of a write in place said here too. Nothing is written. use std::path::Path; use sc_core::Result; use sc_core::analysis::AnalysisRecord; -use sc_core::export::{ExportOutcome, ExportSettings}; +use sc_core::export::{ExportOutcome, ExportSettings, ExportSkip}; use sc_core::ipc::ExportRow; use sc_core::plan::DecideSettings; @@ -16,6 +16,7 @@ use super::row; use super::snap::plan_export_snapped; use crate::decide::decide; use crate::edits::EditState; +use crate::txn::FolderNames; /// One file to preview: its analysis with the grid as shown, and how it would be exported. #[derive(Debug, Clone, Copy)] @@ -34,17 +35,23 @@ pub struct PreviewInput<'a> { pub out_dir: Option<&'a Path>, /// An earlier change of the file is in effect. pub revertible: bool, - /// An earlier file of the batch takes the name its copy would have. - pub same_name: bool, + /// Where backups go: the transaction refuses a file already inside. + pub backup_root: &'a Path, } /// The row exporting the file would show, and whether its write replaces an earlier copy. The -/// source is not hashed, which only the record written into the file needs. +/// source is not hashed, which only the record written into the file needs. `names` holds the names +/// earlier rows of the batch write under, for an export to a folder: only a row that plans a write +/// takes one, so call this in list order. /// /// # Errors /// What [`plan_export_snapped`] returns for a file whose start cannot be read up to the cut; what -/// reading a file of that name in the folder returns. -pub fn preview_row(input: &PreviewInput<'_>) -> Result<(Box, bool)> { +/// reading a file of that name in the folder returns; what a write in place of this file would be +/// refused with ([`sc_io::txn::check_writable_in_place`]). +pub fn preview_row( + input: &PreviewInput<'_>, + names: Option<&mut FolderNames>, +) -> Result<(Box, bool)> { let source = ExportSource::read(input.path); let decided = decide( input.record, @@ -60,7 +67,21 @@ pub fn preview_row(input: &PreviewInput<'_>) -> Result<(Box, bool)> { grid_confirmed: input.edit.confirmed, }; let outcome = plan_export_snapped(input.path, &planning, input.settings)?; - let (outcome, replaces) = gate_folder(outcome, input.path, input.out_dir, input.same_name)?; + let (outcome, replaces) = gate_folder(outcome, input.path, input.out_dir, false)?; + let writes = matches!(outcome, ExportOutcome::Write { .. }); + if writes && input.out_dir.is_none() { + sc_io::txn::check_writable_in_place(input.path, input.backup_root)?; + } + let name_taken = writes + && names + .zip(input.path.file_name()) + .is_some_and(|(names, name)| !names.claim(name)); + let (outcome, replaces) = if name_taken { + let reason = ExportSkip::SameNameInBatch; + (ExportOutcome::Skip { reason }, false) + } else { + (outcome, replaces) + }; Ok(( match outcome { ExportOutcome::Write { plan } => row::planned(&plan, input.revertible), diff --git a/crates/sc-engine/src/export/process.rs b/crates/sc-engine/src/export/process.rs index bec9c84..22eef87 100644 --- a/crates/sc-engine/src/export/process.rs +++ b/crates/sc-engine/src/export/process.rs @@ -64,8 +64,9 @@ pub struct ProcessSettings { /// Each file as the user confirmed it, by id: a file that is no longer that one fails before /// anything is written. `None` checks nothing. pub confirmed: Option>, - /// Files whose copy would take a name an earlier file of this batch takes: skipped, the first - /// in the list being written. By id. + /// Files skipped because a file earlier in the batch writes under the name theirs would have, + /// by id: what the confirm step decided, when there was one, since only a row that plans a + /// write takes a name and the plans are known there. pub same_name: BTreeSet, } @@ -285,8 +286,8 @@ fn check_confirmed(file_id: u32, path: &Path, settings: &ProcessSettings) -> Res }) } -/// `outcome`, given what is at the path a copy would take in `out_dir`, and whether an earlier file -/// of the batch takes that name (`same_name`): anything of that name but this file's own earlier +/// `outcome`, given what is at the path a copy would take in `out_dir`, and whether a file earlier +/// in the batch writes under that name (`same_name`): anything of that name but this file's own earlier /// copy keeps the file from being written. Also says whether the write replaces that copy. /// /// # Errors @@ -304,7 +305,7 @@ pub(super) fn gate_folder( }; let skip = |reason| (ExportOutcome::Skip { reason }, false); if same_name { - return Ok(skip(ExportSkip::CopyOfOtherTrack)); + return Ok(skip(ExportSkip::SameNameInBatch)); } Ok(match copy_at(&dir.join(name), path)? { CopyAt::Nothing => (outcome, false), diff --git a/crates/sc-engine/src/export/row.rs b/crates/sc-engine/src/export/row.rs index 7bbc372..72ab6e1 100644 --- a/crates/sc-engine/src/export/row.rs +++ b/crates/sc-engine/src/export/row.rs @@ -10,7 +10,7 @@ use sc_io::render::{BlockFate, RenderReport}; use sc_io::txn::{TxnKind, TxnReport}; use super::ProcessDone; -use super::wording::{GRID_WITHHELD, notice_text, skip_why, xml_only_text}; +use super::wording::{GRID_WITHHELD, notice_text, skip_note, xml_only_text}; /// What the plan says the user should know, in words, for the row's notes. fn plan_notes(plan: &ExportPlan) -> Vec { @@ -53,7 +53,7 @@ pub(crate) fn skipped(outcome: &ExportOutcome, revertible: bool) -> Box ( diff --git a/crates/sc-engine/src/export/wording.rs b/crates/sc-engine/src/export/wording.rs index d99a785..2653715 100644 --- a/crates/sc-engine/src/export/wording.rs +++ b/crates/sc-engine/src/export/wording.rs @@ -1,5 +1,6 @@ //! What a plan did, in words: the grid report's notes, the command line's lines and the row -//! notes the app shows. Only the "why"; what to do about it depends on where it is shown. +//! notes the app shows. [`skip_why`] is only the "why", since what to do about it depends on where +//! it is shown; [`skip_note`] is the app's row. use sc_core::export::{ExportNotice, ExportSkip, XmlOnlyReason}; @@ -50,6 +51,18 @@ pub fn xml_only_text(reason: XmlOnlyReason) -> String { } } +/// A skipped row's note in the app: why, with what to do when no other part of the app says it. +#[must_use] +pub fn skip_note(reason: ExportSkip) -> String { + let why = skip_why(reason); + match reason { + ExportSkip::CopyOfOtherTrack | ExportSkip::SameNameInBatch => { + format!("{why}; rename one, or choose another folder") + } + _ => why, + } +} + /// Why a file is skipped. #[must_use] pub fn skip_why(reason: ExportSkip) -> String { @@ -108,7 +121,12 @@ pub fn skip_why(reason: ExportSkip) -> String { .to_owned() } ExportSkip::CopyOfOtherTrack => { - "a copy of another track already has this name in the folder".to_owned() + "the folder has a copy of this name that SoundCheck made from another file, or from \ + this one before it was moved and changed" + .to_owned() + } + ExportSkip::SameNameInBatch => { + "a file earlier in this export takes this name in the folder".to_owned() } ExportSkip::GridNeedsReview => { "grid only, and its grid needs review, so it is not written and there is nothing \ @@ -117,3 +135,6 @@ pub fn skip_why(reason: ExportSkip) -> String { } } } + +#[cfg(test)] +mod tests; diff --git a/crates/sc-engine/src/export/wording/tests.rs b/crates/sc-engine/src/export/wording/tests.rs new file mode 100644 index 0000000..c720792 --- /dev/null +++ b/crates/sc-engine/src/export/wording/tests.rs @@ -0,0 +1,48 @@ +use sc_core::export::ExportSkip; + +use super::{skip_note, skip_why}; + +const WHAT_TO_DO: &str = "rename one, or choose another folder"; + +#[test] +fn a_copy_in_the_folder_says_it_may_be_this_file_before_it_moved() { + let why = skip_why(ExportSkip::CopyOfOtherTrack); + assert!(why.contains("made from another file"), "{why}"); + assert!(why.contains("before it was moved and changed"), "{why}"); +} + +#[test] +fn a_name_taken_in_the_batch_does_not_say_a_copy_exists() { + let why = skip_why(ExportSkip::SameNameInBatch); + assert!(why.contains("earlier in this export"), "{why}"); + assert!(!why.contains("already"), "nothing need be there yet: {why}"); + assert!(!why.contains("copy"), "nothing need be there yet: {why}"); +} + +#[test] +fn the_app_says_what_to_do_about_a_name_and_the_command_line_keeps_its_own() { + for reason in [ExportSkip::CopyOfOtherTrack, ExportSkip::SameNameInBatch] { + let note = skip_note(reason); + assert!(note.starts_with(&skip_why(reason)), "{note}"); + assert!(note.ends_with(WHAT_TO_DO), "{note}"); + assert!(!skip_why(reason).contains(WHAT_TO_DO)); + } +} + +#[test] +fn other_skips_keep_their_note() { + let reason = ExportSkip::Silent; + assert_eq!(skip_note(reason), skip_why(reason)); +} + +#[test] +fn the_words_read_as_the_app_shows_them() { + for reason in [ExportSkip::CopyOfOtherTrack, ExportSkip::SameNameInBatch] { + let note = skip_note(reason); + // The app makes a sentence of it: starting small, ending with no stop of its own. + assert!(note.starts_with(char::is_lowercase), "{note}"); + assert!(!note.ends_with('.'), "{note}"); + let lower = note.to_lowercase(); + assert!(!lower.contains("sorry") && !lower.contains("unfortunately")); + } +} diff --git a/crates/sc-engine/src/lib.rs b/crates/sc-engine/src/lib.rs index 86c0ffb..22bb23a 100644 --- a/crates/sc-engine/src/lib.rs +++ b/crates/sc-engine/src/lib.rs @@ -39,6 +39,6 @@ pub use export::{ pub use session::{PlanningRow, RevertedAnalysis, Session, run_job}; pub use track::{Track, TrackProgress}; pub use txn::{ - ApplyOptions, ApplyRequest, Place, RecoveryGate, RecoveryGuard, Tag, apply_file, check_inputs, - forget_cached, forget_recovered, recover_at_start, undo_file, + ApplyOptions, ApplyRequest, FolderNames, Place, RecoveryGate, RecoveryGuard, Tag, apply_file, + check_inputs, forget_cached, forget_recovered, recover_at_start, undo_file, }; diff --git a/crates/sc-engine/src/session/events.rs b/crates/sc-engine/src/session/events.rs index 2db0105..4deb855 100644 --- a/crates/sc-engine/src/session/events.rs +++ b/crates/sc-engine/src/session/events.rs @@ -6,7 +6,7 @@ use std::time::Duration; use sc_core::ipc::{IpcError, JobEvent, JobId}; use super::{Session, row_of}; -use crate::analyze::CacheStatus; +use crate::analyze::{AnalyzeReport, CacheStatus}; use crate::batch::EngineEvent; use crate::decide::decide; use crate::edits::EditState; @@ -29,35 +29,7 @@ impl Session { file_id, report, edit, - } => { - let identity = report.identity; - let bpm_range = report.bpm_range; - let mut record = report.record; - record.evidence = None; - let codec = self - .files - .get(&file_id) - .map_or(sc_core::plan::Codec::Other, |t| t.entry.info.codec); - let row = Box::new(row_of(&record, edit, report.cache == CacheStatus::Hit)); - let plan = decide(&record, codec, &self.settings, edit.confirmed); - if let Some(t) = self.files.get_mut(&file_id) { - t.record = Some(record); - t.edit = edit; - // The file changed since what stands was made of it. - if t.export.is_some() && t.identity.is_some() && identity != t.identity { - t.export_stale = true; - } - t.identity = identity; - t.analysed_range = Some(bpm_range); - } - JobEvent::Analysed { - job_id, - file_id, - row, - plan, - revision: self.revision, - } - } + } => self.on_analysed(job_id, file_id, *report, edit), EngineEvent::Failed { file_id, error } => { self.end_running_export(file_id); failed_event(job_id, file_id, error) @@ -121,6 +93,46 @@ impl Session { }) } + /// A file is analysed: the row and its plan, and whether its export still stands for it. + fn on_analysed( + &mut self, + job_id: JobId, + file_id: u32, + report: AnalyzeReport, + edit: EditState, + ) -> JobEvent { + let identity = report.identity; + let bpm_range = report.bpm_range; + let mut record = report.record; + record.evidence = None; + let codec = self + .files + .get(&file_id) + .map_or(sc_core::plan::Codec::Other, |t| t.entry.info.codec); + let row = Box::new(row_of(&record, edit, report.cache == CacheStatus::Hit)); + let plan = decide(&record, codec, &self.settings, edit.confirmed); + let mut export_stale = false; + if let Some(t) = self.files.get_mut(&file_id) { + t.record = Some(record); + t.edit = edit; + // The file changed since what stands was made of it. + if t.export.is_some() && t.identity.is_some() && identity != t.identity { + t.export_stale = true; + } + t.identity = identity; + t.analysed_range = Some(bpm_range); + export_stale = t.export_stale; + } + JobEvent::Analysed { + job_id, + file_id, + row, + plan, + revision: self.revision, + export_stale, + } + } + /// A file's export ended without changing it: its last export stands. fn end_running_export(&mut self, file_id: u32) { if let Some(t) = self.files.get_mut(&file_id) { diff --git a/crates/sc-engine/src/txn.rs b/crates/sc-engine/src/txn.rs index a8358c5..b347588 100644 --- a/crates/sc-engine/src/txn.rs +++ b/crates/sc-engine/src/txn.rs @@ -22,7 +22,7 @@ use sc_io::txn::{self, Outcome, RecoveryReport, TxnOptions, TxnReport, UndoRepor use crate::CancelToken; pub use gate::{RecoveryGate, RecoveryGuard}; -pub use inputs::check_inputs; +pub use inputs::{FolderNames, check_inputs}; pub use tags::{Tag, check_tags, tag_edits}; /// What [`apply_file`] does to one file. diff --git a/crates/sc-engine/src/txn/inputs.rs b/crates/sc-engine/src/txn/inputs.rs index ad9b0ed..107fb2f 100644 --- a/crates/sc-engine/src/txn/inputs.rs +++ b/crates/sc-engine/src/txn/inputs.rs @@ -3,8 +3,9 @@ //! case or Unicode form on macOS), and two files a copy into one folder would write under the //! same name. -use std::collections::BTreeMap; -use std::path::PathBuf; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; use sc_core::Error; use sc_io::txn::{folder_folds_case, resolve_file}; @@ -52,6 +53,30 @@ pub fn check_inputs(files: &[PathBuf], place: &Place) -> Vec> { .collect() } +/// The names files of a batch take in one folder, as the folder's volume compares them. +#[derive(Debug, Clone)] +pub struct FolderNames { + folds: bool, + taken: BTreeSet, +} + +impl FolderNames { + /// No name taken yet in `dir`, which need not exist. + #[must_use] + pub fn new(dir: &Path) -> Self { + Self { + folds: folder_folds_case(dir), + taken: BTreeSet::new(), + } + } + + /// Takes `name`; `false` when an earlier file took it. + pub fn claim(&mut self, name: &OsStr) -> bool { + self.taken + .insert(name_key(&name.to_string_lossy(), self.folds)) + } +} + /// How the folder compares a name: `folds` when `Track.wav` and `track.wav` are one file there. /// Unicode forms are always one name: macOS volumes treat them so, and on any other volume two /// spellings of one name are worth refusing. diff --git a/crates/sc-engine/src/txn/inputs/tests.rs b/crates/sc-engine/src/txn/inputs/tests.rs index ff9cf2e..202f8b5 100644 --- a/crates/sc-engine/src/txn/inputs/tests.rs +++ b/crates/sc-engine/src/txn/inputs/tests.rs @@ -106,3 +106,16 @@ fn a_name_is_compared_as_the_volume_compares_it() { super::name_key("S\u{327}arkı.wav", false) ); } + +#[test] +fn a_name_is_claimed_once_as_the_folder_compares_it() { + let dir = tempfile::tempdir().unwrap(); + let mut names = super::FolderNames::new(&dir.path().join("not yet")); + let folds = sc_io::txn::folder_folds_case(&dir.path().join("not yet")); + assert!(names.claim(std::ffi::OsStr::new("Track.wav"))); + assert!(!names.claim(std::ffi::OsStr::new("Track.wav")), "taken"); + assert_eq!(names.claim(std::ffi::OsStr::new("TRACK.wav")), !folds); + // The same name in another Unicode form, on any volume. + assert!(names.claim(std::ffi::OsStr::new("caf\u{e9}.wav"))); + assert!(!names.claim(std::ffi::OsStr::new("cafe\u{301}.wav"))); +} diff --git a/crates/sc-engine/tests/session.rs b/crates/sc-engine/tests/session.rs index 244cf7c..f658701 100644 --- a/crates/sc-engine/tests/session.rs +++ b/crates/sc-engine/tests/session.rs @@ -361,3 +361,52 @@ fn confirming_a_grid_does_not_mark_a_row_skipped_for_the_file_itself() { }; assert!(!s.set_grid(id, None, edit).unwrap().export_stale); } + +/// An analysis of the file as `identity`, through the session; what the UI is told of its export. +fn analysed_as(session: &Mutex, id: u32, identity: &str) -> bool { + let record = session.lock().unwrap().planning_row(id).unwrap().record; + let report = sc_engine::AnalyzeReport { + schema: sc_engine::REPORT_SCHEMA, + cache: sc_engine::CacheStatus::Bypassed, + record, + identity: Some(identity.to_owned()), + bpm_range: (sc_core::Bpm(60.0), sc_core::Bpm(120.0)), + }; + let event = session.lock().unwrap().on_engine_event( + 2, + sc_engine::EngineEvent::Analysed { + file_id: id, + report: Box::new(report), + edit: sc_engine::EditState::default(), + }, + ); + match event { + Some(sc_core::ipc::JobEvent::Analysed { export_stale, .. }) => export_stale, + other => panic!("{other:?}"), + } +} + +#[test] +fn regression_the_analysed_event_says_what_the_session_says_of_the_export() { + use sc_core::export::ExportSkip; + let dir = tempfile::tempdir().unwrap(); + let (session, id) = analysed_tone(dir.path()); + let stored = |s: &Mutex| s.lock().unwrap().snapshot().rows[0].export_stale; + // No export stands: a changed file has nothing to make stale. + assert!(!analysed_as(&session, id, "first")); + assert!(!stored(&session)); + session + .lock() + .unwrap() + .on_engine_event(2, skipped_event(id, ExportSkip::GridNeedsReview)); + + // Read again for another reason, the file as it was: the export stands. + assert!(!analysed_as(&session, id, "first")); + assert!(!stored(&session)); + // The file changed on disk, and nothing in the UI knew: the event says so. + assert!(analysed_as(&session, id, "second")); + assert!(stored(&session)); + // Read again, unchanged since: it stays stale until exported again. + assert!(analysed_as(&session, id, "second")); + assert!(stored(&session)); +} diff --git a/crates/sc-io/src/txn/mod.rs b/crates/sc-io/src/txn/mod.rs index 07c33b0..ceb5d78 100644 --- a/crates/sc-io/src/txn/mod.rs +++ b/crates/sc-io/src/txn/mod.rs @@ -95,9 +95,9 @@ pub use fsx::{TEMP_MARKER, hash_file, hex}; pub use journal::{Entry, JOURNAL_FILE, Outcome, State, TxnKind, is_txn_id}; pub use meta::file_identity; pub use preflight::{ - SPACE_MARGIN_BYTES, TagFamily, check_folder_not_rekordbox, folder_folds_case, - is_in_resolved_backup_root, is_under_backup_root, resolve_backup_root, resolve_file, - tag_family, + SPACE_MARGIN_BYTES, TagFamily, check_folder_not_rekordbox, check_writable_in_place, + folder_folds_case, is_in_resolved_backup_root, is_under_backup_root, resolve_backup_root, + resolve_file, tag_family, }; pub use recover::{Pending, Recovered, RecoveryReport, recover}; pub use sidecar::{SIDECAR_SUFFIX, record_grid_check, sidecar_path}; diff --git a/crates/sc-io/src/txn/preflight.rs b/crates/sc-io/src/txn/preflight.rs index 5b60b39..0ba9094 100644 --- a/crates/sc-io/src/txn/preflight.rs +++ b/crates/sc-io/src/txn/preflight.rs @@ -22,7 +22,7 @@ use sc_core::{Error, InPlaceRefusal, RenderRequest, Result}; use super::fsx::io_err; use super::meta::{effective_uid, has_acl, is_locked, owned_by_other}; -use super::volume::{Volume, VolumeProvider}; +use super::volume::{SystemVolumes, Volume, VolumeProvider}; use crate::{flac, iff}; /// Free space every volume written to keeps after the write, bytes (64 MiB). @@ -329,6 +329,20 @@ pub(crate) fn check_in_place( Ok(volume) } +/// What a write in place of `path` would be refused with, for saying so before it is asked for: +/// the refusals [`crate::txn::apply_in_place`] starts with, on the system's volumes. It only looks; +/// free space, which depends on the output, is left to the write. +/// +/// # Errors +/// [`Error::InPlaceRefused`], [`Error::RekordboxUsbExport`], [`Error::InvalidArgument`] for a file +/// inside `backup_root` or not a regular file, [`Error::Io`]. +pub fn check_writable_in_place(path: &Path, backup_root: &Path) -> Result<()> { + let (full, dir, _) = resolve(path)?; + check_outside_backups(&full, backup_root)?; + check_in_place(&full, &dir, &SystemVolumes)?; + Ok(()) +} + /// [`Error::RekordboxUsbExport`] when `volume` is a rekordbox USB export. /// /// # Errors diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e77ffc..b202c05 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -187,9 +187,9 @@ RENDER (streamed) - Player (`sc_engine::player`): a renderer mixes the open track at its own level (unity gain) with a click on the grid lines (1.5 kHz at -6 dBFS on bar lines, 1.25 kHz at -10 dBFS on group starts, 1 kHz at -14 dBFS on other pulses, 5 ms each, the music 6 dB down while it plays) at the track's own rate, then converts it to the output device's rate and channels (`sc_dsp::StreamResampler`, same converter as the analysis); a feeder thread keeps a lock-free `rtrb` ring about 150 ms ahead; the cpal callback copies from the ring, applies the version gain (0 dB for the original, the planned gain for the processed version, 0 dB for either when level-matched, since with gain-only processing the loudness change is the planned gain), clamps to full scale, then applies the monitor volume, each gain moving along a straight line over 10 ms of output from two `f32` atomics, so a switch or a volume change is heard within one device buffer instead of after the queued 150 ms; clamping before the volume means turning down never hides that the processed version clips. The click is in the rendered audio, so it follows the version gain and stays 6 dB over the music. A test counts the callback's allocations through playing, switches, volume moves and a seek: none; it counts an underrun when the ring runs dry once audio flows. A seek raises an epoch, the callback empties the ring and acknowledges, and only then is new audio queued; until then the position is the seek point. Position = seek point + frames played x rate ratio - the device's reported latency and buffer. Music and click go to the first two output channels; the converter is flushed at the end of the track. A grid or click change while paused re-renders from the paused position, so it is heard from the first frame played. Meters (`player::meter`): on the feeder thread, never the audio thread, each rendered 1,024-frame block of music at unity gain and the track's own rate and channels (before the click and the resampler) goes through `ebur128` (BS.1770-5 momentary, 400 ms, and Annex 2 true peak, 4x below 96 kHz, mono as dual mono, as in the analysis; any channel count in the BS.1770 order, though the decoder admits at most two); after each block the block's true peak (loudest channel) and the momentary loudness ending there go into a 256-block history (6 s at 44.1 kHz) behind a mutex, emptied on a seek or a load. A seek also empties `ebur128`'s filters, so before the first block after it the meter is primed with the 100 ms of the track before the new position (`EbuR128::seed_frames_f32`: the K-weighting filters and the true-peak interpolator warm up, nothing is measured and the 400 ms window stays empty); unprimed, the interpolator read the first frames against zeros, and a bass note near full scale overshot the track's true peak by up to 1 dB, a false over (`tests/player_listen.rs` seeks 160 times next to the crests of a -0.9 dBFS 50 Hz sine: none reads above the analysis's true peak). The reading sent is the block holding the heard position (within one block, 23 ms at 44.1 kHz), with the largest block peak since the reading sent before (`Meters::since`), so an over between two 30 Hz events is never lost; OUT is IN plus the planned gain exactly (loudness and true peak are homogeneous in gain), so the meters show the signal, not the version heard or the volume. Momentary loudness is `None` in the first 400 ms after a start, seek or load and at or below -70 LUFS; the peak is `None` for digital silence. Levels are rounded to 0.001 dB. A stereo track held as mono (over 20 minutes) is metered as that fold and its readings carry `folded`. Metering six minutes of stereo 44.1 kHz takes about 1 s on an M1 (about 370x real time, `benches/meter.rs`); momentary loudness matches an independent BS.1770-5 computation within 0.005 LU at every block end, the block peaks a 4x reference within 0.02 dB and the highest block peak the analysis's true peak within 0.005 dB (`tests/meter.rs`; EBU Tech 3341 cases 1-5 when the test set is fetched, and any folder of music with `SC_REAL_FIXTURES`). The device part (`Player`, cpal on the default output device at its own rate) sits behind the `playback` feature, which only the desktop app enables, so `sc-cli` never links CoreAudio; it keeps the version heard (back to Processed on each load) and the monitor volume (kept across tracks) and rebuilds a stream with them; a device that goes away, is rerouted or must be rebuilt stops playback with "Output device changed. Press Space to resume." (the next play opens the default device again), while a momentary overload counts as an underrun. Clicks land within 2 samples of their lines after resampling (null-sink test); a real-time null-sink soak with 50 switches runs without an underrun (`tests/player_soak.rs`, 3 s in CI, `SC_SOAK_S=600` for 10 minutes; a thread stalled 100 ms or more by the machine is reported instead of judged). Gains that are not finite numbers are ignored. `cargo run -p sc-engine --release --features playback --example click -- [seconds] [switches]` is the listening check (with switches, the planned gain is -6 dB and it alternates the versions while printing the meters). - Refit (`sc_analysis::refit::refit(evidence, context, GridEdit)`): the analysis is itself a refit with no edit, so the cached evidence reproduces the analysed grid bit for bit. A `GridEdit` holds the user's overrides (meter, typed BPM kept exactly, tapped BPM choosing the nearest lattice of the fitted tempo, octave steps, a placed bar line with bar 1 counted from the first beat, which beat is beat 1, and the fit: `whole` or `start`, which fits tempo, phase and meter to a window from the first beat where the attacks start, via `SolveSettings::fit_window_s`, with bar 1 still counted from the first beat and residuals and verdict still judged over the whole track, so a track whose tempo changes keeps its flag; the window (`refit::start_window_s`, shared with `sc_engine::fit_choice`) is 128 beats of the whole-track tempo (192 when those hold fewer than 32 attacks, no start fit when even those do), shrunk to 64 or 32 when the tempo changes inside it: each window's fit is scored on the same opening (its first 32 beats) by the share of the attacks there on its lines (a breakdown or off-beat attacks cost every window alike), and windows grow from the shortest: once one holds the opening (80 % of its attacks on the lines), a longer one must stay within 0.1 of the best opening score and give its tempo within three standard errors, so a change too small to push attacks off the lines still cuts the window; over a loose intro the longest window within 0.1 of the best is taken; the window starts at the first attack that begins a run of 8 within 48 beats, so a stray hit in an intro is skipped); each choice counts as certain in the confidence, a slower lattice (half tempo, a slower tap) keeps the bar 1 of the tempo it came from, and an edit outside its limits (20-1000 BPM, three octave steps, meters of up to 32 pulses) gives no grid. Too few beats give a grid only from a typed BPM and a placed bar line (reason `Manual`). A refit returns the grid and the residual of every grid line (NaN where no attack is near) in about 2 ms for a six-minute track (`benches/grid.rs`); the grid view's `grid_refit` command sends those residuals as raw f32 bytes. - Tauri commands (bodies in `src-tauri/src/shell.rs`, plain Rust over `sc_engine::Session`): `expand_paths(paths) -> FileEntry[]` (walk and probe, on a blocking thread), `analyze({fileIds, analysis}, onEvent: Channel) -> JobId` (returns at once; the job runs `run_job` on its own thread), `cancel_job(jobId)`, `set_decide_settings(settings) -> Replan{revision, plans}` (refused with `invalidArgument` outside the limits: target -30..-4 LUFS, ceiling -6..0 dBTP, BPM range 40..300), `calibration_target() -> Lufs?`, `restore_session() -> SessionSnapshot` (the rows the engine holds, for a window that reloads; running jobs are cancelled and named in `runningJobs` until they end, since the new page cannot hear them and reads again until none is left; `artefacts` / `artefactsFailed` / `lastExport` hold the latest batch's rekordbox XML and report and what became of it, which that page was never told about). `spawn_job` ends every job through a guard: one that panics outside the workers' `catch_unwind` still leaves the running set, releases its files and sends `aborted` and `finished`, `clear_session()` (Clear list: cancels running jobs and forgets the files; ids are never reused), `app_version`. The grid view (bodies in `src-tauri/src/grid_view/`): `track_open(fileId, onEvent: Channel) -> TrackOpened` (the cached analysis with its evidence, or the file analysed again first with `analysing` events; the saved edit applied; decoding started with `decoded`/`ready`/`failed` events; the player loaded with the planned gain, playing the processed version with the click on; a `player` event at 30 Hz while playing, once when it stops and whenever the version heard changes (also while paused) carries the position, underruns, the version heard (`listen: { version: original|processed, matched }`) and the meter reading for the heard position (`meter: MeterFrame { position, inPeak, inMomentary, outPeak, outMomentary, folded }` in dBTP and LUFS, the peaks covering every block since the previous event, `null` while stopped; about 235 bytes of JSON per event)), `track_close()`, `read_peaks(fileId, samplesPerBin, firstBin, bins)`, `track_cover(fileId)` and `track_onsets(fileId)` as raw bytes, `grid_refit(fileId, edit)` (a u32 header length, the `GridFitHeader` JSON under 4 KB, then one f32 residual per grid line; nothing saved, the click follows; when the edit fits the start, the whole-track grid drifts or the start fit holds 0.2 more of the start window, the header's `fitChoice` holds the share of the start window's grid lines on an attack under each fit, from `sc_engine::fit_choice`), `grid_commit(fileId, edit, confirm) -> RowUpdate`, `player_play(from?)`, `player_pause()`, `player_seek(to)`, `player_set_click(on)`, `grid_player_listen(fileId, listen)` (the version heard, open track only), `player_volume(db?)` (monitor volume up to 0 dB, `null` mutes; for every track, the UI persists it). A settings change (`set_decide_settings`) also moves the open track's player to its new planned gain. `recovery_status() -> RecoveryStatus` (`running`, `skipped`, `failed` or `finished` with the changes recovered and left pending by the start-up recovery). Every command is async; the heavier ones run on a blocking thread. Each `analysed` event carries the settings revision its plan was decided with, so the UI keeps the newest plan when a replan and an analysis cross. Events per job: `started`, `progress` (at most every 100 ms per file), one of `analysed` (row + plan, under 2 KB) / `failed` / `cancelled`, `batch` (at most every 500 ms), `aborted` when the job cannot start, `finished` last. Plugins: dialog (open files and folders), store (settings), opener. -- Export commands (bodies in `src-tauri/src/shell/export.rs`, over `sc_engine::export::run_export_job`, the same `run_batch` and `Task::Process` as `sc-cli process`): `plan_export({fileIds, settings, outDir}) -> ExportPreview{rows: [{fileId, row, error, identity, replacesCopy}], revision, decide, xmlListed, backupFolder, artefactsFolder}` is the confirm step: on a blocking thread, it refuses what `export` refuses (`busy` included, under a reading hold) and plans each row from the session's analysis with `sc_engine::export::preview_row`, which is `plan_export_snapped` as the job calls it and the row the job would show, each row bounded like the job's; nothing is written, and the source is not hashed, which only the record written into the file needs. A file whose identity (`sc_io::txn::file_identity`: device, inode, length, modification time) no longer matches its analysis gets `fileChanged` on its row. The request sends `confirmed{revision, files: [{fileId, identity}]}` back: `export` refuses another revision with `settingsChanged`, and the job fails the row of a file with another identity before anything is written, checking again after the hash that guards it from there on. `export({fileIds, settings, outDir}, onEvent: Channel) -> JobId` runs on its own thread and is cancelled by `cancel_job`; `revert_file(fileId) -> RevertResult{entry, update, backup, earlierChanges, notes}` is `undo_file` with the session's cache, then the original analysed again (which fills the cache) with its saved edit applied; `write_rekordbox_xml({fileIds, out}) -> XmlWritten{artefacts, notListed}` analyses the rows, a file exported to a folder as that copy, and builds the same rows as `sc-cli xml` (`sc_engine::export::analysed_row`), into a new folder under the exports folder with the grid report, or alone to a `.xml` file named (only what SoundCheck wrote is replaced); `forget_pending(txn) -> ForgetResult{path, backup, kept, status}` is `sc_io::txn::forget` for a change the recovery report lists, nothing deleted. All four are refused with `recoveryRunning` while start-up recovery runs: the shell's recovery thread holds `RecoveryGate::guard()` (so a panic still opens the gate) and passes its cache to `recover_at_start`; the engine's wait at the gate remains for callers that start earlier. Refused before anything is written: settings out of their limits (a lead under the 2 ms fade after the cut included), a folder that does not fit the place or is not absolute, rows the session does not hold, a file named twice under any spelling (`listedTwice`), two files one folder would hold under one name (`sameOutputName`), a file in the folder holding an artefact's name that SoundCheck did not write (`alreadyExists`). A file about to be replaced in place, and a reverted one, closes in the grid view. One file, one worker: the shell holds the files of every running job and command by the path the file system gives them (`src-tauri/src/shell/holds.rs`; released when the job ends, before `finished` goes out, and on an error, a cancel or a panic). An export or a revert needs a file to itself and is refused with `busy` while anything else holds it, an analysis included; an analysis or the XML command is refused while the file is being exported or reverted, though analyses may share a file; a grid view does not open on a file being exported in place or reverted. A revert holds its file throughout, so a second press cannot undo a second change. The export folder may not lie inside the backups or on a rekordbox USB export, and the batch's artefacts check the latter again. +- Export commands (bodies in `src-tauri/src/shell/export.rs`, over `sc_engine::export::run_export_job`, the same `run_batch` and `Task::Process` as `sc-cli process`): `plan_export({fileIds, settings, outDir}) -> ExportPreview{rows: [{fileId, row, error, identity, replacesCopy}], revision, decide, xmlListed, backupFolder, artefactsFolder}` is the confirm step: on a blocking thread, it refuses what `export` refuses (`busy` included, under a reading hold) and plans each row from the session's analysis with `sc_engine::export::preview_row`, which is `plan_export_snapped` as the job calls it and the row the job would show, each row bounded like the job's; nothing is written, and the source is not hashed, which only the record written into the file needs. Of the rows that plan a write, the first in the list takes a name in the folder (`FolderNames`, comparing as `folder_folds_case` says the volume does) and the later ones are skipped as `sameNameInBatch`; a row that writes nothing or cannot be planned takes no name. In place, a row that plans a write also meets `sc_io::txn::check_writable_in_place`, the transaction's opening refusals, which only look: what it refuses is the error on that row. The UI runs one plan at a time, since a superseded plan still holds its files: the next waits for it, and Write stays disabled until the current plan has answered. A file whose identity (`sc_io::txn::file_identity`: device, inode, length, modification time) no longer matches its analysis gets `fileChanged` on its row. The request sends `confirmed{revision, files: [{fileId, identity, sameName}]}` back: `export` refuses another revision with `settingsChanged`, the job fails the row of a file with another identity before anything is written, checking again after the hash that guards it from there on, and a row with `sameName` stays skipped, the job not deciding names again (with no `confirmed`, which the app always sends, it goes by the paths alone). `export({fileIds, settings, outDir}, onEvent: Channel) -> JobId` runs on its own thread and is cancelled by `cancel_job`; `revert_file(fileId) -> RevertResult{entry, update, backup, earlierChanges, notes}` is `undo_file` with the session's cache, then the original analysed again (which fills the cache) with its saved edit applied; `write_rekordbox_xml({fileIds, out}) -> XmlWritten{artefacts, notListed}` analyses the rows, a file exported to a folder as that copy, and builds the same rows as `sc-cli xml` (`sc_engine::export::analysed_row`), into a new folder under the exports folder with the grid report, or alone to a `.xml` file named (only what SoundCheck wrote is replaced); `forget_pending(txn) -> ForgetResult{path, backup, kept, status}` is `sc_io::txn::forget` for a change the recovery report lists, nothing deleted. All four are refused with `recoveryRunning` while start-up recovery runs: the shell's recovery thread holds `RecoveryGate::guard()` (so a panic still opens the gate) and passes its cache to `recover_at_start`; the engine's wait at the gate remains for callers that start earlier. Refused before anything is written: settings out of their limits (a lead under the 2 ms fade after the cut included), a folder that does not fit the place or is not absolute, rows the session does not hold, a file named twice under any spelling (`listedTwice`), a file in the folder holding an artefact's name that SoundCheck did not write (`alreadyExists`). A file about to be replaced in place, and a reverted one, closes in the grid view. One file, one worker: the shell holds the files of every running job and command by the path the file system gives them (`src-tauri/src/shell/holds.rs`; released when the job ends, before `finished` goes out, and on an error, a cancel or a panic). An export or a revert needs a file to itself and is refused with `busy` while anything else holds it, an analysis included; an analysis or the XML command is refused while the file is being exported or reverted, though analyses may share a file; a grid view does not open on a file being exported in place or reverted. A revert holds its file throughout, so a second press cannot undo a second change. The export folder may not lie inside the backups or on a rekordbox USB export, and the batch's artefacts check the latter again. - Export events: rows go `started`, `progress`, `processing` (plan known, write starting), `written` (in place and verified, its own analysis and grid check running; the row's stage is `writing`), then `exportDone`, or `exportSkipped` after `started`, or `failed`/`cancelled`. After the last file `artefacts{folder, xml, report, listed}` (or `artefactsFailed`; none with the XML off, as with `sc-cli process --no-xml`, or when every file failed) and `finished`. Each carries an `ExportRow{stage, action: write{gainDb, cut} | xmlOnly{reason} | skip{reason}, positionsShift, gridWithheld, notices, verification, gridCheck, editCarried, revertible, notes}` of at most 3 KB of JSON whatever a file holds; `notes` starts with the plan in words (`sc_engine::export::wording`: why a file is skipped or XML-only, its notices, a withheld grid), so the app shows what the grid report says: notes and stale tag labels are cut to fixed JSON budgets (`bounded_strings`), and the whole notes go to the grid report and the log. `exportDone` adds `inPlace` and `update: RowUpdate`: a copy in a folder has no update and its row stays, while a file replaced in place whose own analysis did not finish has none either and its row is cleared, because what it shows describes audio that is gone. The analysis keeps its evidence on the Rust side, so `ProcessDone.analysis` never crosses IPC. The session keeps each row's latest ended export for `restore_session` (`SessionRow.export`), a failed or cancelled export leaves the one before it standing, and `SessionRow.revertible` says a change is in effect that `revert_file` can put back, also with no export row after a revert that left an earlier change. -- A copy to a folder may replace a file at its path only when `sc_io::txn::copy_at` says SoundCheck wrote it of this file: its sidecar records a copy with the file's current length and BLAKE3 (identity read before hashing and compared after), it is not the source however reached, and it was made from this file (`source_path_hash`, a hash of the resolved path so sidecars in shared folders give no home folder away, or the recorded original hash for a moved file; older sidecars go by the hash alone, which at worst refuses). A copy of another track skips the row as `copyOfOtherTrack`, as does a later file of one batch taking a name (`check_inputs`, comparing names as `folder_folds_case` says the volume does). The transaction takes the file's identity then, checks it again under the target lock and right before an atomic rename, and `process_file` skips a row whose name is taken by anything else (`otherFileInFolder`), before anything is written. `apply` never replaces. `write_artefacts` merges with what SoundCheck wrote in the folder (`export/artefacts/merge.rs`): the XML by location, the report by file, entries whose file is gone dropped, sorted by that key with `TrackID`s numbered in that order, so a folder's artefacts do not depend on the order of its batches; the foreign-file guard runs before anything there is read as ours, and a file of ours that does not read whole is refused (`artefact_conflict` before the job, `write_artefacts` again before writing, which ends as `artefactsFailed`), never taken for an empty one. `ExportSkip::cure` says what could make a skipped file writable: the file itself never, else a grid, settings or what is in the folder; the UI plans those again and counts them as work only if the plan differs. `AnalyzeReport.identity` and `bpm_range` are read before the analysis, so a file changed during it does not match afterwards, and `SessionRow.analysedRange` keeps each row's range across a reload. +- A copy to a folder may replace a file at its path only when `sc_io::txn::copy_at` says SoundCheck wrote it of this file: its sidecar records a copy with the file's current length and BLAKE3 (identity read before hashing and compared after), it is not the source however reached, and it was made from this file (`source_path_hash`, a hash of the resolved path so sidecars in shared folders give no home folder away, or the recorded original hash for a moved file; older sidecars go by the hash alone, which at worst refuses). A copy made from another file, or from this one before it was moved and changed, skips the row as `copyOfOtherTrack`; a later file of one batch taking a name skips as `sameNameInBatch`. The transaction takes the file's identity then, checks it again under the target lock and right before an atomic rename, and `process_file` skips a row whose name is taken by anything else (`otherFileInFolder`), before anything is written. `apply` never replaces. `write_artefacts` merges with what SoundCheck wrote in the folder (`export/artefacts/merge.rs`): the XML by location, the report by file, entries whose file is gone dropped, sorted by that key with `TrackID`s numbered in that order, so a folder's artefacts do not depend on the order of its batches; the foreign-file guard runs before anything there is read as ours, and a file of ours that does not read whole is refused (`artefact_conflict` before the job, `write_artefacts` again before writing, which ends as `artefactsFailed`), never taken for an empty one. `ExportSkip::cure` says what could make a skipped file writable: the file itself never, else a grid, settings or what is in the folder; the UI plans those again and counts them as work only if the plan differs. `AnalyzeReport.identity` and `bpm_range` are read before the analysis, so a file changed during it does not match afterwards, and `SessionRow.analysedRange` keeps each row's range across a reload. - `process_file` reads and hashes the source first (`ExportSource::read`, never an older probe), before its analysis, so a file that changes during the analysis is refused rather than written from a plan of other bytes. A planned cut snaps with the render's own parser before the Serato skip is decided, so a container the render cannot parse fails with the render's error instead of a skip worded about Serato. A cut copy of a file with unread tags carries the notice `seratoCuesMayHaveShifted`. ## Storage diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index e23b680..db46696 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -20,7 +20,7 @@ SoundCheck levels a DJ library to one loudness and makes new tracks start exactl 2. **Loudness fix** behind a top-level **DJ | Streaming** switch. DJ: one fixed, persisted **DJ target** on S-P95 (default -11 until measured; "Calibrate from my library" sets it once) and **Club hot** -8. Streaming: Spotify -14, Apple -16 on Integrated. Targets can be set from -30 to -4 LUFS and ceilings from -6 to 0 dBTP. Gain only, ceiling-capped (-0.5 dBTP DJ, -1.0 streaming), "Short by X LU" when headroom runs out; MP3 via lossless `global_gain` in 1.5 dB steps with the residual shown. Per-track override. 3. **Grid inspect and fix** per track (`Enter` or a double-click on an analysed row opens it, `Esc` returns with the row selected): waveform at beat zoom auto-located to bar 1 (the original in grey, the planned gain lighter inside it), bar ruler numbering every beat or pulse when zoomed in (9/8 2+2+2+3 reads `1 2 3 2 5 2 7 2 3`), grid overlay, residual lane (per bar / per beat), an overview strip of the whole track that moves the view, a cursor readout (time, bar and beat or pulse and group, residual, short-term loudness), the fit with its numbers coloured against the verdict limits, drift card with numbers; while playing, the waveform scrolls under the playhead once it reaches the middle of the view (with macOS Reduce motion it pages instead); in a window narrower than 1000 px the rail folds into a one-line fit strip above the waveform (confidence, BPM, meter, P95 and max, verdict) with the rest behind Details (`I`), and the toolbars keep their icons (Before / after, Beat 1 here) and the time signature; keys: `Space` play (from one beat before bar 1 the first time), `Home` back to bar 1, `C` click, `A` the analysed grid dashed under the edited one, `D` bar 1 at the pointer, `,` / `.` nudge, `1`-`9`, `T` tap, `M` meter, `R` reset, `Cmd+Z` / `Cmd+Shift+Z`, arrows pan, `+` / `-` zoom, `Enter` confirm and open the next Needs-review track, `N` next without confirming, `B` original / processed, `Shift+B` match level, `Shift+Up` / `Shift+Down` volume +/-1 dB; fixes: drag anchor (kick-snapped), nudge +/-1 ms (10 ms, 1 beat), type/tap BPM, x2 / /2, `1`-`n` set which beat is beat 1, **fit to the whole track or its start** (offered when the tempo changes within the track: Start fits the grid to the opening from the first kick, up to 128 beats and fewer when the tempo already changes within them, where a DJ mixes in, each option showing the share of kicks it holds there; the rest still reads Drifts), meter picker (runner-up first), re-fit from here, reset, undo; ruler and click accents follow the grouping; **click audition** (accented downbeat) on a minimal in-app player, which plays the track at its planned gain (a settings change applies to the open track at once; the click follows that level and stays 6 dB over the music). **Live meters** either side of the waveform, its full height: IN (the original) on the left, OUT (at the planned gain, what an export holds) on the right, 40 px wide (16 px in a window narrower than 1000 px, the numbers then in a tooltip), one scale from -36 to +3 dB with ticks every 6 dB; the bar is the true peak (dBTP) with an instant attack, coloured in fixed zones (green below -12 dB, yellow from -12 to -3 dB, red above -3 dB), a peak-hold tick that holds 1.5 s then falls 20 dB per 1.7 s, and the momentary loudness (400 ms, LUFS) as a tick with a small arrow; the held peak and the momentary loudness are written above each bar in 12 px numbers, their units in the tooltip; OUT also draws the loudness target (dashed: the S-P95 target in DJ mode, the integrated target in Streaming mode) and the true-peak ceiling; a peak over the ceiling (OUT) or over 0 dBTP (IN) turns the peak readout and tick red until clicked or another track opens; stopped, the bars are empty and the strips show the track's numbers instead (IN `S-P95 -9.2 · TP -0.3`, OUT the planned values, Integrated in Streaming mode); OUT always reads IN plus the planned gain, whatever is heard and whatever the volume. **A/B audition** in the transport: `Original | Processed -2.3 dB` switches what is heard at the same position without a gap (a 10 ms ramp; every opened track starts on Processed), the meter of the version heard at full brightness and the other dimmed; **Match level** plays both at the same loudness (with gain only the two then sound the same; it matters once the limiter changes the sound); the click follows the switch. **Volume** right of play/pause: a 96 px slider (an icon with a popover in a window narrower than 1000 px, or when the transport row is too narrow for it), 0 dB at the right end, -60 dB then off at the left, the level shown on hover and while dragging, `Shift`+drag for fine steps, the speaker icon mutes; it only sets what is heard (not the meters, the planned gain or any file) and is remembered across tracks and restarts. A stereo file longer than 20 minutes plays as mono (left and right averaged), and its meter readings are marked as such. Edits are saved (under `~/Library/Application Support/app.soundcheck.desktop/grid-edits`, so they survive restarts and a cleared cache) and apply while the audio is unchanged (a DJ app writing tags keeps them; another master of the track drops them). A grid the user confirms by ear leaves the Needs-review queue; its drift, residual and BPM-range marks stay on the row, and should a new analysis give a different grid for the same edit, the track returns to the queue. 4. **Two modes**: **Prepare** (new tracks; lossless files cut at the head so the first bar line lands a lead after the start (5 ms until calibrated against rekordbox; the bar line is bar 1 or one extrapolated from it by whole bars at the written two-decimal BPM), removing at most the bar's last beat ("Cut 0.21 s"); the cut moves back up to 1 ms to the quietest moment, so the bar line lands up to 1 ms past the lead, and the first 2 ms fade in from silence, so the cut never clicks; a file whose first bar line already lies before the lead is not cut, since that would mean adding silence ("Starts on bar 1" when that line is bar 1 itself, else "Starts on a bar line (bar 1 at 8.00 s)"); when the first bar line lies more than a beat after the lead, nothing is cut ("Not cut: bar 1 1.00 s in"; "Not cut: first bar line 1.00 s in (bar 1 at 9.00 s)") and the grid travels in tags and the XML, because cutting more would remove music; a track whose grid needs review is not cut until the user confirms its grid by ear ("Not cut: grid needs review"), because a cut at an untrusted bar 1 would start the file in the wrong place; its gain and loudness tags are still written, but not its tempo or grid (no BPM tag, and the `SOUNDCHECK` record says `bpm=none;bar1=none`), so a BPM tag that disagreed with the grid stays and the track still asks for review after the export; Library mode does not write such a grid either, and the row says the BPM and bar 1 were withheld; "grid only" skips such a track ("its grid needs review"), because it would write nothing but an empty record; the cut shown, the frame count and the bar 1 recorded in the file are planned from where the cut actually falls after the 1 ms move; in place allowed; Serato data is detected when a file is added (ID3 `GEOB` objects described `Serato ...` in WAV, AIFF and MP3, `SERATO_*` Vorbis fields in FLAC; M4A is not read): an in-place cut of such a file is refused, because its Serato cue points are positions in the audio and would move (also when the file's tags cannot be read to rule Serato data out), while a cut copy in another folder keeps its Serato cue points and beat grid byte for byte, so they sit late by the cut in Serato and the row says so; MP3/AAC get a metadata-only grid in the rekordbox XML ("XML only", noted as not yet verified against the encoder delay; with the XML off, nothing is written; an MP3/AAC file without a grid, or whose grid needs review and was not confirmed, is skipped, since the XML would carry nothing for it, and so is one in Library mode whose grid was not confirmed) + "Convert to AIFF copy + cut") and **Library** (never change length; loudness only by default; rekordbox XML grid opt-in: the batch's XML lists a Library track only when its grid was confirmed in SoundCheck, because importing it replaces the DJ app's own grid; cut-to-grid only into a new folder). A batch holding files with Serato data runs in Library even with Prepare selected, and the rail says so; choosing Prepare for those files' folder is remembered per folder. The saved mode is left alone, so the next batch of new tracks is Prepare again. -5. **Export**: from the app, the rail's Export section (Prepare | Library; To folder, the default, with a folder picker that is remembered | In place with backup, an explicit choice; depth source / 16 / 24; Grid only; Write TBPM, on by default in Prepare and off in Library, each mode remembering its own; Write rekordbox XML) and the Export button, also in the top bar of a narrow window, which takes every analysed row with no finished export. A file already exported is never written again without asking: a row exported with other settings says so ("Exported at -11.0", "Exported to ~/Old", likewise depth and mode), and the confirm step says "18 exported at -11.0 stay as they are" with an unticked "Export them again at -8.0"; a saved grid that differs from what was written, or a revert, brings a file back as new work. A row skipped or left to the XML goes along with a batch but makes none on its own, unless the settings changed. What is being typed in a field is committed and reaches the engine first. The button then shows what the engine plans, file by file, with nothing written until it is confirmed ("Write 10 files"): "12 files: 9 gain, 3 cut, 2 skipped; backups in ~/Music/SoundCheck Backups" (a file with both a gain and a cut counts under each), the target and ceiling, where the XML and report go, when a cut will shift positions and by how much, why each skipped file is skipped, which earlier copies are replaced and which files in the folder are left. A file that changed since it was analysed, or after the summary, says so on its row and to press Analyse, which is lit for it and brings the row up to date. A skipped row stays out of the count: it goes along with a batch and is written only if the plan now says otherwise, which is what a confirmed grid, other settings or a cleared folder can change; a skip about the file itself (its sample rate, channels, format or silence) changes with none of them. The job refuses settings that changed after the summary, and fails the row of a file that changed after it ("changed since you confirmed") before anything is written. Export again to one folder replaces a copy SoundCheck wrote there, when its sidecar still proves it, so the path and whatever a DJ app keyed to it stay; any other file of that name is left alone and its row skipped, and so is a copy of another track of that name: a copy is replaced only when it was made from this file (the same path, or a moved file byte for byte the same). Two files of one batch that would share a name write the first in the list and skip the later one, comparing names as the folder's volume does. One folder has one rekordbox XML and report, describing that folder: a batch adds to them, entries whose file is gone are dropped, and rows are sorted by location, so the result does not depend on the order of the batches. An XML or report in the folder that starts like SoundCheck's but cannot be read whole refuses the export, naming the file, rather than lose what it lists: move it away or choose another folder; one that turns unreadable during the job is left as it is and the banner above the table says so. Export is disabled with its reason while another job runs, a revert runs, the check for changes a crash interrupted runs, no folder is chosen ("Choose a folder", which opens the picker; in the top bar of a narrow window Export itself does), or tracks were analysed under another BPM range ("3 tracks need Analyse for this BPM range"). Files go to a folder by default, the originals untouched, or in place with a backup when asked for; format source / AIFF / WAV 24-bit / FLAC; DJ-safe validation (44.1/48 kHz, integer PCM, stereo, plain PCM header, FORM AIFF); tags `TBPM` + `TXXX:BPM`, ReplayGain 2.0, `SOUNDCHECK` record; **rekordbox XML per batch** (carrier, with Analysis Lock advice): `soundcheck-rekordbox.xml` lists only files with a grid it may carry, each at its path now, spelled exactly as its folder lists the name (what the Finder, and rekordbox when it added the file, see: macOS opens a file under other Unicode spellings and cases too, and a FAT32 stick even reports back whichever spelling reached the file first, but rekordbox matches an existing track by its location, so any other spelling would add the track a second time), named by its own title and artist tags (the file name when it has no title), with one 4/4 `TEMPO` whose `Inizio` is the first beat in the file (after a Prepare cut, bar 1 at the lead), `Bpm` the written two-decimal BPM and `Battito` that beat's place in its bar; nothing else about the track is written (`Name`, `Artist`, `TotalTime`, `AverageBpm`, `Location` and the `TEMPO`, plus the XML's own `TrackID` that its playlist refers to; rekordbox matches the track by its location and keeps its own id), because **importing a track from the XML into rekordbox's collection overwrites that track's information, beat grid and BPM with the XML's: import one track first and check it before importing the rest**; a track whose grid needs review and was not confirmed, without a grid, or in a meter other than 4/4 is left out of the XML (importing it could only change its information, or clear rekordbox's own grid), and the report says why; its playlist is named after the batch (`SoundCheck 2026-10-11 14.05.33`), since rekordbox replaces a playlist of the same name on import; `grid-report.csv` (one row per file: file, mode, action, gain, cut, BPM, bar 1, what the XML carries or why not, grid check (the self-check's result), notes; UTF-8 with a byte-order mark so spreadsheets show Turkish names; comma-separated with decimal points, so Excel set to a Turkish or other locale that uses `;` and decimal commas splits it wrongly when opened by double-click: import it through Data > From Text/CSV choosing comma and point, or open it in Numbers); both go into the export folder, or for an export in place into a new `~/Music/SoundCheck/exports//` folder, and replace only files of those names that SoundCheck wrote (a foreign file of either name in the export folder refuses the export before any file is written); output self-check (grid check): every written file is analysed again and its grid compared with the source's own analysis: the detector's grid of the source (for a grid the user edited, solved with the user's meter, tempo octave and fitted part, but not the bar line they placed, the beat 1 they chose or the tempo they typed) put its bar line at some offset from the exported bar 1, and the written file's analysis is expected to put one within 5 ms of the same place (after a Prepare cut, bar 1 sits at the lead), compared modulo the bar, at a tempo within 0.005 BPM of the source's; so a nudged bar 1, a chosen beat 1 or a typed tempo is neither confirmed nor contradicted. **What it proves and what it does not**: it is SoundCheck's own detector reading the written audio again, and that detector is not stable under gain or a sub-millisecond shift: gain alone can change the beat model's downbeat votes and move its bar 1 by a whole beat when nothing moved in time (the write's exact frame count pins how much was cut, not where the music landed). So agreement shows only that the detector still reads the grid where the export put it, not that rekordbox or another DJ app will agree (the release checklist's rekordbox protocol checks that), and disagreement does not show the file is wrong, though it cannot rule that out: on a confirmed grid a real misplacement would show only as an advisory with its numbers. It has three outcomes besides `pass` and `not checked`: (1) on a grid you confirmed, any disagreement is advisory: `the analysis reads this differently; your confirmed grid was kept: `, never shown as a failure; (2) on a grid you did not confirm, when its bar lines sit a whole number of the meter's beats away (a quarter note in 4/4, three eighth notes in 6/8) within the same 5 ms, `check bar 1: the analysis reads it 1 beat earlier (...)`, because the beats line up and only which beat is bar 1 is in question, so listen and choose (the row's amber state); (3) on a grid you did not confirm, any other disagreement is reported with its numbers: `off by X ms`, `BPM differs by Y`, `meter differs` or `no grid found`. `not checked` gives the reason (no grid exported, grid withheld because it needs review, a meter whose beats differ in length such as 9/8 as 2+2+2+3, a file left to the XML). The result, with its raw numbers in every outcome, is recorded in the file's sidecar and the grid report's `grid_check` column, printed by `sc-cli process` and carried to the app for the row's chip, which tells the outcomes apart by the result's type; no outcome fails the export or a release, and a sidecar written by an earlier version with a disagreement on a confirmed grid reads as advisory; rekordbox USB exports refused with explanation; FLAC re-analysis notice; loudness tags a gain change makes stale are reported and carried unchanged: album Replay Gain, `RVA2`, iTunes `iTunNORM` (`COMM`), R 128 gains and Serato Autotags (auto gain); the track Replay Gain is replaced; the sidecar `.soundcheck.json` of an exported file records the export settings, the plans, the grid as exported (bar 1 and the first bar line in the output's samples, the written BPM) and the source's loudness; a grid the user edited or confirmed is carried over to the exported file (bar 1 moved back by the cut), so a confirmed grid stays confirmed after the export, and the original's edit is kept beside it, so an undo brings it back; an export is refused, before anything is written, when the file changed between its plan and its write (for example, another export wrote it); Library keeps the file's modification time, Prepare does not; export waits for the start-up recovery, so no file is written while an interrupted change may still be repaired; a file's `SOUNDCHECK` record is read back when it is added, so a later run can see it was already processed (a record of a later format or a damaged one still marks the file as processed, with the reason it could not be read). +5. **Export**: from the app, the rail's Export section (Prepare | Library; To folder, the default, with a folder picker that is remembered | In place with backup, an explicit choice; depth source / 16 / 24; Grid only; Write TBPM, on by default in Prepare and off in Library, each mode remembering its own; Write rekordbox XML) and the Export button, also in the top bar of a narrow window, which takes every analysed row with no finished export. A file already exported is never written again without asking: a row exported with other settings says so ("Exported at -11.0", "Exported to ~/Old", likewise depth and mode), and the confirm step says "18 exported at -11.0 stay as they are" with an unticked "Export them again at -8.0"; a saved grid that differs from what was written, a source that changed on disk and was analysed again, or a revert, brings a file back as new work. A row skipped or left to the XML goes along with a batch but makes none on its own, unless the settings changed; turning the XML on brings back a row only the XML could carry. What is being typed in a field is committed and reaches the engine first. The button then shows what the engine plans, file by file, with nothing written until it is confirmed ("Write 10 files"): "12 files: 9 gain, 3 cut, 2 skipped; backups in ~/Music/SoundCheck Backups" (a file with both a gain and a cut counts under each), the target and ceiling, where the XML and report go, when a cut will shift positions and by how much, why each skipped file is skipped, which earlier copies are replaced and which files in the folder are left. Write waits until the plan has answered and no earlier plan is still reading the files. An export in place that would refuse a file (a symbolic link, a locked, read-only or hard-linked file, one on a rekordbox USB export) says so on its row, marked refused, and the file stays out of the job and out of the count; the table keeps the refusal on its row once the summary closes, as it does for an export that failed. A file that changed since it was analysed, or after the summary, says so on its row and to press Analyse, which is lit for it and brings the row up to date. A skipped row stays out of the count: it goes along with a batch and is written only if the plan now says otherwise, which is what a confirmed grid, other settings or a cleared folder can change; a skip about the file itself (its sample rate, channels, format or silence) changes with none of them. The job refuses settings that changed after the summary, and fails the row of a file that changed after it ("changed since you confirmed") before anything is written. Export again to one folder replaces a copy SoundCheck wrote there, when its sidecar still proves it, so the path and whatever a DJ app keyed to it stay; any other file of that name is left alone and its row skipped, and so is a copy of another track of that name: a copy is replaced only when it was made from this file (the same path, or a moved file byte for byte the same). Two files of one batch that would share a name write the first in the list that plans a write and skip the later one, comparing names as the folder's volume does: a file that is itself skipped, left to the XML or not plannable takes no name, and a file the summary said was skipped stays skipped when the job runs. Each skip says which it is and what to do ("rename one, or choose another folder"): a file earlier in the export takes the name, or the folder holds a copy of that name made from another file, or from this one before it was moved and changed. One folder has one rekordbox XML and report, describing that folder: a batch adds to them, entries whose file is gone are dropped, and rows are sorted by location, so the result does not depend on the order of the batches. An XML or report in the folder that starts like SoundCheck's but cannot be read whole refuses the export, naming the file, rather than lose what it lists: move it away or choose another folder; one that turns unreadable during the job is left as it is and the banner above the table says so. Export is disabled with its reason while another job runs, a revert runs, the check for changes a crash interrupted runs, no folder is chosen ("Choose a folder", which opens the picker; in the top bar of a narrow window Export itself does), or tracks were analysed under another BPM range ("3 tracks need Analyse for this BPM range"). Files go to a folder by default, the originals untouched, or in place with a backup when asked for; format source / AIFF / WAV 24-bit / FLAC; DJ-safe validation (44.1/48 kHz, integer PCM, stereo, plain PCM header, FORM AIFF); tags `TBPM` + `TXXX:BPM`, ReplayGain 2.0, `SOUNDCHECK` record; **rekordbox XML per batch** (carrier, with Analysis Lock advice): `soundcheck-rekordbox.xml` lists only files with a grid it may carry, each at its path now, spelled exactly as its folder lists the name (what the Finder, and rekordbox when it added the file, see: macOS opens a file under other Unicode spellings and cases too, and a FAT32 stick even reports back whichever spelling reached the file first, but rekordbox matches an existing track by its location, so any other spelling would add the track a second time), named by its own title and artist tags (the file name when it has no title), with one 4/4 `TEMPO` whose `Inizio` is the first beat in the file (after a Prepare cut, bar 1 at the lead), `Bpm` the written two-decimal BPM and `Battito` that beat's place in its bar; nothing else about the track is written (`Name`, `Artist`, `TotalTime`, `AverageBpm`, `Location` and the `TEMPO`, plus the XML's own `TrackID` that its playlist refers to; rekordbox matches the track by its location and keeps its own id), because **importing a track from the XML into rekordbox's collection overwrites that track's information, beat grid and BPM with the XML's: import one track first and check it before importing the rest**; a track whose grid needs review and was not confirmed, without a grid, or in a meter other than 4/4 is left out of the XML (importing it could only change its information, or clear rekordbox's own grid), and the report says why; its playlist is named after the batch (`SoundCheck 2026-10-11 14.05.33`), since rekordbox replaces a playlist of the same name on import; `grid-report.csv` (one row per file: file, mode, action, gain, cut, BPM, bar 1, what the XML carries or why not, grid check (the self-check's result), notes; UTF-8 with a byte-order mark so spreadsheets show Turkish names; comma-separated with decimal points, so Excel set to a Turkish or other locale that uses `;` and decimal commas splits it wrongly when opened by double-click: import it through Data > From Text/CSV choosing comma and point, or open it in Numbers); both go into the export folder, or for an export in place into a new `~/Music/SoundCheck/exports//` folder, and replace only files of those names that SoundCheck wrote (a foreign file of either name in the export folder refuses the export before any file is written); output self-check (grid check): every written file is analysed again and its grid compared with the source's own analysis: the detector's grid of the source (for a grid the user edited, solved with the user's meter, tempo octave and fitted part, but not the bar line they placed, the beat 1 they chose or the tempo they typed) put its bar line at some offset from the exported bar 1, and the written file's analysis is expected to put one within 5 ms of the same place (after a Prepare cut, bar 1 sits at the lead), compared modulo the bar, at a tempo within 0.005 BPM of the source's; so a nudged bar 1, a chosen beat 1 or a typed tempo is neither confirmed nor contradicted. **What it proves and what it does not**: it is SoundCheck's own detector reading the written audio again, and that detector is not stable under gain or a sub-millisecond shift: gain alone can change the beat model's downbeat votes and move its bar 1 by a whole beat when nothing moved in time (the write's exact frame count pins how much was cut, not where the music landed). So agreement shows only that the detector still reads the grid where the export put it, not that rekordbox or another DJ app will agree (the release checklist's rekordbox protocol checks that), and disagreement does not show the file is wrong, though it cannot rule that out: on a confirmed grid a real misplacement would show only as an advisory with its numbers. It has three outcomes besides `pass` and `not checked`: (1) on a grid you confirmed, any disagreement is advisory: `the analysis reads this differently; your confirmed grid was kept: `, never shown as a failure; (2) on a grid you did not confirm, when its bar lines sit a whole number of the meter's beats away (a quarter note in 4/4, three eighth notes in 6/8) within the same 5 ms, `check bar 1: the analysis reads it 1 beat earlier (...)`, because the beats line up and only which beat is bar 1 is in question, so listen and choose (the row's amber state); (3) on a grid you did not confirm, any other disagreement is reported with its numbers: `off by X ms`, `BPM differs by Y`, `meter differs` or `no grid found`. `not checked` gives the reason (no grid exported, grid withheld because it needs review, a meter whose beats differ in length such as 9/8 as 2+2+2+3, a file left to the XML). The result, with its raw numbers in every outcome, is recorded in the file's sidecar and the grid report's `grid_check` column, printed by `sc-cli process` and carried to the app for the row's chip, which tells the outcomes apart by the result's type; no outcome fails the export or a release, and a sidecar written by an earlier version with a disagreement on a confirmed grid reads as advisory; rekordbox USB exports refused with explanation; FLAC re-analysis notice; loudness tags a gain change makes stale are reported and carried unchanged: album Replay Gain, `RVA2`, iTunes `iTunNORM` (`COMM`), R 128 gains and Serato Autotags (auto gain); the track Replay Gain is replaced; the sidecar `.soundcheck.json` of an exported file records the export settings, the plans, the grid as exported (bar 1 and the first bar line in the output's samples, the written BPM) and the source's loudness; a grid the user edited or confirmed is carried over to the exported file (bar 1 moved back by the cut), so a confirmed grid stays confirmed after the export, and the original's edit is kept beside it, so an undo brings it back; an export is refused, before anything is written, when the file changed between its plan and its write (for example, another export wrote it); Library keeps the file's modification time, Prepare does not; export waits for the start-up recovery, so no file is written while an interrupted change may still be repaired; a file's `SOUNDCHECK` record is read back when it is added, so a later run can see it was already processed (a record of a later format or a damaged one still marks the file as processed, with the reason it could not be read). 6. **Trust surface**: Action column (what will happen, in dB/LU/ms) separate from Status (where it is); "Done, verified" only after tiered verification; on-screen tag diff ("Verified: 27/27 identical, 3 added") with the cover rendered from the output file; Revert (the original's bytes and modification time back, its measurements and saved grid edit shown again; a file with no change to revert is refused); a file another job or command is working on is refused, not queued, so pressing Export or Revert twice changes it once; export, Revert and the rekordbox XML button are refused while the check for changes a crash interrupted runs at start, and changes it leaves pending can be forgotten without deleting anything; three-line skip/error copy; positions-shift badge on Prepare rows. During an export a row goes Queued, Processing, Writing, Checking (already verified, only the grid check still running), then Done, verified, Skipped or Failed (the file not changed); Cancel stops the batch, and a cancelled or failed row keeps what it showed before. The Action column shows `Gain -2.3 dB`, `Cut 0.21 s` with its positions-shift badge, `Grid only` or `Grid: XML only (MP3)`, with what was verified under it ("Verified: 27/27 carried, 3 added": every tag block but the format and audio blocks written anew, and a block left out is never hidden). A row exported fine whose grid still asks for a look says "Done, verified" with the reason under it and stays in the Needs review tab, not also in Done, so every row counts in one tab. A row left to the XML that the XML lists says "XML only", and counts under Skipped. The Status column carries the grid check, which only reports, since the export already succeeded: quiet green when it agrees; neutral, never red or amber, when the analysis reads a grid the user confirmed differently ("Reads differently", the confirmed grid is kept, the numbers on hover); amber "Check bar 1" for an unconfirmed grid a whole number of beats off, with count and direction; a quiet warning with the measured difference otherwise; muted "Not checked" with its reason. Selecting a row shows the whole story under the table: the change, what was verified, the check in words, every note, why it was skipped or failed, and Revert. Revert takes about 10 s on a 6-minute track, with a spinner on its row and nothing else blocked. The rows of a running batch cannot be reverted or opened until it ends, since a batch holds all its files; Revert survives a window reload. A window reloaded during an export stops it, waits for the file in flight, and still finds the batch's files and summary; a file it wrote in place whose analysis did not finish is analysed again. If the job has not stopped after a minute, the app says to restart. The footer shows progress and Cancel, then links that show `soundcheck-rekordbox.xml` and `grid-report.csv` in the Finder and "Show in Finder"; it says so plainly when an XML lists no tracks, and the banner above the table says so when the XML or report could not be written (the exported files are fine). The "rekordbox XML" button writes an XML of every analysed row, an exported copy where it is, leaving the files untouched, and each row it leaves out says why. 7. **Batch UX**: drop folders/files, analyse-only, Needs-review queue (`N`/`Enter`/`Esc`), filter chips (`Cmd+F` filters by name), `Cmd+,` for the settings (it moves focus to the settings rail; in a window narrower than 1000 px the rail is a drawer, opened by `Cmd+,` or a toolbar button showing the target, such as `DJ · −11.0 LUFS`, the name filter folds into an icon that opens in the chips' place (a chip still filtering shows in it), and Add folder and Add tracks become one Add menu; a table narrower than 820 px, as in any window under about 1100 px, shows the true peak under the loudness and the meter under the BPM, so each row still fits without scrolling sideways), progress and cancel, resume from cache, per-batch CSV. The track list is remembered across restarts (analyses come back from the cache; a file moved or deleted since shows as "File not found"); **Clear list** empties it without touching any file. 8. **`sc-cli`** with `analyze ` (text, or `--json` with exactly the table's fields, one document per file; `--no-grid` for loudness only, `--bpm-range 70-180` for the DJ app's range, `--model small|full`, `--evidence` to include the model beats, activations and onsets a refit uses, `--no-cache`, `--jobs N` files at once with reports still printed in the order given), `plan ` (what processing would do to each file, decided exactly as the table decides it: `--mode dj|streaming`, `--target`, `--ceiling`; the Action, Status and review reasons per file, then the counts the filter chips show; grid edits saved in the app apply; `--batch-mode prepare|library` adds a line per file with what exporting would do: the gain, the head cut (`Cut 0.21 s`, where the cut falls after its move to the quietest frame, `Starts on bar 1`, `Starts on a bar line (bar 1 at 8.00 s)`, `Not cut: bar 1 1.00 s in`, `Not cut: grid needs review (bar 1 at 2.30 s; confirm it in the app)`, `length kept` in Library mode), the tags written (`BPM` in Prepare mode, Replay Gain 2.0 track gain and peak, the `SOUNDCHECK` record) and notes (a cut copy's Serato cues), or `XML only` with the reason (MP3/AAC until their writes arrive; with `--grid-only`, FLAC, sources other than 16- or 24-bit integer PCM and files without a tag), or "skipped" in three lines (what, why, what to do: Serato data found in the file's tags, or tags that could not be read to rule it out, and a cut in place, a sample rate other than 44.1/48 kHz, more than two channels, an analysed-only codec, silence, no grid, nothing to write with `--no-xml`); `--grid-only` plans no gain and no cut, `--lead-ms N` (2-50, default 5; the floor is the 2 ms fade after the cut, so bar 1's attack never sits inside it) sets the lead, `--no-xml` turns the batch's rekordbox XML off; with `--json` the outcome is the document's `export` field), `labels ` (evaluation labels from each file's grid as the app shows it, edits applied: `file,bpm,bar1_s,meter,grouping,confirmed,fit`, `fit` being `whole` or `start`; `--confirmed-only`), `bench ` (time and real-time factor of each stage), `eval --labels --dir ` (scores BPM, meter, bar 1 (modulo the label's bar; only for rows with a labelled tempo; a row with `fit` `start` against the analysis refitted to the start) and integrated loudness against hand labels; `scripts/ffmpeg-reference.sh` writes the loudness reference), `cache path|clear` (the analysis cache lives under `~/Library/Caches/app.soundcheck.desktop/analysis`, keyed by the file's identity (inode and change time, not the disk, so a replugged USB stick keeps its analyses), size and time, settings and app version, and a file's entry is dropped whenever SoundCheck changes, undoes or recovers it, so a file SoundCheck rewrote at the same length and time, or put back by an undo, is analysed again), `apply --gain-db ` (WAV, AIFF, FLAC, one after the other in the order given; a file listed twice under any spelling (`T.wav`, `./T.wav`, its full path, another case) is changed once and its later mentions refused, and with `--out` two files that would get the same name there are caught before anything is written, only the first being written; the gain on every sample, a boost that would clip refused with the largest gain that would not; `--trim-samples N` cuts the start: the cut lands up to 1 ms before sample N per channel, on the quietest frame there (never after N), and the first 2 ms after it fade in from silence so the cut never clicks; the line gives the cut made and the one asked for (`500 frames trimmed (520 requested)`) and cue points and markers move by the cut made, `--bits 16|24` sets the output depth (default the source's, TPDF dither only at 16-bit), `--tag NAME=VALUE` (repeatable) adds or replaces a tag item by a name that is the same for every format: `BPM` (ID3 `TBPM` with the value rounded plus `TXXX:BPM` with the value as given in WAV/AIFF, `BPM` in FLAC), `INITIALKEY` (`TKEY` / `INITIALKEY`), any other name of capitals, digits and `_` (`TXXX:NAME` / `NAME`), written into a WAV/AIFF's existing ID3 chunk or a FLAC's Vorbis comment, never creating a tag; ID3v2.3/2.4 frame ids (`TBPM`, `TKEY`, and the iTunes `TCMP`, `GRP1`, ...) and labels with `:` are refused before any file is touched, while other four-letter names (`MOOD`, `YEAR`) are ordinary names; in place by default: the original is first copied to the backup folder, `~/Music/SoundCheck Backups///` (`--backup-root ` or `SC_BACKUP_ROOT` chooses another; a backup is never overwritten), the new version is written next to it as a hidden temp file, read back and checked, then put in place atomically with the original's extended attributes (Finder tags, comments; macOS's own provenance stamp is the new file's, as for any file the app writes), creation date, permissions and modification time (`--no-keep-mtime` gives it the current time); `--out ` writes copies there instead and leaves the files untouched, never replacing an existing one; next to each written file `.soundcheck.json` records what was asked, what was carried, patched and added, the hashes of original and output and where the backup is (`--no-sidecar` skips it); one line per file, starting with the path as given: gain, depth, frames trimmed, blocks carried/patched, tags added or why not, stale loudness tags, "verified", the backup's path; a refusal prints three lines on stderr (what, why, what to do: rekordbox USB export, symbolic link, Finder-locked, read-only file or folder, access control list, other owner, hard links, not enough free space with the bytes needed and free, not DJ-safe, would clip, changed meanwhile, unsupported format, existing output); `--json` prints one document per file with `schema`, `file`, `ok`, the transaction id, output, backup, sidecar, the request, the render summary, BLAKE3 hashes, notes and per-step timings, or the error class, message, why and what to do), `undo ` (puts back the version before each file's newest change from the backup, read back and checked against its hash, with its metadata (what could not be restored is printed as a note), and says how many earlier changes remain; refused when the file changed since, so nothing is lost; the sidecar is removed, or rewritten for the change before; the backup is kept), `journal` (every change recorded in the backup folder's `journal.jsonl`, newest first: time, id, kind, how it ended or where it stopped, file, backup; `--incomplete` for unfinished ones only; `--forget ` gives up on a pending change so recovery leaves it alone from then on, deleting nothing and saying where its backup is; `--json`; JSON fields and their values are listed in the sc-cli README), `recover` (finishes or rolls back changes a crash or a pulled cable interrupted: before the new version was in place the temp file is removed and the original was never touched; after it, metadata and sidecar are completed; changes on a volume that is not mounted, or on a file another change is working on, stay pending and are tried again, and the exit code is then 3; the app runs it at start (what it found is in the app's log, `~/Library/Logs/app.soundcheck.desktop/soundcheck.log`) and `apply`/`undo` run it first), `process --batch-mode prepare|library` (exports exactly as the app's Export button does, through the same engine job: recovery first, then per file the analysis (cached, grid edits applied), the plan worded as `plan --batch-mode` words it, the write in place after a backup or with `--out ` as a copy, verified, refused before anything is replaced unless the output has the planned length (Library: unchanged; Prepare: shorter by the cut exactly), and a fresh analysis of the written file, whose grid is checked against the exported one (` grid check: pass (bar line +0.4 ms, BPM +0.0003)`, or one of the outcomes above, under the file's line, `gridCheck` in `--json`); `--grid-only`, `--depth source|16|24`, `--lead-ms N`, `--no-tbpm`, `--mode dj|streaming`, `--target`, `--ceiling`, `--json`; one line per file (`: prepare: Gain -2.0 dB, Cut 0.21 s; tags ...; written and verified; backup `, `XML only: ...`, or `skipped` with why and what to do) and a summary `N files: W written, X XML only, S skipped, F failed`; `--no-xml`; unless it, the batch's rekordbox XML and grid report are written into `--out` or a new `~/Music/SoundCheck/exports//` folder and named in two lines), `xml [--out FILE]` (the rekordbox XML of the files named, Library ones included since naming them is the choice: a grid edited or confirmed in the app is used first, else an exported file's grid read back from its sidecar while the file is unchanged, else the analysis; files without a grid it may carry are left out with the reason; to stdout, or to `--out`, which must end in `.xml` and never replaces a file SoundCheck did not write: an audio file a glob put there, or another app's XML, is refused in three lines and left as it is), `grid-check` (later). A file that fails prints an error document and the exit code is 2; a missing beat-tracking model stops the run before any file with the directories searched. diff --git a/src-tauri/src/shell/export.rs b/src-tauri/src/shell/export.rs index b327c43..0d652b9 100644 --- a/src-tauri/src/shell/export.rs +++ b/src-tauri/src/shell/export.rs @@ -19,9 +19,9 @@ use sc_engine::export::{ xml_out_refusal, xml_tracks, }; use sc_engine::{ - Analyzer, BatchFile, BatchRow, BatchSettings, CancelToken, EngineEvent, Place, ProcessSettings, - RevertedAnalysis, Task, XmlSelect, apply_saved, check_inputs, run_batch, undo_file, - write_artefacts, + Analyzer, BatchFile, BatchRow, BatchSettings, CancelToken, EngineEvent, FolderNames, Place, + ProcessSettings, RevertedAnalysis, Task, XmlSelect, apply_saved, check_inputs, run_batch, + undo_file, write_artefacts, }; use sc_io::rekordbox::is_soundcheck_xml; @@ -72,8 +72,8 @@ impl Shell { } /// The rows `file_ids` name, in that order, each named once however it is spelled on disk, and - /// the later of any files one folder would hold under one name: the first in the list is - /// written, and those are skipped. + /// the later of any files one folder would hold under one name, going by their paths alone (the + /// plans, which know a row that writes nothing takes no name, come after this). /// /// # Errors /// `invalidArgument` for no file or a row the session does not hold; `listedTwice` for a @@ -166,9 +166,14 @@ impl Shell { let place = out_dir .as_ref() .map_or(Place::InPlace, |dir| Place::Folder(dir.clone())); - let (files, same_name) = self.distinct_rows(&req.file_ids, &place)?; + let (files, by_path) = self.distinct_rows(&req.file_ids, &place)?; let process = ProcessSettings { - same_name, + // The confirm step decided who takes a name, knowing which rows write; what the user + // was told it would skip stays skipped, whatever dropped out of the job since. + same_name: req.confirmed.as_ref().map_or(by_path, |c| { + let skipped = c.files.iter().filter(|f| f.same_name); + skipped.map(|f| f.file_id).collect() + }), ..process }; if let Some(dir) = &out_dir { @@ -189,17 +194,19 @@ impl Shell { /// What exporting `req.file_ids` would do with each, planned as the job plans them, with /// everything the job would refuse refused here too. Nothing is written. A file that cannot be - /// planned (not analysed yet, changed since it was analysed, its start unreadable) gets its - /// error on its row, as the job would report it on its row. + /// planned (not analysed yet, changed since it was analysed, its start unreadable, an export in + /// place refusing it) gets its error on its row, as the job would report it on its row. Of the + /// rows that plan a write, the first in the list takes a name and the later ones are skipped. /// /// # Errors /// What [`Self::export`] refuses; `busy` while one of the files is being exported or reverted. pub fn plan_export(&self, req: &ExportRequest) -> Result { let (process, files, revision) = self.checked_export(req)?; let _hold = self.hold(&files, HoldKind::Reading)?; + let mut names = process.out_dir.as_deref().map(FolderNames::new); let rows = files .iter() - .map(|file| self.preview_of(file, &process, req)) + .map(|file| self.preview_of(file, &process, req, names.as_mut())) .collect(); let shown = |dir: &Path| dir.display().to_string(); let artefacts_folder = if req.settings.xml { @@ -234,6 +241,7 @@ impl Shell { file: &BatchFile, process: &ProcessSettings, req: &ExportRequest, + names: Option<&mut FolderNames>, ) -> ExportPreviewRow { let file_id = file.file_id; let failed = |error: Error| ExportPreviewRow { @@ -269,9 +277,9 @@ impl Shell { settings: &req.settings, out_dir: process.out_dir.as_deref(), revertible: planning.revertible, - same_name: process.same_name.contains(&file_id), + backup_root: &process.backup_root, }; - match preview_row(&input) { + match preview_row(&input, names) { Ok((mut row, replaces_copy)) => { row.exported_with = Some(ExportedWith::new( process.decide, diff --git a/src-tauri/src/shell/tests.rs b/src-tauri/src/shell/tests.rs index 30248c1..adbfdea 100644 --- a/src-tauri/src/shell/tests.rs +++ b/src-tauri/src/shell/tests.rs @@ -12,6 +12,7 @@ use sc_core::{AudioSpec, Bpm, testsig}; use super::*; mod commands; +mod confirm; mod export; mod grid; mod holds; diff --git a/src-tauri/src/shell/tests/confirm.rs b/src-tauri/src/shell/tests/confirm.rs new file mode 100644 index 0000000..59b785f --- /dev/null +++ b/src-tauri/src/shell/tests/confirm.rs @@ -0,0 +1,451 @@ +//! What the confirm step says is what the job does: only a row that plans a write takes a file +//! name, the job keeps to what the confirm decided, and an export in place refused later is +//! refused in the plan, which writes nothing. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use sc_core::export::{ExportSettings, ExportSkip}; +use sc_core::ipc::{ + ConfirmedExport, ConfirmedFile, ExportAction, ExportPreview, ExportPreviewRow, ExportRequest, + IpcErrorKind, JobEvent, +}; + +use super::support::{Lib, analysed, export, in_folder, prepare, request, sha256, steps, tone_wav}; + +fn no_xml(settings: ExportSettings) -> ExportSettings { + ExportSettings { + xml: false, + ..settings + } +} + +fn to_folder() -> ExportSettings { + no_xml(in_folder(prepare())) +} + +fn in_place() -> ExportSettings { + no_xml(prepare()) +} + +fn name_taken(row: &ExportPreviewRow) -> bool { + row.row.as_ref().is_some_and(|r| { + r.action + == ExportAction::Skip { + reason: ExportSkip::SameNameInBatch, + } + }) +} + +fn writes(row: &ExportPreviewRow) -> bool { + matches!( + row.row.as_ref().map(|r| &r.action), + Some(ExportAction::Write { .. }) + ) +} + +/// What the app sends after `preview`: the rows it could plan, each with what the plan decided. +fn confirmed(mut req: ExportRequest, preview: &ExportPreview) -> ExportRequest { + let planned: Vec<&ExportPreviewRow> = preview + .rows + .iter() + .filter(|r| r.row.is_some() && r.identity.is_some()) + .collect(); + req.file_ids = planned.iter().map(|r| r.file_id).collect(); + req.confirmed = Some(ConfirmedExport { + revision: preview.revision, + files: planned + .iter() + .map(|r| ConfirmedFile { + file_id: r.file_id, + identity: r.identity.clone().expect("planned"), + same_name: name_taken(r), + }) + .collect(), + }); + req +} + +/// A tone of the same name in a folder of its own under the library. +fn tone_in(lib: &Lib, folder: &str, name: &str) -> PathBuf { + let dir = lib.base().join(folder); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + tone_wav(&path, 0.2, 44_100); + path +} + +/// Every file under `dir` with its bytes' hash and modification time; folders by name. +fn tree(dir: &Path) -> BTreeMap, std::time::SystemTime)>> { + let mut found = BTreeMap::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return found; + }; + for entry in entries { + let path = entry.unwrap().path(); + let meta = std::fs::symlink_metadata(&path).unwrap(); + if meta.is_dir() { + found.insert(path.clone(), None); + found.extend(tree(&path)); + } else if meta.is_file() { + found.insert( + path.clone(), + Some((sha256(&path), meta.modified().unwrap())), + ); + } else { + found.insert(path, None); + } + } + found +} + +#[test] +fn regression_a_row_that_cannot_be_planned_takes_no_name() { + let lib = Lib::new(); + let shell = lib.shell(1); + let a = tone_in(&lib, "a", "track.wav"); + let b = tone_in(&lib, "b", "track.wav"); + let c = tone_in(&lib, "c", "other.wav"); + let ids = analysed(&shell, &[&a, &b, &c]); + // `a` changes after its analysis, and another length tells it apart at once. + let mut bytes = std::fs::read(&a).unwrap(); + bytes.extend_from_slice(&[0; 2]); + std::fs::write(&a, &bytes).unwrap(); + + let out = lib.base().join("out"); + let req = request(&ids, to_folder(), Some(&out)); + let preview = shell.plan_export(&req).unwrap(); + assert_eq!( + preview.rows[0].error.as_ref().map(|e| e.kind), + Some(IpcErrorKind::FileChanged) + ); + assert!(writes(&preview.rows[1]), "{:?}", preview.rows[1]); + assert!(writes(&preview.rows[2]), "{:?}", preview.rows[2]); + + // The job writes the two the confirm said it would. + let (_, events) = export(&shell, confirmed(req, &preview)); + assert!(steps(&events, ids[1]).contains(&"exportDone"), "{events:?}"); + assert!(steps(&events, ids[2]).contains(&"exportDone"), "{events:?}"); + assert!(out.join("track.wav").exists()); + assert!(out.join("other.wav").exists()); +} + +#[test] +fn regression_a_row_that_stays_a_skip_takes_no_name() { + let lib = Lib::new(); + let shell = lib.shell(1); + let dir = lib.base().join("a"); + std::fs::create_dir_all(&dir).unwrap(); + let hi_res = dir.join("track.wav"); + tone_wav(&hi_res, 0.2, 96_000); + let plain = tone_in(&lib, "b", "track.wav"); + let ids = analysed(&shell, &[&hi_res, &plain]); + + let out = lib.base().join("out"); + let req = request(&ids, to_folder(), Some(&out)); + let preview = shell.plan_export(&req).unwrap(); + assert_eq!( + preview.rows[0].row.as_ref().unwrap().action, + ExportAction::Skip { + reason: ExportSkip::NotDjSafeRate { + sample_rate_hz: 96_000 + } + } + ); + assert!( + writes(&preview.rows[1]), + "no copy of that name exists or is planned: {:?}", + preview.rows[1] + ); + + let (_, events) = export(&shell, confirmed(req, &preview)); + assert!(steps(&events, ids[1]).contains(&"exportDone"), "{events:?}"); + assert!(out.join("track.wav").exists()); +} + +#[test] +fn of_the_rows_that_write_the_first_in_the_list_takes_the_name() { + let lib = Lib::new(); + let shell = lib.shell(1); + let first = tone_in(&lib, "a", "track.wav"); + let later = tone_in(&lib, "b", "track.wav"); + let ids = analysed(&shell, &[&first, &later]); + let out = lib.base().join("out"); + let preview = shell + .plan_export(&request(&ids, to_folder(), Some(&out))) + .unwrap(); + assert!(writes(&preview.rows[0])); + assert!(name_taken(&preview.rows[1]), "{:?}", preview.rows[1]); + assert!(!preview.rows[1].replaces_copy); + + // The other way round, the other one does. + let swapped = [ids[1], ids[0]]; + let preview = shell + .plan_export(&request(&swapped, to_folder(), Some(&out))) + .unwrap(); + assert_eq!(preview.rows[0].file_id, ids[1]); + assert!(writes(&preview.rows[0])); + assert!(name_taken(&preview.rows[1])); +} + +#[test] +fn regression_a_row_the_confirm_skipped_stays_skipped_when_the_name_taker_drops_out() { + let lib = Lib::new(); + let shell = lib.shell(1); + let a = tone_in(&lib, "a", "track.wav"); + let b = tone_in(&lib, "b", "track.wav"); + let c = tone_in(&lib, "c", "other.wav"); + let ids = analysed(&shell, &[&a, &b, &c]); + + let out = lib.base().join("out"); + let req = request(&ids, to_folder(), Some(&out)); + let preview = shell.plan_export(&req).unwrap(); + assert!(name_taken(&preview.rows[1])); + let mut sent = confirmed(req, &preview); + // The row that took the name goes: recomputing would hand it to the row told it is skipped. + sent.file_ids.retain(|id| *id != ids[0]); + if let Some(c) = sent.confirmed.as_mut() { + c.files.retain(|f| f.file_id != ids[0]); + } + let (_, events) = export(&shell, sent); + + let skipped = events.iter().find_map(|e| match e { + JobEvent::ExportSkipped { file_id, row, .. } if *file_id == ids[1] => Some(row.clone()), + _ => None, + }); + assert_eq!( + skipped.expect("skipped").action, + ExportAction::Skip { + reason: ExportSkip::SameNameInBatch + } + ); + assert!(steps(&events, ids[2]).contains(&"exportDone"), "{events:?}"); + assert!(!out.join("track.wav").exists(), "nothing took the name"); + assert!(out.join("other.wav").exists()); +} + +#[test] +fn a_name_taken_only_in_this_batch_says_so_and_what_to_do() { + let lib = Lib::new(); + let shell = lib.shell(1); + let a = tone_in(&lib, "a", "track.wav"); + let b = tone_in(&lib, "b", "track.wav"); + let ids = analysed(&shell, &[&a, &b]); + let out = lib.base().join("out"); + let preview = shell + .plan_export(&request(&ids, to_folder(), Some(&out))) + .unwrap(); + assert_eq!( + preview.rows[1].row.as_ref().unwrap().notes, + [ + "a file earlier in this export takes this name in the folder; rename one, or choose \ + another folder" + ] + ); + assert!( + !out.exists(), + "the plan writes nothing, not even the folder" + ); +} + +#[test] +fn regression_the_plan_says_what_an_export_in_place_would_refuse_and_changes_nothing() { + let lib = Lib::new(); + let shell = lib.shell(1); + let plain = lib.tone("plain.wav", 0.2); + let target = lib.tone("target.wav", 0.2); + let link = lib.music().join("link.wav"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let linked = lib.tone("linked.wav", 0.2); + std::fs::hard_link(&linked, lib.base().join("second-name.wav")).unwrap(); + let read_only = lib.tone("read-only.wav", 0.2); + + let ids = analysed(&shell, &[&plain, &link, &linked, &read_only]); + let mut perms = std::fs::metadata(&read_only).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&read_only, perms).unwrap(); + + let before = tree(&lib.base()); + let req = request(&ids, in_place(), None); + let preview = shell.plan_export(&req).unwrap(); + assert_eq!(tree(&lib.base()), before, "the plan writes nothing"); + assert!(!lib.backups().join("journal.jsonl").exists()); + + assert!(writes(&preview.rows[0]), "{:?}", preview.rows[0]); + for (row, what) in preview.rows[1..] + .iter() + .zip(["link", "hard link", "read-only"]) + { + let error = row + .error + .as_ref() + .unwrap_or_else(|| panic!("{what}: {row:?}")); + assert_eq!(error.kind, IpcErrorKind::InPlaceRefused, "{what}"); + assert_eq!(error.file_id, Some(row.file_id), "{what}"); + assert!( + row.row.is_none() && row.identity.is_none(), + "{what}: {row:?}" + ); + } + + // The job gets what the confirm said it could write, and writes it. + let plain_before = sha256(&plain); + let target_before = sha256(&target); + let (_, events) = export(&shell, confirmed(req, &preview)); + assert!(steps(&events, ids[0]).contains(&"exportDone"), "{events:?}"); + assert_ne!(sha256(&plain), plain_before); + assert_eq!(sha256(&target), target_before); + assert!( + !events.iter().any(|e| matches!(e, JobEvent::Failed { .. })), + "{events:?}" + ); +} + +// `chflags` and the flags it sets are macOS only. +#[cfg(target_os = "macos")] +#[test] +fn a_locked_file_is_refused_in_the_plan() { + let run = |args: &[&str]| { + assert!( + std::process::Command::new(args[0]) + .args(&args[1..]) + .status() + .unwrap() + .success(), + "{args:?} failed" + ); + }; + let lib = Lib::new(); + let shell = lib.shell(1); + let locked = lib.tone("locked.wav", 0.2); + let ids = analysed(&shell, &[&locked]); + let path = locked.to_str().unwrap(); + run(&["chflags", "uchg", path]); + let preview = shell.plan_export(&request(&ids, in_place(), None)); + // Unlocked before anything is asserted: the temp folder has to go, whatever the result. + run(&["chflags", "nouchg", path]); + assert_eq!( + preview.unwrap().rows[0].error.as_ref().map(|e| e.kind), + Some(IpcErrorKind::InPlaceRefused) + ); +} + +#[test] +fn a_link_that_goes_to_a_folder_is_not_refused() { + let lib = Lib::new(); + let shell = lib.shell(1); + let target = lib.tone("target.wav", 0.2); + let link = lib.music().join("link.wav"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let ids = analysed(&shell, &[&link]); + let out = lib.base().join("out"); + let preview = shell + .plan_export(&request(&ids, to_folder(), Some(&out))) + .unwrap(); + assert!(writes(&preview.rows[0]), "{:?}", preview.rows[0]); +} + +#[test] +fn a_file_an_export_in_place_would_skip_anyway_is_not_refused() { + let lib = Lib::new(); + let shell = lib.shell(1); + let target = lib.tone_at("target.wav", 0.2, 96_000); + let link = lib.music().join("link.wav"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let ids = analysed(&shell, &[&link]); + let preview = shell.plan_export(&request(&ids, in_place(), None)).unwrap(); + assert!( + matches!( + preview.rows[0].row.as_ref().map(|r| &r.action), + Some(ExportAction::Skip { .. }) + ), + "{:?}", + preview.rows[0] + ); +} + +#[test] +fn a_moved_and_changed_source_is_told_true_of_the_copy_in_the_folder() { + let lib = Lib::new(); + let shell = lib.shell(1); + let a = tone_in(&lib, "a", "track.wav"); + let ids = analysed(&shell, &[&a]); + let out = lib.base().join("out"); + let (_, events) = export(&shell, request(&ids, to_folder(), Some(&out))); + assert!(steps(&events, ids[0]).contains(&"exportDone"), "{events:?}"); + + // Moved, and changed the way new tags change it: not the bytes the copy was made from. + let moved_dir = lib.base().join("moved"); + std::fs::create_dir_all(&moved_dir).unwrap(); + let moved = moved_dir.join("track.wav"); + std::fs::rename(&a, &moved).unwrap(); + let mut bytes = std::fs::read(&moved).unwrap(); + bytes.extend_from_slice(&[0; 2]); + std::fs::write(&moved, &bytes).unwrap(); + let again = analysed(&shell, &[&moved]); + + let preview = shell + .plan_export(&request(&again, to_folder(), Some(&out))) + .unwrap(); + let row = preview.rows[0].row.as_ref().unwrap(); + assert_eq!( + row.action, + ExportAction::Skip { + reason: ExportSkip::CopyOfOtherTrack + } + ); + assert_eq!( + row.notes, + [ + "the folder has a copy of this name that SoundCheck made from another file, or from \ + this one before it was moved and changed; rename one, or choose another folder" + ] + ); +} + +fn at_export_done(e: &JobEvent) -> bool { + matches!(e, JobEvent::ExportDone { .. }) +} + +#[test] +fn a_client_that_says_no_name_is_taken_still_overwrites_nothing() { + let lib = Lib::new(); + let shell = lib.shell(1); + let a = tone_in(&lib, "a", "track.wav"); + let b = tone_in(&lib, "b", "track.wav"); + // Another level, so the two copies could not pass for each other. + tone_wav(&b, 0.4, 44_100); + let ids = analysed(&shell, &[&a, &b]); + let out = lib.base().join("out"); + let req = request(&ids, to_folder(), Some(&out)); + let preview = shell.plan_export(&req).unwrap(); + let mut sent = confirmed(req, &preview); + sent.file_ids = ids.clone(); + if let Some(c) = sent.confirmed.as_mut() { + for f in &mut c.files { + f.same_name = false; + } + assert_eq!(c.files.len(), 2, "both rows confirmed"); + } + + // Held right after the first copy is written: its bytes then are the ones to keep. + let held = super::support::held_at(&shell, sent, at_export_done); + let copy = out.join("track.wav"); + let first = sha256(©); + let events = held.finish(); + + assert!(steps(&events, ids[0]).contains(&"exportDone"), "{events:?}"); + let second = steps(&events, ids[1]); + assert!(!second.contains(&"exportDone"), "{events:?}"); + assert!( + second.contains(&"exportSkipped") || second.contains(&"failed"), + "{events:?}" + ); + assert_eq!(sha256(©), first, "the first copy is not written over"); + let names: Vec<_> = std::fs::read_dir(&out) + .unwrap() + .map(|e| e.unwrap().file_name()) + .filter(|n| n.to_string_lossy().ends_with(".wav")) + .collect(); + assert_eq!(names, ["track.wav"], "one copy, no second name made up"); +} diff --git a/src-tauri/src/shell/tests/export.rs b/src-tauri/src/shell/tests/export.rs index 12f56c0..da786c6 100644 --- a/src-tauri/src/shell/tests/export.rs +++ b/src-tauri/src/shell/tests/export.rs @@ -318,11 +318,11 @@ fn duplicate_paths_are_refused_before_anything_is_written() { assert_eq!( skipped.action, ExportAction::Skip { - reason: ExportSkip::CopyOfOtherTrack + reason: ExportSkip::SameNameInBatch } ); assert!( - skipped.notes[0].contains("another track"), + skipped.notes[0].contains("earlier in this export"), "{:?}", skipped.notes ); @@ -337,7 +337,7 @@ fn duplicate_paths_are_refused_before_anything_is_written() { assert!(matches!( preview.rows[1].row.as_ref().unwrap().action, ExportAction::Skip { - reason: ExportSkip::CopyOfOtherTrack + reason: ExportSkip::SameNameInBatch } )); assert!(matches!( diff --git a/src-tauri/src/shell/tests/preview.rs b/src-tauri/src/shell/tests/preview.rs index 3f14fdb..c088c6a 100644 --- a/src-tauri/src/shell/tests/preview.rs +++ b/src-tauri/src/shell/tests/preview.rs @@ -254,6 +254,7 @@ fn confirmed_of(mut req: ExportRequest, preview: &ExportPreview) -> ExportReques .map(|r| ConfirmedFile { file_id: r.file_id, identity: r.identity.clone().expect("planned"), + same_name: false, }) .collect(), }); diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index fbd4001..98c162e 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -190,7 +190,7 @@ function runJob(jobId: number, fileIds: number[], channel: unknown) { } else { const a = analysisFor(s); analysed.set(fileId, a); - send(channel, { type: 'analysed', jobId, fileId, row: a, plan: planFor(s, a), revision }); + send(channel, { type: 'analysed', jobId, fileId, row: a, plan: planFor(s, a), revision, exportStale: false }); } done += 1; const secs = (Date.now() - started) / 1000; diff --git a/src/features/export/ExportCells.tsx b/src/features/export/ExportCells.tsx index 7639d12..6bdda04 100644 --- a/src/features/export/ExportCells.tsx +++ b/src/features/export/ExportCells.tsx @@ -65,8 +65,10 @@ export function ExportActionCell({ const text = exportActionText(exportRow, row.entry.info.codec); const shift = exportRow.positionsShift; const verified = exportRow.verification ? verifiedText(exportRow.verification) : null; - // Said first: the row's other numbers already are those of the settings now. - const different = now ? exportDifference(row, now)?.text : undefined; + // Said first: the row's other numbers already are those of the settings now. A skipped row was + // never exported, so its reason stays: it is what the user has to act on. + const skipped = exportRow.action.type === 'skip'; + const different = now && !skipped ? exportDifference(row, now)?.text : undefined; const second = different ?? verified ?? text.detail; const main = text.main ?? [text.gain, text.cut].filter(Boolean).join(' · '); const twoChanges = text.gain !== null && text.cut !== null; diff --git a/src/features/export/ExportConfirm.test.tsx b/src/features/export/ExportConfirm.test.tsx index 45fc06a..91969c1 100644 --- a/src/features/export/ExportConfirm.test.tsx +++ b/src/features/export/ExportConfirm.test.tsx @@ -89,8 +89,8 @@ describe('the confirm step', () => { expect(exported[0]?.confirmed).toEqual({ revision: 7, files: [ - { fileId: 1, identity: 'id-1' }, - { fileId: 2, identity: 'id-2' }, + { fileId: 1, identity: 'id-1', sameName: false }, + { fileId: 2, identity: 'id-2', sameName: false }, ], }); }); @@ -202,7 +202,7 @@ describe('the confirm step', () => { ); render(); void openExportConfirm(); - expect(await screen.findByText('1 file skipped: a copy of another track already has its name.')).toBeInTheDocument(); + expect(await screen.findByText('1 file skipped: the folder has a copy of that name made from another file.')).toBeInTheDocument(); expect(screen.getByText('1 file replaces your earlier copy.')).toBeInTheDocument(); expect(screen.queryByText(/isn't SoundCheck's/)).toBeNull(); }); diff --git a/src/features/export/ExportConfirm.tsx b/src/features/export/ExportConfirm.tsx index d823514..ef390cd 100644 --- a/src/features/export/ExportConfirm.tsx +++ b/src/features/export/ExportConfirm.tsx @@ -7,7 +7,7 @@ import { pipeline } from '@/features/pipeline/actions'; import { Toggle } from '@/features/settings/controls'; import { useLibrary } from '@/state/library'; import { confirmExport, dismissExportConfirm, setExportAgain, useExportFlow } from './flow'; -import { countPreview, shortPath, summaryLine, type ExportCounts } from './summary'; +import { countPreview, isRefusal, shortPath, summaryLine, type ExportCounts } from './summary'; const buttonClass = 'inline-flex h-[30px] items-center whitespace-nowrap rounded-[7px] border px-3 text-[13px] font-medium disabled:cursor-default disabled:opacity-45'; @@ -52,7 +52,7 @@ function Particulars({ rows: planned }: { rows: ExportPreviewRow[] }) { const name = entry ? displayTitle(entry.info, entry.path) : `Track ${fileId}`; if (error) { const text = entry ? rowErrorCopy(error, entry.path) : sentence(error.message); - return [{ fileId, name, head: 'Cannot be planned', text }]; + return [{ fileId, name, head: isRefusal(error) ? 'Refused' : 'Cannot be planned', text }]; } if (!row) return []; const text = sentence(row.notes.join(' ')); @@ -133,8 +133,15 @@ export function ExportConfirm() { {counts.otherTrack > 0 && (

{counts.otherTrack === 1 - ? '1 file skipped: a copy of another track already has its name.' - : `${counts.otherTrack} files skipped: copies of other tracks already have their names.`} + ? '1 file skipped: the folder has a copy of that name made from another file.' + : `${counts.otherTrack} files skipped: the folder has copies of those names made from other files.`} +

+ )} + {counts.sameName > 0 && ( +

+ {counts.sameName === 1 + ? '1 file skipped: a file earlier in this export takes its name.' + : `${counts.sameName} files skipped: files earlier in this export take their names.`}

)} {preview.artefactsFolder && ( diff --git a/src/features/export/ExportDetail.test.tsx b/src/features/export/ExportDetail.test.tsx index 061357e..9cbe9dd 100644 --- a/src/features/export/ExportDetail.test.tsx +++ b/src/features/export/ExportDetail.test.tsx @@ -94,10 +94,12 @@ describe('the row details', () => { expect(button).toHaveAttribute('title', expect.stringContaining('running export')); }); - it('says why the XML left it out, and that its grid changed since', () => { + it('says why the XML left it out, and that its grid or file changed since', () => { show({ export: doneRow(), xmlNotListed: 'no grid', exportStale: true }); expect(screen.getByText('Not in the rekordbox XML: no grid')).toBeInTheDocument(); - expect(screen.getByText(/grid changed since this export/)).toBeInTheDocument(); + expect( + screen.getByText('The grid or the file changed since this export; export it again to write it.'), + ).toBeInTheDocument(); }); it('Revert runs from here too', async () => { diff --git a/src/features/export/ExportDetail.tsx b/src/features/export/ExportDetail.tsx index 06d1fc7..d55f24e 100644 --- a/src/features/export/ExportDetail.tsx +++ b/src/features/export/ExportDetail.tsx @@ -86,7 +86,7 @@ export function ExportDetail() { >
- {row.exportStale && The grid changed since this export; export it again to write it.} + {row.exportStale && The grid or the file changed since this export; export it again to write it.} {row.reverting && (

Reverting: restoring the original and analysing it again… diff --git a/src/features/export/cells.test.tsx b/src/features/export/cells.test.tsx index 9784f18..229280b 100644 --- a/src/features/export/cells.test.tsx +++ b/src/features/export/cells.test.tsx @@ -121,6 +121,17 @@ describe('the Action column of an export', () => { expect(screen.getByText('The file was not changed')).toBeInTheDocument(); }); + it('a row an export in place would refuse says why, once the confirm step is closed', () => { + useLibrary.getState().add([entry(1)]); + useLibrary.getState().markRefused([ + { fileId: 1, error: { kind: 'inPlaceRefused', message: '/music/Track 1.wav is a symbolic link', fileId: 1 } }, + ]); + const refused = useLibrary.getState().rows[1] as Row; + render(); + expect(screen.getByText(/is a symbolic link/)).toBeInTheDocument(); + expect(screen.getByText('The file was not changed')).toBeInTheDocument(); + }); + it('before any export it still shows the plan', () => { render(); expect(screen.getByText('Gain −3.2 dB')).toBeInTheDocument(); @@ -262,6 +273,21 @@ describe('a row exported with other settings', () => { expect(screen.queryByText(/^Verified/)).toBeNull(); }); + it('a skipped row keeps its reason, never saying how it was exported', () => { + const xmlOff = { ...now, settings: { ...now.settings, xml: false } }; + const nothing = exportRow({ + stage: 'skipped', + action: { type: 'skip', reason: { type: 'nothingToWrite', reason: { type: 'gridOnlyFlac' } } }, + positionsShift: false, + exportedWith: xmlOff, + notes: ['only the rekordbox XML could carry its grid, and the XML is off'], + }); + render(); + expect(screen.getByText('Skipped')).toBeInTheDocument(); + expect(screen.getByText('Only the rekordbox XML could carry its grid, and the XML is off')).toBeInTheDocument(); + expect(screen.queryByText(/^Exported/)).toBeNull(); + }); + it('says nothing when it matches, and shows what was verified', () => { render( , diff --git a/src/features/export/flow.test.tsx b/src/features/export/flow.test.tsx new file mode 100644 index 0000000..dd6402d --- /dev/null +++ b/src/features/export/flow.test.tsx @@ -0,0 +1,267 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import type { ExportPreview, ExportPreviewRow, ExportRequest, ExportRow } from '@/lib/ipc'; +import { pipeline } from '@/features/pipeline/actions'; +import { useLibrary } from '@/state/library'; +import { useRecovery } from '@/state/recovery'; +import { useSettings } from '@/state/settings'; +import { analysed, doneRow, entry, exportPreview, exportRow, previewRow } from '@/test/fixtures'; +import { ExportConfirm } from './ExportConfirm'; +import { exportNow } from './work'; +import { + confirmExport, + dismissExportConfirm, + openExportConfirm, + setExportAgain, + useExportFlow, +} from './flow'; + +const initialLibrary = useLibrary.getState(); +const initialSettings = useSettings.getState(); + +/** Opens the step: not awaited, since opening ends only when its plan answers. */ +async function open() { + await act(async () => { + void openExportConfirm(); + }); +} + +/** Plans still held, answered after each test so that none waits on them. */ +let outstanding: (() => void)[] = []; + +/** A backend whose plans answer only when told, in the order they were asked. */ +function heldPlans() { + const plans: { + req: ExportRequest; + answer: (p: ExportPreview) => void; + fail: (e: unknown) => void; + }[] = []; + const exported: ExportRequest[] = []; + let running = 0; + let most = 0; + mockIPC((cmd, args) => { + const req = (args as { req: ExportRequest }).req; + if (cmd === 'plan_export') { + running += 1; + most = Math.max(most, running); + return new Promise((go, no) => { + outstanding.push(() => no(new Error('the test is over'))); + plans.push({ + req, + answer: (p) => { + running -= 1; + go(p); + }, + fail: (e) => { + running -= 1; + no(e); + }, + }); + }); + } + if (cmd === 'export') { + exported.push(req); + return 5; + } + return undefined; + }); + return { plans, exported, most: () => most }; +} + +function rowsOf(req: ExportRequest): ExportPreviewRow[] { + return req.fileIds.map((fileId) => previewRow(fileId, exportRow(), { identity: `id-${fileId}` })); +} + +function writeButton(): HTMLButtonElement { + return screen + .getAllByRole('button') + .find((b) => + /^Write|^Nothing to write|^Export$/.test(b.textContent ?? ''), + ) as HTMLButtonElement; +} + +/** Row 1 is new work; row 2 was exported at another target, so it stays unless ticked. */ +function setUpDiffering() { + useLibrary.getState().add([entry(1), entry(2)]); + useLibrary.getState().applyEvent(analysed(1)); + useLibrary.getState().applyEvent(analysed(2)); + const now = exportNow(useSettings.getState(), 'prepare'); + const was: ExportRow = doneRow({ exportedWith: { ...now, decide: { ...now.decide, target: now.decide.target - 3 } } }); + useLibrary.setState((l) => ({ rows: { ...l.rows, 2: { ...l.rows[2]!, export: was } } })); +} + +beforeEach(() => { + useLibrary.setState(initialLibrary, true); + useSettings.setState({ ...initialSettings, place: 'inPlace' }, true); + useRecovery.setState({ running: false }); + dismissExportConfirm(); + pipeline().send({ type: 'FINISHED' }); +}); + +afterEach(async () => { + dismissExportConfirm(); + await act(async () => { + for (const end of outstanding) end(); + outstanding = []; + }); +}); + +describe('one plan at a time', () => { + it('does not start a plan while an older one still holds the files, and Write waits for both', async () => { + const backend = heldPlans(); + useLibrary.getState().add([entry(1)]); + useLibrary.getState().applyEvent(analysed(1)); + render(); + + await open(); + await waitFor(() => expect(backend.plans).toHaveLength(1)); + + // Cancel and press Export again while the engine is still on the first plan. + act(() => dismissExportConfirm()); + await open(); + expect(backend.plans).toHaveLength(1); + expect(useExportFlow.getState().phase).toBe('planning'); + expect(writeButton()).toBeDisabled(); + + // The superseded plan answers: it lands nowhere, and only now the next one starts. + await act(async () => { + backend.plans[0]?.answer(exportPreview(rowsOf(backend.plans[0].req))); + }); + await waitFor(() => expect(backend.plans).toHaveLength(2)); + expect(useExportFlow.getState().phase).toBe('planning'); + expect(useExportFlow.getState().preview).toBeNull(); + expect(writeButton()).toBeDisabled(); + + await act(async () => { + backend.plans[1]?.answer(exportPreview(rowsOf(backend.plans[1].req))); + }); + await waitFor(() => expect(useExportFlow.getState().phase).toBe('ready')); + expect(writeButton()).toBeEnabled(); + expect(backend.most()).toBe(1); + }); + + it('keeps Write disabled after a tick and an untick until the older plan has answered', async () => { + const backend = heldPlans(); + setUpDiffering(); + render(); + await open(); + await waitFor(() => expect(backend.plans).toHaveLength(1)); + await act(async () => { + backend.plans[0]?.answer(exportPreview(rowsOf(backend.plans[0].req))); + }); + await waitFor(() => expect(useExportFlow.getState().phase).toBe('ready')); + + // Tick: the long plan starts. Untick while it runs: the short one has to wait for it. + await act(async () => setExportAgain(true)); + await waitFor(() => expect(backend.plans).toHaveLength(2)); + await act(async () => setExportAgain(false)); + expect(backend.plans).toHaveLength(2); + expect(useExportFlow.getState().phase).toBe('planning'); + act(() => confirmExport()); + expect(backend.exported).toHaveLength(0); + + await act(async () => { + backend.plans[1]?.answer(exportPreview(rowsOf(backend.plans[1].req))); + }); + await waitFor(() => expect(backend.plans).toHaveLength(3)); + expect(useExportFlow.getState().phase).toBe('planning'); + expect(backend.plans[2]?.req.fileIds).toEqual([1]); + + await act(async () => { + backend.plans[2]?.answer(exportPreview(rowsOf(backend.plans[2].req))); + }); + await waitFor(() => expect(useExportFlow.getState().phase).toBe('ready')); + expect(useExportFlow.getState().preview?.rows.map((r) => r.fileId)).toEqual([1]); + expect(backend.most()).toBe(1); + }); + + it('skips a plan superseded while it waited, and goes on after one that failed', async () => { + const backend = heldPlans(); + setUpDiffering(); + render(); + await open(); + await waitFor(() => expect(backend.plans).toHaveLength(1)); + + await act(async () => setExportAgain(true)); + await act(async () => setExportAgain(false)); + await act(async () => setExportAgain(true)); + expect(backend.plans).toHaveLength(1); + + await act(async () => { + backend.plans[0]?.fail({ kind: 'io', message: 'read failed', fileId: null }); + }); + // Only the last wish is asked, once. + await waitFor(() => expect(backend.plans).toHaveLength(2)); + expect(backend.plans[1]?.req.fileIds).toEqual([1, 2]); + await act(async () => { + backend.plans[1]?.answer(exportPreview(rowsOf(backend.plans[1].req))); + }); + await waitFor(() => expect(useExportFlow.getState().phase).toBe('ready')); + expect(backend.plans).toHaveLength(2); + }); +}); + +describe('what the job is told', () => { + it("carries the plan's same-name decision, row by row, and leaves out what it could not plan", async () => { + const backend = heldPlans(); + useLibrary.getState().add([entry(1), entry(2), entry(3), entry(4)]); + for (const id of [1, 2, 3, 4]) useLibrary.getState().applyEvent(analysed(id)); + render(); + await open(); + await waitFor(() => expect(backend.plans).toHaveLength(1)); + + const taken = exportRow({ + stage: 'skipped', + action: { type: 'skip', reason: { type: 'sameNameInBatch' } }, + positionsShift: false, + notes: [ + 'a file earlier in this export takes this name in the folder; rename one, or choose another folder', + ], + }); + const foreign = exportRow({ + stage: 'skipped', + action: { type: 'skip', reason: { type: 'copyOfOtherTrack' } }, + positionsShift: false, + notes: ['the folder has a copy'], + }); + await act(async () => { + backend.plans[0]?.answer( + exportPreview([ + previewRow(1, exportRow(), { identity: 'id-1' }), + previewRow(2, taken, { identity: 'id-2' }), + previewRow(3, foreign, { identity: 'id-3' }), + { + fileId: 4, + row: null, + error: { kind: 'inPlaceRefused', message: 'it is a link', fileId: 4 }, + identity: null, + replacesCopy: false, + }, + ]), + ); + }); + await waitFor(() => expect(useExportFlow.getState().phase).toBe('ready')); + expect(screen.getByText(/1 refused/)).toBeInTheDocument(); + expect(screen.getByText(/Refused: /)).toBeInTheDocument(); + expect( + screen.getByText('1 file skipped: a file earlier in this export takes its name.'), + ).toBeInTheDocument(); + expect(writeButton()).toHaveTextContent('Write 1 file'); + + // The refused row says why on the table, and keeps saying it once the step closes. + const refusedRow = () => useLibrary.getState().rows[4]; + expect(refusedRow()?.state).toBe('failed'); + expect(refusedRow()?.exportError).toEqual({ kind: 'inPlaceRefused', message: 'it is a link', fileId: 4 }); + expect(useLibrary.getState().rows[1]?.state).not.toBe('failed'); + act(() => confirmExport()); + await waitFor(() => expect(backend.exported).toHaveLength(1)); + expect(backend.exported[0]?.fileIds).toEqual([1, 2, 3]); + expect(refusedRow()?.state).toBe('failed'); + expect(refusedRow()?.exportError?.kind).toBe('inPlaceRefused'); + expect(backend.exported[0]?.confirmed?.files).toEqual([ + { fileId: 1, identity: 'id-1', sameName: false }, + { fileId: 2, identity: 'id-2', sameName: true }, + { fileId: 3, identity: 'id-3', sameName: false }, + ]); + }); +}); diff --git a/src/features/export/flow.ts b/src/features/export/flow.ts index 4a4476d..7ffc402 100644 --- a/src/features/export/flow.ts +++ b/src/features/export/flow.ts @@ -9,6 +9,7 @@ import { useRecovery } from '@/state/recovery'; import { useSettings } from '@/state/settings'; import { closeGridViewOf } from './actions'; import { batchChoice } from './mode'; +import { isRefusal } from './summary'; import { againCopy, exportNow, exportWork, type ExportWork } from './work'; /** @@ -95,6 +96,9 @@ export function hasExportWork(work: ExportWork): boolean { /** Bumped by every opening and closing, so an old answer lands nowhere. */ let attempt = 0; +/** The plan the engine is working on: it holds its files until it answers, whoever still wants it. */ +let running: Promise = Promise.resolve(); + /** Opens the confirm step and plans the export, once what is being typed has reached the engine. */ export async function openExportConfirm(): Promise { const mine = ++attempt; @@ -133,21 +137,31 @@ export async function openExportConfirm(): Promise { await planRows(mine); } -/** Asks the engine about exactly what would go out, so what the step says is what happens. */ +/** + * Asks the engine about exactly what would go out, so what the step says is what happens. One plan + * at a time: a superseded plan still holds its files, so the next waits for it, and the step stays + * in `planning` (Write disabled) until then. A plan superseded while waiting never starts. + */ async function planRows(mine: number): Promise { const { request, planned, differing, again } = useExportFlow.getState(); if (!request) return; const fileIds = again ? planned : planned.filter((id) => !differing.includes(id)); const asked = { ...request, fileIds }; useExportFlow.setState({ phase: 'planning', preview: null, request: asked, error: null }); + await running; + if (mine !== attempt) return; try { - const preview = fileIds.length > 0 ? await planExport(asked) : null; + const answer = fileIds.length > 0 ? planExport(asked) : null; + running = answer?.catch(() => undefined) ?? running; + const preview = await answer; if (mine !== attempt) return; useExportFlow.setState({ phase: 'ready', preview }); - const changed = (preview?.rows ?? []) - .filter((r) => r.error?.kind === 'fileChanged') - .map((r) => r.fileId); + const rows = preview?.rows ?? []; + const changed = rows.filter((r) => r.error?.kind === 'fileChanged').map((r) => r.fileId); if (changed.length > 0) useLibrary.getState().markChanged(changed); + // Left out of the job, a refused row says why on the table, as a failed export would. + const refused = rows.flatMap(({ fileId, error }) => (error && isRefusal(error) ? [{ fileId, error }] : [])); + if (refused.length > 0) useLibrary.getState().markRefused(refused); } catch (err) { if (mine === attempt) useExportFlow.setState({ phase: 'failed', error: errorCopy(ipcErrorOf(err)) }); } @@ -174,7 +188,11 @@ export function confirmExport(): void { fileIds, confirmed: { revision: preview.revision, - files: planned.map((r) => ({ fileId: r.fileId, identity: r.identity as string })), + files: planned.map((r) => ({ + fileId: r.fileId, + identity: r.identity as string, + sameName: r.row?.action.type === 'skip' && r.row.action.reason.type === 'sameNameInBatch', + })), }, }, planned, diff --git a/src/features/export/summary.test.ts b/src/features/export/summary.test.ts index 64f158a..a68f970 100644 --- a/src/features/export/summary.test.ts +++ b/src/features/export/summary.test.ts @@ -85,3 +85,22 @@ describe('the confirm step numbers', () => { expect(shortPath('/Volumes/USB/Music')).toBe('/Volumes/USB/Music'); }); }); + +describe('rows an export in place refuses', () => { + const refused = (kind: 'inPlaceRefused' | 'rekordboxUsbExport' | 'io', fileId: number): ExportPreviewRow => ({ + fileId, + row: null, + error: { kind, message: 'no', fileId }, + identity: null, + replacesCopy: false, + }); + + it('are counted as refused, not as unreadable', () => { + const rows = [refused('inPlaceRefused', 1), refused('rekordboxUsbExport', 2), refused('io', 3)]; + const counts = countPreview(rows); + expect(counts.refused).toBe(2); + expect(counts.unreadable).toBe(1); + expect(counts.written).toBe(0); + expect(summaryLine(rows, { backupFolder: '/b' }, null)).toBe('3 files: 1 unreadable, 2 refused; backups in /b'); + }); +}); diff --git a/src/features/export/summary.ts b/src/features/export/summary.ts index c872628..959a71d 100644 --- a/src/features/export/summary.ts +++ b/src/features/export/summary.ts @@ -1,10 +1,15 @@ -import type { Cut, ExportPreview, ExportPreviewRow, ExportRow } from '@/lib/ipc'; +import type { Cut, ExportPreview, ExportPreviewRow, ExportRow, IpcError } from '@/lib/ipc'; import { NEGLIGIBLE_DB } from './text'; function cutSeconds(cut: Cut): number | null { return cut.type === 'cut' ? cut.seconds : null; } +/** Whether the plan found an export in place would refuse the file, rather than not read it. */ +export function isRefusal(error: IpcError): boolean { + return error.kind === 'inPlaceRefused' || error.kind === 'rekordboxUsbExport'; +} + export interface ExportCounts { files: number; /** Files whose level changes; a file can also be cut. */ @@ -17,6 +22,8 @@ export interface ExportCounts { skipped: number; /** Files that could not be planned; they are left out of the job. */ unreadable: number; + /** Files an export in place refuses; they are left out of the job too. */ + refused: number; /** The longest cut, seconds. */ longestCutS: number; /** Files that would be written. */ @@ -25,8 +32,10 @@ export interface ExportCounts { replacing: number; /** Files skipped because the folder holds another file of that name. */ foreign: number; - /** Files skipped because a copy of another track has that name. */ + /** Files skipped because a copy SoundCheck made from another file has that name. */ otherTrack: number; + /** Files skipped because a file earlier in the batch takes that name. */ + sameName: number; /** Files only the XML carries that are new work: those going along again are not. */ xmlOnlyNew: number; } @@ -42,16 +51,19 @@ export function countPreview(rows: ExportPreviewRow[], along: number[] = []): Ex xmlOnly: 0, skipped: 0, unreadable: 0, + refused: 0, longestCutS: 0, written: 0, replacing: 0, foreign: 0, otherTrack: 0, + sameName: 0, xmlOnlyNew: 0, }; - for (const { row, fileId, replacesCopy } of rows) { + for (const { row, error, fileId, replacesCopy } of rows) { if (!row) { - c.unreadable += 1; + if (error && isRefusal(error)) c.refused += 1; + else c.unreadable += 1; continue; } switch (row.action.type) { @@ -80,6 +92,7 @@ export function countPreview(rows: ExportPreviewRow[], along: number[] = []): Ex c.skipped += 1; if (row.action.reason.type === 'otherFileInFolder') c.foreign += 1; if (row.action.reason.type === 'copyOfOtherTrack') c.otherTrack += 1; + if (row.action.reason.type === 'sameNameInBatch') c.sameName += 1; break; } } @@ -106,6 +119,7 @@ export function summaryLine( c.xmlOnly > 0 && `${c.xmlOnly} XML only`, c.skipped > 0 && `${c.skipped} skipped`, c.unreadable > 0 && `${c.unreadable} unreadable`, + c.refused > 0 && `${c.refused} refused`, ].filter(Boolean); const files = `${c.files} ${c.files === 1 ? 'file' : 'files'}`; const where = preview.backupFolder diff --git a/src/features/export/work.test.ts b/src/features/export/work.test.ts index bd37f69..8e93a86 100644 --- a/src/features/export/work.test.ts +++ b/src/features/export/work.test.ts @@ -186,3 +186,46 @@ describe('what the confirm step says about rows that stay', () => { }); }); }); + +describe('a row skipped because only the XML could carry its grid', () => { + const nothing = (w: ExportedWith) => + row( + exportRow({ + stage: 'skipped', + action: { type: 'skip', reason: { type: 'nothingToWrite', reason: { type: 'gridOnlyFlac' } } }, + exportedWith: w, + }), + ); + const xmlOff = { ...same, settings: { ...same.settings, xml: false } }; + + it('comes back when the XML is turned on', () => { + expect(exportDifference(nothing(xmlOff), now)?.text).toBe('Exported without the rekordbox XML'); + const found = work([nothing(xmlOff)]); + expect(found.retry).toEqual([1]); + expect(found.along).toEqual([]); + }); + + it('goes along while the XML stays off', () => { + const off = { ...now, settings: { ...now.settings, xml: false } }; + expect(exportDifference(nothing(xmlOff), off)).toBeNull(); + expect(exportWork([nothing(xmlOff)], [1], off).along).toEqual([1]); + }); + + it('says the difference that matters most when there is another', () => { + const elsewhere = { ...xmlOff, outDir: '/Users/dj/Old' }; + expect(exportDifference(nothing(elsewhere), now)?.text).toBe('Exported to ~/Old'); + }); + + it('the XML does not bring back a row skipped for anything else', () => { + expect(exportDifference(skip(xmlOff), now)).toBeNull(); + expect(exportDifference(done(xmlOff), now)).toBeNull(); + }); +}); + +describe('a name taken in the batch', () => { + it('is planned again, since what else goes along can change with no word to the row', () => { + expect(skipCure({ type: 'sameNameInBatch' })).toBe('disk'); + const taken = row(exportRow({ stage: 'skipped', action: { type: 'skip', reason: { type: 'sameNameInBatch' } }, exportedWith: same })); + expect(work([taken]).retry).toEqual([1]); + }); +}); diff --git a/src/features/export/work.ts b/src/features/export/work.ts index 96a1330..3f773c6 100644 --- a/src/features/export/work.ts +++ b/src/features/export/work.ts @@ -52,12 +52,18 @@ export interface ExportDifference { /** * How a row's export differs from one made now; `null` when it does not. A file only the XML - * carries has no gain or depth to differ, only a place to be listed in. + * carries has no gain or depth to differ, only a place to be listed in. A file skipped because only + * the XML could carry it differs when the XML does. */ export function exportDifference(row: Row, now: ExportNow): ExportDifference | null { const was = row.export?.exportedWith; if (!was || row.export?.stage === 'processing' || row.export?.stage === 'writing') return null; - return differenceOf(was, now, row.export?.action.type === 'xmlOnly'); + const action = row.export?.action; + if (action?.type === 'skip' && action.reason.type === 'nothingToWrite' && was.settings.xml !== now.settings.xml) { + const text = was.settings.xml ? 'Exported with the rekordbox XML' : 'Exported without the rekordbox XML'; + return differenceOf(was, now, false) ?? { kind: 'other', text }; + } + return differenceOf(was, now, action?.type === 'xmlOnly'); } function differenceOf(was: ExportedWith, now: ExportNow, xmlOnly: boolean): ExportDifference | null { @@ -108,7 +114,8 @@ export function againCopy(rows: Row[], now: ExportNow): { stays: string; offer: /** * What could make a skipped file writable, as `ExportSkip::cure` says in the engine. `file`: nothing * here, it is about the file itself. `grid`: a saved or confirmed grid. `settings`: other export - * settings. `disk`: what is in the folder, which can change with no word to the app. + * settings. `disk`: what is in the folder or goes along in the batch, which can change with no word + * to the row. */ export type SkipCure = 'file' | 'grid' | 'settings' | 'disk'; @@ -131,6 +138,7 @@ export function skipCure(reason: ExportSkip): SkipCure { return 'settings'; case 'otherFileInFolder': case 'copyOfOtherTrack': + case 'sameNameInBatch': return 'disk'; default: { const unhandled: never = reason; diff --git a/src/lib/ipc/generated/ConfirmedFile.ts b/src/lib/ipc/generated/ConfirmedFile.ts index cba2a97..59c28fb 100644 --- a/src/lib/ipc/generated/ConfirmedFile.ts +++ b/src/lib/ipc/generated/ConfirmedFile.ts @@ -11,4 +11,9 @@ fileId: number, /** * Its identity then (length, modification time, inode): opaque, compared whole. */ -identity: string, }; +identity: string, +/** + * The plan skipped it because a file earlier in the batch takes its name: it stays skipped, + * whatever else drops out of the job. + */ +sameName: boolean, }; diff --git a/src/lib/ipc/generated/ExportPreviewRow.ts b/src/lib/ipc/generated/ExportPreviewRow.ts index 617014a..d3c0390 100644 --- a/src/lib/ipc/generated/ExportPreviewRow.ts +++ b/src/lib/ipc/generated/ExportPreviewRow.ts @@ -16,8 +16,8 @@ fileId: number, */ row: ExportRow | null, /** - * Why it could not be planned: not analysed yet, changed since it was analysed, or its start - * could not be read. + * Why it could not be planned: not analysed yet, changed since it was analysed, its start + * could not be read, or an export in place would refuse it. */ error: IpcError | null, /** diff --git a/src/lib/ipc/generated/ExportSkip.ts b/src/lib/ipc/generated/ExportSkip.ts index 2c1456a..824edcc 100644 --- a/src/lib/ipc/generated/ExportSkip.ts +++ b/src/lib/ipc/generated/ExportSkip.ts @@ -26,7 +26,7 @@ channels: number, } | { "type": "unsupported", /** * The codec. */ -codec: Codec, } | { "type": "silent" } | { "type": "noGrid" } | { "type": "gridNeedsReview" } | { "type": "otherFileInFolder" } | { "type": "copyOfOtherTrack" } | { "type": "xmlNoGrid", +codec: Codec, } | { "type": "silent" } | { "type": "noGrid" } | { "type": "gridNeedsReview" } | { "type": "otherFileInFolder" } | { "type": "copyOfOtherTrack" } | { "type": "sameNameInBatch" } | { "type": "xmlNoGrid", /** * The codec. */ diff --git a/src/lib/ipc/generated/JobEvent.ts b/src/lib/ipc/generated/JobEvent.ts index f240b20..c07cd46 100644 --- a/src/lib/ipc/generated/JobEvent.ts +++ b/src/lib/ipc/generated/JobEvent.ts @@ -55,7 +55,11 @@ plan: Plan, /** * The settings revision `plan` was decided with; a newer replan wins. */ -revision: number, } | { "type": "failed", +revision: number, +/** + * The file's export no longer stands for it, as the session now says. + */ +exportStale: boolean, } | { "type": "failed", /** * The job. */ diff --git a/src/state/exportRows.test.ts b/src/state/exportRows.test.ts index 4aa50b8..72f7604 100644 --- a/src/state/exportRows.test.ts +++ b/src/state/exportRows.test.ts @@ -1,4 +1,4 @@ -import type { ExportRow, IpcError, RevertResult, RowUpdate, SessionRow, SessionSnapshot } from '@/lib/ipc'; +import type { ExportRow, IpcError, JobEvent, RevertResult, RowUpdate, SessionRow, SessionSnapshot } from '@/lib/ipc'; import { analysed, analysis, artefacts, doneRow, entry, exportRow, plan, previewRow, verification } from '@/test/fixtures'; import { counts, isLocked, staleIds, useLibrary, visibleIds, xmlRowIds } from './library'; @@ -245,15 +245,37 @@ describe('a file that changed', () => { expect(staleIds(lib(), [70, 180])).toEqual([]); }); - it('with an export standing, its new analysis makes that export stale', () => { + it('takes whether the export stands from the engine, with the new analysis', () => { exporting(1); lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: false, update: null }); lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); lib().markChanged([1]); expect(staleIds(lib(), [70, 180])).toEqual([1]); lib().queue([1], [70, 180]); - lib().applyEvent(analysed(1)); + lib().applyEvent({ ...analysed(1), exportStale: true } as JobEvent); + expect(row(1).exportStale).toBe(true); + expect(row(1).changedOnDisk).toBe(false); + }); + + it('a file changed with no word to the UI, then read again for another reason, shows stale at once', () => { + exporting(1); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: false, update: null }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + // Say a BPM range change: nothing marked the row changed, and only the engine saw the file differ. + expect(row(1).changedOnDisk).toBeFalsy(); + lib().queue([1], [60, 120]); + lib().applyEvent({ ...analysed(1), exportStale: true } as JobEvent); expect(row(1).exportStale).toBe(true); + }); + + it('a row marked changed whose file the engine read as it was is not stale', () => { + exporting(1); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: false, update: null }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + lib().markChanged([1]); + lib().queue([1], [70, 180]); + lib().applyEvent(analysed(1)); + expect(row(1).exportStale).toBe(false); expect(row(1).changedOnDisk).toBe(false); }); }); diff --git a/src/state/library.ts b/src/state/library.ts index f22efe0..7c237b2 100644 --- a/src/state/library.ts +++ b/src/state/library.ts @@ -169,9 +169,9 @@ export const useLibrary = create()((set, get) => ({ row: update.row, plan: update.plan, revision: update.revision, + // The engine says whether the saved grid differs from what the export wrote. + exportStale: update.exportStale, }); - // The engine says whether the saved grid differs from what the export wrote. - set({ rows: one(get().rows, update.fileId, () => ({ exportStale: update.exportStale })) }); }, applyEvent(event) { const { rows, job } = get(); @@ -203,8 +203,8 @@ export const useLibrary = create()((set, get) => ({ return { state: restingState(plan, row.export), progress: 1, - // Read again: what its export wrote was made of what the file was before. - ...(row.changedOnDisk && row.export ? { exportStale: true } : {}), + // The engine says whether what its export wrote still stands for the file. + exportStale: event.exportStale, changedOnDisk: false, analysis: event.row, plan, @@ -336,6 +336,14 @@ export const useLibrary = create()((set, get) => ({ markChanged(fileIds) { set({ rows: patchRows(get().rows, fileIds.map((id) => [id, () => ({ changedOnDisk: true })])) }); }, + markRefused(refusals) { + set({ + rows: patchRows( + get().rows, + refusals.map(({ fileId, error }) => [fileId, () => ({ state: 'failed', progress: 0, exportError: error })]), + ), + }); + }, setNotice(notice) { set({ notice }); }, diff --git a/src/state/libraryTypes.ts b/src/state/libraryTypes.ts index 9ed4e88..3b02791 100644 --- a/src/state/libraryTypes.ts +++ b/src/state/libraryTypes.ts @@ -50,7 +50,7 @@ export interface Row { exportError?: IpcError; /** A change of the file made in place is in effect, which Revert can put back. */ revertible?: boolean; - /** Its grid was saved since its export: exporting it again would write something new. */ + /** Its grid was saved, or the file changed, since its export: exporting it again would write something new. */ exportStale?: boolean; /** Revert is running on this row (it takes about 10 s for a 6-minute track). */ reverting?: boolean; @@ -165,6 +165,8 @@ export interface LibraryState { restore(snapshot: SessionSnapshot): void; /** These files changed after they were analysed: Analyse brings them up to date. */ markChanged(fileIds: number[]): void; + /** Rows an export would be refused for, each with why: they fail as the job would fail them. */ + markRefused(refusals: { fileId: number; error: IpcError }[]): void; setNotice(notice: string | null): void; setInfo(info: string | null): void; /** Empties the list (the engine forgets it too, through `clearList`). */ diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 30bac09..356d288 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -81,7 +81,7 @@ export function plan(overrides: Partial = {}): Plan { } export function analysed(fileId: number, p: Plan = plan(), a: RowAnalysis = analysis()): JobEvent { - return { type: 'analysed', jobId: 1, fileId, row: a, plan: p, revision: 0 }; + return { type: 'analysed', jobId: 1, fileId, row: a, plan: p, revision: 0, exportStale: false }; } export function verification(overrides: Partial = {}): ExportVerification {