diff --git a/README.md b/README.md index 9a134a5..119e01b 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ SoundCheck is a free, open-source desktop app for DJs and producers. Drop a fold > **Status: in development, no usable release yet.** What works today, from source: > - **The app** analyses a dropped library (loudness, BPM, meter and bar 1, with a Needs-review queue) and opens any track in a grid view to inspect and fix its grid while a click plays along, with live IN/OUT meters, an original/processed A/B and a volume control. It does not write files yet. > - **`sc-cli apply`** already changes WAV, AIFF and FLAC files: gain and an optional head trim (snapped back up to 1 ms to the quietest frame and faded in over 2 ms), every other chunk, block and tag carried byte for byte, verified, with a backup in `~/Music/SoundCheck Backups` and `sc-cli undo`. -> - **`sc-cli process --batch-mode prepare|library`** exports: the planned gain, the Prepare cut and the tags written in place after a backup (or as copies with `--out`), verified against the planned length, with a sidecar recording the exported grid; grid edits and confirmations made in the app carry over to the exported file. +> - **`sc-cli process --batch-mode prepare|library --out `** exports: the planned gain, the Prepare cut and the tags written as copies (or, with `--in-place`, in place after a backup; one of the two is required), verified against the planned length, with a sidecar recording the exported grid; grid edits and confirmations made in the app carry over to the exported file. > - **`sc-cli process`** also writes the batch's **rekordbox XML** (`soundcheck-rekordbox.xml`: each gridded track's location, tags and a 4/4 `TEMPO` with bar 1 where the export put it; importing a track overwrites its rekordbox grid and info, so try one first) and a **`grid-report.csv`**, into the `--out` folder or `~/Music/SoundCheck/exports//`; **`sc-cli xml `** writes the XML for files already exported or only analysed. > - **`sc-cli plan --batch-mode prepare|library`** previews what an export will do to each file: the gain, the head cut (`Cut 0.21 s`, `Starts on bar 1`, `Starts on a bar line (bar 1 at 8.00 s)`, `Not cut: bar 1 1.00 s in`, or `Not cut: grid needs review` until the grid is confirmed), the tags, or why a file is left to the rekordbox XML (MP3/AAC for now) or skipped. > -> Exporting from the app and MP3 output come next; `sc-cli process` already checks every exported file's grid against the written file's own analysis. The first pre-release, `v0.1.0-alpha.1`, follows once exported files pass rekordbox 7's own analysis; `v0.1.0` is the first release for everyone. Watch the releases page or the changelog. +> - **Exporting from the app**: the rail's Export section, a summary of what will happen to each file before anything is written, row states with what was verified and each file's grid check, Revert, and the batch's rekordbox XML and `grid-report.csv` in the footer. +> +> MP3 output comes next; every exported file's grid is already checked against the written file's own analysis. The first pre-release, `v0.1.0-alpha.1`, follows once exported files pass rekordbox 7's own analysis; `v0.1.0` is the first release for everyone. Watch the releases page or the changelog. ## What v0.1 will do @@ -40,7 +42,7 @@ pnpm dev:mock # the UI alone in a browser, with synthetic tracks cargo run --release -p sc-cli -- analyze # loudness, BPM, meter and bar 1 cargo run --release -p sc-cli -- plan # what processing would do to each file cargo run --release -p sc-cli -- plan --batch-mode prepare # ... and what exporting would write -cargo run --release -p sc-cli -- process --batch-mode prepare # export (backup first; --out for copies) +cargo run --release -p sc-cli -- process --batch-mode prepare --out # export copies (--in-place replaces the files, backup first) cargo run --release -p sc-cli -- apply --gain-db -3 # change files (backup first; --out for copies) cargo run --release -p sc-cli -- undo # put the previous version back cargo run --release -p sc-cli -- journal # recorded changes; `recover` finishes interrupted ones diff --git a/crates/sc-cli/README.md b/crates/sc-cli/README.md index b36726a..c943127 100644 --- a/crates/sc-cli/README.md +++ b/crates/sc-cli/README.md @@ -7,7 +7,8 @@ The headless SoundCheck binary. `sc-cli analyze --json` prints exactly th File changes go through the write transaction (`sc_engine::apply_file`, `sc_io::txn`): - `apply --gain-db [--trim-samples N] [--bits 16|24] [--tag NAME=VALUE]... [--out ] [--backup-root ] [--no-keep-mtime] [--no-sidecar] [--json]` changes WAV, AIFF and FLAC files in place after backing them up (or writes copies into `--out`), verified. `--trim-samples N` asks for a cut of N frames: it is made up to 1 ms earlier (44 frames at 44.1 kHz, 48 at 48 kHz), at the frame whose loudest channel is quietest (the latest on a tie, never later than N), and the first 2 ms after it fade in (raised cosine from silence); the line says `500 frames trimmed (520 requested)`, and cue points, markers, loops, `bext` TimeReference and the FLAC CUESHEET move by the cut made. Tags use neutral names: `BPM` (ID3 `TBPM` + `TXXX:BPM`, Vorbis `BPM`), `INITIALKEY` (`TKEY` / `INITIALKEY`), any other `NAME` (`TXXX:NAME` / `NAME`); ID3v2.3/2.4 frame ids (and the iTunes ones, e.g. `TCMP`) and labels with `:` stop the run before any file; other four-letter names such as `MOOD` are ordinary names. A file listed twice, and with `--out` a second file with the same output name, is refused before anything is written. -- `process --batch-mode prepare|library [--grid-only] [--out ] [--depth source|16|24] [--lead-ms N] [--no-tbpm] [--no-xml] [--mode dj|streaming] [--target LUFS] [--ceiling dBTP] [--backup-root ] [--jobs N] [--json]` exports files exactly as the app's Export button does (the engine's process job): crash recovery runs first, then each file is analysed (the cache is used, the grid edits saved in the app are applied), planned as `plan --batch-mode` plans it, written in place after a backup (or as a copy into `--out`) with its gain, Prepare cut and tags, verified, and analysed again, which refreshes its cache entry, and its grid checked against the one exported: a second line ` grid check: ` (`pass` when the written file's analysis gives a tempo within 0.005 BPM of the source's own analysis and a bar line within 5 ms of where the source's analysis put one relative to the exported bar 1, modulo the bar: for a grid the user did not edit, the exported bar 1 and tempo themselves; for an edited one both analyses are solved with the user's meter, tempo octave and fitted part only, so a nudged bar 1, a chosen beat 1 or a typed tempo neither agrees nor disagrees by itself. On a grid the user confirmed, any disagreement reads `the analysis reads this differently; your confirmed grid was kept: `, the detail worded as below. On a grid not confirmed, bar lines 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, read `check bar 1: the analysis reads it 1 beat earlier (bar line -416.7 ms, BPM +0.0000)`, and anything else `off by`, `BPM differs`, `meter differs` or `no grid found`. Otherwise `not checked: `), `written.gridCheck` with `--json` (`{"result": "pass", "offsetMs", "bpmDiff"}`; `{"result": "advisory", "disagreement": {"kind": "offBy", "offsetMs", "bpmDiff"}}`, `kind` also `bpmDiffers` (`bpmDiff`, `found`), `meterDiffers` (`found`, `exported`) or `noGridFound`; `{"result": "checkBar1", "offsetMs", "bpmDiff", "beats"}`; `offBy`, `bpmDiffers`, `meterDiffers` and `noGridFound` with the same fields as those kinds; `{"result": "notChecked", "reason": {"type": "noGrid"|"gridWithheld"|"oddMeter"|"xmlOnly"|"notAnalysed"}}`; a reader should take a `result` it does not know as no result), and the sidecar's `export.gridCheck` (next to `export.grid.detectorOffsetMs`, `detectorBpm` and `meter`, what the source's analysis gave and what was exported). Every outcome is a result, never a failure of the run (exit code 0): it is the same detector reading its own output, not stable under gain, so a disagreement does not mean the file is wrong, though on a confirmed grid a real misplacement would show only as an advisory with its numbers, and an agreement is not proof that rekordbox agrees; a grid edit saved for it is carried over to the written file, bar 1 moved back by the cut. The output must have the planned frame count (Library: the source's; Prepare: the source's minus the cut) or nothing is replaced. Library keeps the modification time, Prepare does not. The tempo tag is written in Prepare only, unless `--no-tbpm`. One line per file in the order given, worded as `plan` words it: `: prepare: Gain -2.0 dB, Cut 0.21 s; tags BPM, ...; written and verified; backup ` (or `; written to `), `: library: XML only: `, or `: prepare: skipped` with ` why:` and ` what to do:` lines; then `N files: W written, X XML only, S skipped, F failed`. Unless `--no-xml`, the batch's rekordbox XML (`soundcheck-rekordbox.xml`) and grid report (`grid-report.csv`) are written last, into `--out`, or for an export in place into a new folder `~/Music/SoundCheck/exports//` (`SC_EXPORTS_ROOT` names another root), replacing only files of those names that SoundCheck wrote (with `--out`, a file of either name that SoundCheck did not write refuses the whole run in three lines, exit code 2, before anything is exported), and two lines name them (on stderr with `--json`). The XML lists each written file at its path now, and each file left to it (MP3/AAC), when it has a grid the XML may carry, spelled as its folder lists the name; each `TRACK` has its `TrackID` (1, 2, ... in the order listed, the key its playlist entries use, `KeyType` 0) and otherwise only `Name` and `Artist` (from the file's title and artist tags; the file name without a title tag, no `Artist` without an artist tag), `TotalTime`, `AverageBpm`, `Location` and one 4/4 `TEMPO`: `Inizio` the first beat at or after the start (after a Prepare cut, bar 1 at the lead), `Bpm` the written two-decimal BPM, `Battito` that beat's number in its bar. Importing a track from the XML overwrites its information, beat grid and BPM in rekordbox's collection: try one track first. A grid that needs review and was not confirmed, no grid, or a meter other than 4/4 keeps the file out of the XML. In Library mode only rows whose grid was confirmed in the app are listed (importing the XML would replace the DJ app's own grid); an MP3/AAC file in Library mode without a confirmed grid, or anywhere without a grid or with one that needs review, is skipped, since the XML would carry nothing for it; the others carry a note that their bar 1 is not yet verified against the encoder delay. The playlist is named after the batch (`SoundCheck `), since rekordbox replaces a playlist of the same name on import. The report has one row per file: `file,mode,action,gain_db,cut_s,bpm,bar1_s,grid,grid_check,notes` (UTF-8 with a byte-order mark so spreadsheet apps read Turkish names, CRLF, RFC 4180 quoting, text cells that would start a formula prefixed with `'`; `grid_check` the self-check of the exported grid, as worded on the `grid check:` line, with its numbers in every outcome). It is comma-separated with decimal points whatever the locale: Excel set to a locale that uses `;` and decimal commas (Turkish, German, ...) splits it wrongly on a double-click, so import it through Data > From Text/CSV (comma, point) or open it in Numbers. The sidecar (schema 2) records the export: settings, the plans, the grid as exported in the output's samples and the source's measurements. +- `process --batch-mode prepare|library (--out | --in-place) [--grid-only] [--depth source|16|24] [--lead-ms N] [--no-tbpm] [--no-xml] [--mode dj|streaming] [--target LUFS] [--ceiling dBTP] [--backup-root ] [--jobs N] [--json]` exports files exactly as the app's Export button does (the engine's process job): crash recovery runs first, then each file is analysed (the cache is used, the grid edits saved in the app are applied), planned as `plan --batch-mode` plans it, written in place after a backup (or as a copy into `--out`) with its gain, Prepare cut and tags, verified, and analysed again, which refreshes its cache entry, and its grid checked against the one exported: a second line ` grid check: ` (`pass` when the written file's analysis gives a tempo within 0.005 BPM of the source's own analysis and a bar line within 5 ms of where the source's analysis put one relative to the exported bar 1, modulo the bar: for a grid the user did not edit, the exported bar 1 and tempo themselves; for an edited one both analyses are solved with the user's meter, tempo octave and fitted part only, so a nudged bar 1, a chosen beat 1 or a typed tempo neither agrees nor disagrees by itself. On a grid the user confirmed, any disagreement reads `the analysis reads this differently; your confirmed grid was kept: `, the detail worded as below. On a grid not confirmed, bar lines 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, read `check bar 1: the analysis reads it 1 beat earlier (bar line -416.7 ms, BPM +0.0000)`, and anything else `off by`, `BPM differs`, `meter differs` or `no grid found`. Otherwise `not checked: `), `written.gridCheck` with `--json` (`{"result": "pass", "offsetMs", "bpmDiff"}`; `{"result": "advisory", "disagreement": {"kind": "offBy", "offsetMs", "bpmDiff"}}`, `kind` also `bpmDiffers` (`bpmDiff`, `found`), `meterDiffers` (`found`, `exported`) or `noGridFound`; `{"result": "checkBar1", "offsetMs", "bpmDiff", "beats"}`; `offBy`, `bpmDiffers`, `meterDiffers` and `noGridFound` with the same fields as those kinds; `{"result": "notChecked", "reason": {"type": "noGrid"|"gridWithheld"|"oddMeter"|"xmlOnly"|"notAnalysed"}}`; a reader should take a `result` it does not know as no result), and the sidecar's `export.gridCheck` (next to `export.grid.detectorOffsetMs`, `detectorBpm` and `meter`, what the source's analysis gave and what was exported). Every outcome is a result, never a failure of the run (exit code 0): it is the same detector reading its own output, not stable under gain, so a disagreement does not mean the file is wrong, though on a confirmed grid a real misplacement would show only as an advisory with its numbers, and an agreement is not proof that rekordbox agrees; a grid edit saved for it is carried over to the written file, bar 1 moved back by the cut. The output must have the planned frame count (Library: the source's; Prepare: the source's minus the cut) or nothing is replaced. Library keeps the modification time, Prepare does not. The tempo tag is written in Prepare only, unless `--no-tbpm`. One line per file in the order given, worded as `plan` words it: `: prepare: Gain -2.0 dB, Cut 0.21 s; tags BPM, ...; written and verified; backup ` (or `; written to `), `: library: XML only: `, or `: prepare: skipped` with ` why:` and ` what to do:` lines; then `N files: W written, X XML only, S skipped, F failed`. Unless `--no-xml`, the batch's rekordbox XML (`soundcheck-rekordbox.xml`) and grid report (`grid-report.csv`) are written last, into `--out`, or for an export in place into a new folder `~/Music/SoundCheck/exports//` (`SC_EXPORTS_ROOT` names another root), replacing only files of those names that SoundCheck wrote (with `--out`, a file of either name that SoundCheck did not write refuses the whole run in three lines, exit code 2, before anything is exported), and two lines name them (on stderr with `--json`). The XML lists each written file at its path now, and each file left to it (MP3/AAC), when it has a grid the XML may carry, spelled as its folder lists the name; each `TRACK` has its `TrackID` (1, 2, ... in the order listed, the key its playlist entries use, `KeyType` 0) and otherwise only `Name` and `Artist` (from the file's title and artist tags; the file name without a title tag, no `Artist` without an artist tag), `TotalTime`, `AverageBpm`, `Location` and one 4/4 `TEMPO`: `Inizio` the first beat at or after the start (after a Prepare cut, bar 1 at the lead), `Bpm` the written two-decimal BPM, `Battito` that beat's number in its bar. Importing a track from the XML overwrites its information, beat grid and BPM in rekordbox's collection: try one track first. A grid that needs review and was not confirmed, no grid, or a meter other than 4/4 keeps the file out of the XML. In Library mode only rows whose grid was confirmed in the app are listed (importing the XML would replace the DJ app's own grid); an MP3/AAC file in Library mode without a confirmed grid, or anywhere without a grid or with one that needs review, is skipped, since the XML would carry nothing for it; the others carry a note that their bar 1 is not yet verified against the encoder delay. The playlist is named after the batch (`SoundCheck `), since rekordbox replaces a playlist of the same name on import. The report has one row per file: `file,mode,action,gain_db,cut_s,bpm,bar1_s,grid,grid_check,notes` (UTF-8 with a byte-order mark so spreadsheet apps read Turkish names, CRLF, RFC 4180 quoting, text cells that would start a formula prefixed with `'`; `grid_check` the self-check of the exported grid, as worded on the `grid check:` line, with its numbers in every outcome). It is comma-separated with decimal points whatever the locale: Excel set to a locale that uses `;` and decimal commas (Turkish, German, ...) splits it wrongly on a double-click, so import it through Data > From Text/CSV (comma, point) or open it in Numbers. The sidecar (schema 2) records the export: settings, the plans, the grid as exported in the output's samples and the source's measurements. + It must be told where files go: `--out ` writes copies, `--in-place` replaces the files after a backup, and with neither it refuses in three lines and exits 2, because changing the originals is never a default. Exporting again with `--out` replaces a copy of the same file that SoundCheck wrote there, when its sidecar still proves it; any other file of that name, a copy of another track included, is left alone and the file reported as skipped. The folder's XML and report keep what they say about its other files, and one that starts like SoundCheck's but cannot be read whole refuses the batch. - `xml [--out FILE] [--jobs N] [--no-grid] [--bpm-range LOW-HIGH] [--model small|full]` writes a rekordbox XML of the files named, in the order given, Library ones included (naming them is the choice). Every file is analysed (cache used, grid edits saved in the app applied); a grid edited or confirmed in the app is used first, else an exported file's grid read back from its sidecar (schema 2) while the file is still the bytes that export wrote, else the analysis, withheld when it needs review and was not confirmed. Only files with a grid the XML may carry are listed, as `process` lists them. The XML goes to stdout or to `--out`, which must end in `.xml` and never replaces a file SoundCheck did not write (a glob such as `--out *.wav` putting an audio file there, another app's XML): that is refused in three lines (`: not written`, ` why:`, ` what to do:`) with exit code 2 before anything is analysed. One line per file says what the XML carries or why it leaves the file out (`: exported earlier; tempo: beat 1 at 0.005 s; 128.00 BPM`, `: analysed; not in the XML: grid needs review`), then a summary with the playlist name, on stdout with `--out` and on stderr otherwise. Exit code 2 when a file failed (the XML lists the others). - `undo ` puts back the version before each file's newest change. - `journal [--incomplete] [--forget ] [--json]` lists the changes recorded in the backup folder, newest first, or gives up on a pending one (ids look like `6ac5b4f4-75821-0`). diff --git a/crates/sc-cli/src/apply.rs b/crates/sc-cli/src/apply.rs index 9f4c46a..9be02a9 100644 --- a/crates/sc-cli/src/apply.rs +++ b/crates/sc-cli/src/apply.rs @@ -459,6 +459,7 @@ pub fn run_apply(args: ApplyArgs) -> anyhow::Result { keep_mtime: !args.no_keep_mtime, sidecar: !args.no_sidecar, cache: Some(cache), + replace_own_copy: false, }; let cancel = CancelToken::new(); let mut failed = 0; diff --git a/crates/sc-cli/src/export_plan.rs b/crates/sc-cli/src/export_plan.rs index 9dd2986..f81a658 100644 --- a/crates/sc-cli/src/export_plan.rs +++ b/crates/sc-cli/src/export_plan.rs @@ -7,7 +7,7 @@ use std::io::Write; use clap::ValueEnum; use sc_core::export::{ BatchMode, Cut, DEFAULT_LEAD_MS, ExportOutcome, ExportPlan, ExportSettings, ExportSkip, - LEAD_MS_RANGE, + LEAD_MS_RANGE, Place, }; pub(crate) use sc_engine::export::wording::{GRID_WITHHELD, notice_text, skip_why, xml_only_text}; @@ -58,7 +58,9 @@ impl ExportArgs { let Some(mode) = self.batch_mode else { return Ok(None); }; + // What an export in place would do: the strictest case for Serato data and tags. let settings = ExportSettings { + place: Place::InPlace, grid_only: self.grid_only, lead_ms: self.lead_ms, xml: !self.no_xml, @@ -178,6 +180,8 @@ fn what_to_do(reason: ExportSkip) -> &'static str { ExportSkip::NotDjSafeRate { .. } => "convert it to 44.1 or 48 kHz in an audio editor first", 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::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-cli/src/process.rs b/crates/sc-cli/src/process.rs index fc33ecc..d25fefd 100644 --- a/crates/sc-cli/src/process.rs +++ b/crates/sc-cli/src/process.rs @@ -1,7 +1,9 @@ //! `sc-cli process`: exporting files with the engine's process job (`sc_engine::Task::Process`), -//! the one the app's Export button runs. Crash recovery runs first; every file is analysed (the -//! cache is used), planned as `sc-cli plan --batch-mode` plans it, written in place after a -//! backup (or as a copy with `--out`), verified, and analysed again, its grid checked against +//! the one the app's Export button runs. It must be told where files go: `--out ` for copies, +//! or `--in-place` to replace them after a backup; with neither it refuses, because changing the +//! originals is never a default. Crash recovery runs first; every file is analysed (the +//! cache is used), planned as `sc-cli plan --batch-mode` plans it, written, verified, and analysed +//! again, its grid checked against //! the one exported (` grid check:` under its line). Each file prints one line //! (or one JSON document) in the order given: what was written, or why not; a refusal prints //! three lines on stderr. A summary line ends the text output. Unless `--no-xml`, the batch's @@ -18,7 +20,8 @@ use sc_core::export::{ BatchMode, DEFAULT_LEAD_MS, ExportOutcome, ExportPlan, ExportSettings, GridCheck, Place as ExportPlace, XmlOnlyReason, XmlTrackInfo, }; -use sc_core::plan::Codec; +use sc_core::ipc::RecoveryStatus; +use sc_core::plan::{Codec, DecideSettings}; use sc_core::{Error, Seconds}; use sc_engine::export::wording::UNVERIFIED; use sc_engine::{ @@ -65,6 +68,34 @@ pub struct ArtefactArgs { no_xml: bool, } +/// Where `process` writes: one of the two is required. +#[derive(clap::Args)] +pub struct DestinationArgs { + /// Write copies into this folder (created if needed) and leave the files as they are. A copy + /// an earlier export wrote there is replaced; any other file of that name is left alone. + #[arg(long, value_name = "DIR", conflicts_with = "in_place")] + out: Option, + /// Replace the files themselves, after a backup. Either this or --out is required. + #[arg(long)] + in_place: bool, +} + +impl DestinationArgs { + /// Says that `process` was not told where files go, in three lines, and exits: changing the + /// originals is never a default. + fn require(&self) { + if self.out.is_some() || self.in_place { + return; + } + eprintln!( + "process: nothing written\n why: it was not told where the files go, and it changes \ + the originals only when asked\n what to do: pass --out to write copies, or \ + --in-place to replace the files after a backup" + ); + std::process::exit(crate::EXIT_FAILED); + } +} + /// The flags of `process`. #[derive(clap::Args)] pub struct ProcessArgs { @@ -78,9 +109,8 @@ pub struct ProcessArgs { /// Change no audio: no gain and no cut; only tags carry the grid. #[arg(long)] grid_only: bool, - /// Write copies into this folder (created if needed) and leave the files as they are. - #[arg(long, value_name = "DIR")] - out: Option, + #[command(flatten)] + destination: DestinationArgs, /// Output bits per sample. #[arg(long, value_enum, default_value = "source")] depth: DepthArg, @@ -121,7 +151,7 @@ impl ProcessArgs { }; let defaults = ExportSettings::new(mode); let settings = ExportSettings { - place: if self.out.is_some() { + place: if self.destination.out.is_some() { ExportPlace::Folder } else { ExportPlace::InPlace @@ -170,36 +200,56 @@ struct Counts { failed: usize, } +/// How the batch exports. No confirm step checks anything here: the command line is the +/// confirmation. +fn process_settings( + decide: DecideSettings, + export: ExportSettings, + out_dir: Option, + backup_root: PathBuf, + recovered: RecoveryStatus, +) -> anyhow::Result { + Ok(ProcessSettings { + decide, + export, + out_dir, + backup_root, + edits: Some(EditStore::open(EditStore::default_dir()?)), + recovery: Arc::new(RecoveryGate::new(recovered)), + confirmed: None, + same_name: std::collections::BTreeSet::new(), + }) +} + /// `sc-cli process`: returns how many files failed. /// /// # Errors /// Invalid flags, an unreachable backup, cache or edits folder, or a failed write to stdout. pub fn run_process(args: &ProcessArgs) -> anyhow::Result { + args.destination.require(); let decide = decide_settings(args.mode, args.target, args.ceiling, &args.analysis); decide.validate()?; let export = args.export_settings()?; // Refused before anything is exported: the batch could not write its XML or report. - if export.xml && refuse_foreign_artefact(args.out.as_deref()) { + if export.xml && refuse_foreign_artefact(args.destination.out.as_deref()) { return Ok(1); } let root = args.backup.resolve()?; let cache = Cache::open(Cache::default_dir()?); let recovered = recover_first(&root, &cache); - let out_dir = args.out.as_ref().map(std::path::absolute).transpose()?; + let out_dir = args + .destination + .out + .as_ref() + .map(std::path::absolute) + .transpose()?; let place = out_dir.clone().map_or(Place::InPlace, Place::Folder); let action = if out_dir.is_some() { Action::Copy } else { Action::Change }; - let process = ProcessSettings { - decide, - export, - out_dir, - backup_root: root, - edits: Some(EditStore::open(EditStore::default_dir()?)), - recovery: Arc::new(RecoveryGate::new(recovered)), - }; + let process = process_settings(decide, export, out_dir, root, recovered)?; let settings = BatchSettings { analysis: crate::settings(&args.analysis), workers: args.jobs.unwrap_or_else(default_workers), @@ -264,7 +314,7 @@ pub fn run_process(args: &ProcessArgs) -> anyhow::Result { } if export.xml && printer.rows.iter().any(|r| r.action != RowAction::Failed) { let now = std::time::SystemTime::now(); - let dir = artefacts_dir(args.out.as_deref(), now)?; + let dir = artefacts_dir(args.destination.out.as_deref(), now)?; let playlist = sc_io::rekordbox::playlist_name(now); let written = write_artefacts(&dir, &printer.rows, XmlSelect::Batch, &playlist)?; print_artefacts(&written, &playlist, export.batch_mode, args.json); @@ -296,6 +346,11 @@ fn foreign_artefact(out: &Path) -> Option<(PathBuf, String)> { there; it is never replaced", conflict.name ), + Error::Corrupt { .. } => format!( + "it starts like the {} SoundCheck writes but cannot be read, so what it lists would \ + be lost; it is left as it is", + conflict.name + ), other => format!("it could not be read to check it: {other}"), }; (conflict.path, why) diff --git a/crates/sc-cli/src/process/tests.rs b/crates/sc-cli/src/process/tests.rs index 1a5e778..4c279c8 100644 --- a/crates/sc-cli/src/process/tests.rs +++ b/crates/sc-cli/src/process/tests.rs @@ -75,6 +75,7 @@ fn written_with(grid_check: GridCheck) -> FileResult { artist: None, }, analysis: None, + analysed: None, edit: EditState::default(), edit_carried: false, grid_check, diff --git a/crates/sc-cli/tests/inputs.rs b/crates/sc-cli/tests/inputs.rs index 20ae682..2ac53da 100644 --- a/crates/sc-cli/tests/inputs.rs +++ b/crates/sc-cli/tests/inputs.rs @@ -67,6 +67,8 @@ fn copies_that_would_share_a_name_are_refused_before_anything_is_written() { std::fs::write(a.join("Track.wav"), wav_bytes(false)).expect("a/Track.wav"); std::fs::write(b.join("TRACK.wav"), wav_bytes(false)).expect("b/TRACK.wav"); let out = lib.base.join("out"); + std::fs::create_dir_all(&out).expect("out"); + let folds_case = lib.base.join("OUT").exists(); let run = lib.run_in( &lib.music, &[ @@ -79,6 +81,26 @@ fn copies_that_would_share_a_name_are_refused_before_anything_is_written() { arg(&out), ], ); + if !folds_case { + // These are two different names on this volume, so both copies are written. + assert_eq!(run.code, Some(0), "{}", run.text()); + let mut written: Vec = std::fs::read_dir(&out) + .expect("out") + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + written.sort(); + assert_eq!( + written, + vec![ + "TRACK.wav", + "TRACK.wav.soundcheck.json", + "Track.wav", + "Track.wav.soundcheck.json" + ] + ); + return; + } assert_eq!(run.code, Some(2), "{}", run.text()); assert!( run.stdout.starts_with("a/Track.wav: gain -1.00 dB"), diff --git a/crates/sc-cli/tests/process.rs b/crates/sc-cli/tests/process.rs index 3694b2a..38dcc02 100644 --- a/crates/sc-cli/tests/process.rs +++ b/crates/sc-cli/tests/process.rs @@ -59,7 +59,7 @@ fn in_place_writes_backs_up_and_records_the_export() { let lib = Library::new(); let wav = tone(&lib, "a.wav", 44_100); let original = bytes(&wav); - let run = process(&lib, &[arg(&wav), "--batch-mode", "library"]); + let run = process(&lib, &[arg(&wav), "--in-place", "--batch-mode", "library"]); assert!(run.ok, "{}", run.text()); let first = run.stdout.lines().next().expect("a line"); assert!( @@ -166,7 +166,14 @@ fn json_has_one_document_per_file() { let hi_res = tone(&lib, "b.wav", 96_000); let run = process( &lib, - &[arg(&wav), arg(&hi_res), "--batch-mode", "library", "--json"], + &[ + arg(&wav), + arg(&hi_res), + "--in-place", + "--batch-mode", + "library", + "--json", + ], ); assert!(run.ok, "{}", run.text()); let docs = run.docs(); @@ -196,6 +203,7 @@ fn refusals_print_three_lines_and_fail_the_run() { arg(&wav), arg(&wav), arg(&missing), + "--in-place", "--batch-mode", "library", ], @@ -220,7 +228,10 @@ fn refusals_print_three_lines_and_fail_the_run() { // A skipped file is not a failure. let lib = Library::new(); let hi_res = tone(&lib, "b.wav", 96_000); - let run = process(&lib, &[arg(&hi_res), "--batch-mode", "prepare"]); + let run = process( + &lib, + &[arg(&hi_res), "--in-place", "--batch-mode", "prepare"], + ); assert!(run.ok, "{}", run.text()); assert!( run.stdout.contains("b.wav: prepare: skipped"), @@ -233,3 +244,98 @@ fn refusals_print_three_lines_and_fail_the_run() { run.text() ); } + +#[test] +fn refuses_without_being_told_where_files_go() { + let lib = Library::new(); + let wav = tone(&lib, "a.wav", 44_100); + let before = bytes(&wav); + let run = process(&lib, &[arg(&wav), "--batch-mode", "prepare"]); + assert_eq!(run.code, Some(2), "{}", run.text()); + assert_eq!(bytes(&wav), before, "nothing was written"); + assert!(!lib.backups.exists(), "nothing was journaled"); + let lines: Vec<&str> = run.stderr.lines().collect(); + assert_eq!(lines.len(), 3, "three lines: {}", run.stderr); + assert!(lines[1].contains("why:"), "{}", run.stderr); + assert!( + lines[2].contains("--out") && lines[2].contains("--in-place"), + "{}", + run.stderr + ); +} + +#[test] +fn out_and_in_place_are_one_choice() { + let lib = Library::new(); + let wav = tone(&lib, "a.wav", 44_100); + let out = lib.base.join("out"); + let run = process( + &lib, + &[ + arg(&wav), + "--batch-mode", + "prepare", + "--in-place", + "--out", + arg(&out), + ], + ); + assert_eq!(run.code, Some(2), "{}", run.text()); + assert!(!out.exists()); +} + +#[test] +fn a_second_export_to_one_folder_replaces_its_copy_and_keeps_the_rest_of_the_report() { + let lib = Library::new(); + let a = tone(&lib, "a.wav", 44_100); + let b = tone(&lib, "b.wav", 44_100); + let out = lib.base.join("out"); + let batch = |file: &Path, target: &str| { + process( + &lib, + &[ + arg(file), + "--batch-mode", + "prepare", + "--out", + arg(&out), + "--target", + target, + ], + ) + }; + let first = batch(&a, "-11"); + assert!(first.ok, "{}", first.text()); + let copy = out.join("a.wav"); + let before = bytes(©); + let source = bytes(&a); + + let other = batch(&b, "-11"); + assert!(other.ok, "{}", other.text()); + let again = batch(&a, "-16"); + assert!(again.ok, "{}", again.text()); + + assert_ne!(bytes(©), before, "the copy was replaced"); + assert_eq!(bytes(&a), source, "the source is untouched"); + let report = String::from_utf8(bytes(&out.join("grid-report.csv"))).expect("text"); + assert!( + report.contains("a.wav") && report.contains("b.wav"), + "{report}" + ); +} + +#[test] +fn a_file_in_the_folder_it_did_not_write_is_left_alone() { + let lib = Library::new(); + let a = tone(&lib, "a.wav", 44_100); + let out = lib.base.join("out"); + std::fs::create_dir_all(&out).expect("folder"); + std::fs::write(out.join("a.wav"), b"someone else's").expect("written"); + let run = process( + &lib, + &[arg(&a), "--batch-mode", "prepare", "--out", arg(&out)], + ); + assert_eq!(bytes(&out.join("a.wav")), b"someone else's"); + assert!(run.stdout.contains("skipped"), "{}", run.text()); + assert!(run.stdout.contains("move that file away"), "{}", run.text()); +} diff --git a/crates/sc-cli/tests/xml.rs b/crates/sc-cli/tests/xml.rs index 8d997b1..d6a4210 100644 --- a/crates/sc-cli/tests/xml.rs +++ b/crates/sc-cli/tests/xml.rs @@ -79,7 +79,14 @@ fn process_in_place_writes_the_artefacts_into_a_new_batch_folder() { let wav = tone(&lib, "Şarkı & co.wav"); let run = sc_cli( &lib, - &["process", arg(&wav), "--batch-mode", "prepare", "--no-grid"], + &[ + "process", + arg(&wav), + "--in-place", + "--batch-mode", + "prepare", + "--no-grid", + ], ); assert!(run.ok, "{}", run.text()); let folders = batch_folders(&lib); @@ -136,7 +143,14 @@ fn process_in_place_writes_the_artefacts_into_a_new_batch_folder() { // A second batch gets its own folder. let again = sc_cli( &lib, - &["process", arg(&wav), "--batch-mode", "prepare", "--no-grid"], + &[ + "process", + arg(&wav), + "--in-place", + "--batch-mode", + "prepare", + "--no-grid", + ], ); assert!(again.ok, "{}", again.text()); assert_eq!(batch_folders(&lib).len(), 2); @@ -199,6 +213,7 @@ fn no_xml_writes_no_artefacts() { &[ "process", arg(&wav), + "--in-place", "--batch-mode", "library", "--no-grid", @@ -265,7 +280,14 @@ fn xml_reads_an_export_back_from_its_sidecar() { tag_wav(&wav, "Gece Yarısı", "Ayşe & İlhan"); let run = sc_cli( &lib, - &["process", arg(&wav), "--batch-mode", "library", "--no-grid"], + &[ + "process", + arg(&wav), + "--in-place", + "--batch-mode", + "library", + "--no-grid", + ], ); assert!(run.ok, "{}", run.text()); record_grid(&wav); diff --git a/crates/sc-core/src/export.rs b/crates/sc-core/src/export.rs index 9bd12be..25b8edb 100644 --- a/crates/sc-core/src/export.rs +++ b/crates/sc-core/src/export.rs @@ -86,10 +86,10 @@ impl BatchMode { #[ts(export)] #[serde(rename_all = "camelCase")] pub enum Place { - /// Replaces the original, after backing it up. - #[default] + /// Replaces the original, after backing it up: only when asked for. InPlace, - /// A copy in another folder; the original is untouched. + /// A copy in another folder; the original is untouched. The default. + #[default] Folder, } @@ -115,13 +115,13 @@ pub struct ExportSettings { } impl ExportSettings { - /// The defaults of `batch_mode`: in place, source depth, gain on, the tempo tag in Prepare - /// only (a Library track's tag is the DJ app's), XML on, [`DEFAULT_LEAD_MS`]. + /// The defaults of `batch_mode`: to a folder (originals are changed only when asked), source + /// depth, gain on, the tempo tag in Prepare only (a Library track's tag is the DJ app's), XML on, [`DEFAULT_LEAD_MS`]. #[must_use] pub fn new(batch_mode: BatchMode) -> Self { Self { batch_mode, - place: Place::InPlace, + place: Place::Folder, depth: None, grid_only: false, tbpm: batch_mode == BatchMode::Prepare, @@ -359,6 +359,12 @@ pub enum ExportSkip { /// written (where bar 1 is cannot be trusted), so there is nothing to write. Confirm the /// grid first. GridNeedsReview, + /// 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. + CopyOfOtherTrack, /// An MP3 or AAC file, which only the rekordbox XML carries, without a grid: the XML would /// list nothing for it. XmlNoGrid { @@ -386,6 +392,41 @@ pub enum ExportSkip { }, } +/// What could make a skipped file writable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipCure { + /// Nothing the user does here: it is about the file itself. + File, + /// A grid saved or confirmed. + Grid, + /// Other export settings. + Settings, + /// What is in the folder, which can change with no word to the app. + Disk, +} + +impl ExportSkip { + /// What could make the file writable: whether a later export is worth planning again. + #[must_use] + pub fn cure(self) -> SkipCure { + match self { + Self::NotDjSafeRate { .. } + | Self::UnsupportedChannels { .. } + | Self::Unsupported { .. } + | Self::Silent => SkipCure::File, + Self::NoGrid + | Self::GridNeedsReview + | Self::XmlNoGrid { .. } + | Self::XmlGridNeedsReview { .. } + | Self::XmlNotOptedIn { .. } => SkipCure::Grid, + Self::SeratoInPlaceCut { .. } + | Self::SeratoUnknownInPlaceCut { .. } + | Self::NothingToWrite { .. } => SkipCure::Settings, + Self::OtherFileInFolder | Self::CopyOfOtherTrack => SkipCure::Disk, + } + } +} + /// What exporting does with one file. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] diff --git a/crates/sc-core/src/export/tests.rs b/crates/sc-core/src/export/tests.rs index f44ec0d..8c87564 100644 --- a/crates/sc-core/src/export/tests.rs +++ b/crates/sc-core/src/export/tests.rs @@ -5,11 +5,12 @@ use super::*; fn defaults_per_mode() { let prepare = ExportSettings::new(BatchMode::Prepare); assert!(prepare.tbpm && prepare.xml && !prepare.grid_only); - assert_eq!(prepare.place, Place::InPlace); + assert_eq!(prepare.place, Place::Folder); assert_eq!(prepare.depth, None); assert!((prepare.lead_ms - 5.0).abs() < f64::EPSILON); let library = ExportSettings::new(BatchMode::Library); assert!(!library.tbpm && library.xml); + assert_eq!(library.place, Place::Folder); assert_eq!(ExportSettings::default(), prepare); assert!(prepare.validate().is_ok() && library.validate().is_ok()); } @@ -89,6 +90,63 @@ fn outcomes_serialise_as_tagged_unions() { let settings = serde_json::to_string(&ExportSettings::default()).unwrap(); assert_eq!( settings, - r#"{"batchMode":"prepare","place":"inPlace","depth":null,"gridOnly":false,"tbpm":true,"xml":true,"leadMs":5.0}"# + r#"{"batchMode":"prepare","place":"folder","depth":null,"gridOnly":false,"tbpm":true,"xml":true,"leadMs":5.0}"# ); } + +#[test] +fn a_skip_says_what_could_cure_it() { + use crate::Seconds; + use crate::plan::Codec; + let cases = [ + ( + ExportSkip::NotDjSafeRate { + sample_rate_hz: 96_000, + }, + SkipCure::File, + ), + ( + ExportSkip::UnsupportedChannels { channels: 6 }, + SkipCure::File, + ), + ( + ExportSkip::Unsupported { codec: Codec::Alac }, + SkipCure::File, + ), + (ExportSkip::Silent, SkipCure::File), + (ExportSkip::NoGrid, SkipCure::Grid), + (ExportSkip::GridNeedsReview, SkipCure::Grid), + (ExportSkip::XmlNoGrid { codec: Codec::Mp3 }, SkipCure::Grid), + ( + ExportSkip::XmlGridNeedsReview { codec: Codec::Mp3 }, + SkipCure::Grid, + ), + ( + ExportSkip::XmlNotOptedIn { codec: Codec::Mp3 }, + SkipCure::Grid, + ), + ( + ExportSkip::SeratoInPlaceCut { + cut_s: Seconds(0.1), + }, + SkipCure::Settings, + ), + ( + ExportSkip::SeratoUnknownInPlaceCut { + cut_s: Seconds(0.1), + }, + SkipCure::Settings, + ), + ( + ExportSkip::NothingToWrite { + reason: XmlOnlyReason::GridOnlyFlac, + }, + SkipCure::Settings, + ), + (ExportSkip::OtherFileInFolder, SkipCure::Disk), + (ExportSkip::CopyOfOtherTrack, 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 61bcc83..47efa7e 100644 --- a/crates/sc-core/src/ipc/export.rs +++ b/crates/sc-core/src/ipc/export.rs @@ -1,20 +1,26 @@ //! What an export sends and receives over IPC: the request, the row each file shows (at most //! [`super::MAX_ROW_BYTES`] of JSON whatever the file holds, which [`ExportRow`]'s bounded lists -//! see to), the batch's artefacts, and the answers of `revert_file`, `write_rekordbox_xml` and +//! see to), the batch's artefacts, and the answers of `plan_export`, `revert_file`, `write_rekordbox_xml` and //! `forget_pending`. The written file's analysis, with its evidence, never crosses: the row //! carries the numbers the table shows. +use std::path::Path; + use serde::{Deserialize, Serialize}; use ts_rs::TS; use super::recovery::RecoveryStatus; use super::track::RowUpdate; -use super::{FileEntry, JobStage}; +use super::{FileEntry, IpcError, JobId, JobStage}; use crate::export::{Cut, ExportNotice, ExportSettings, ExportSkip, GridCheck, XmlOnlyReason}; +use crate::plan::DecideSettings; /// Most JSON bytes the notes of one [`ExportRow`] take; [`bounded_strings`] keeps them there. pub const MAX_ROW_NOTES_JSON_BYTES: usize = 700; +/// Most JSON bytes the folder in one [`ExportedWith`] takes. +pub const MAX_EXPORTED_FOLDER_JSON_BYTES: usize = 300; + /// Most JSON bytes the stale tag labels of one [`ExportVerification`] take. pub const MAX_STALE_TAGS_JSON_BYTES: usize = 300; @@ -29,6 +35,122 @@ pub struct ExportRequest { pub settings: ExportSettings, /// The absolute folder copies go to: required by, and only by, a `folder` place. pub out_dir: Option, + /// What the user confirmed, from `plan_export`: the job refuses to run on other settings, and + /// a file no longer the one confirmed fails its row before anything is written. `None` checks + /// nothing; the app always sends it. + pub confirmed: Option, +} + +/// What the user saw in the confirm step, sent back with the export. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmedExport { + /// The settings revision the plan was decided with. + pub revision: u32, + /// Each file as it was planned. + pub files: Vec, +} + +/// One file as the confirm step planned it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmedFile { + /// The file. + pub file_id: u32, + /// Its identity then (length, modification time, inode): opaque, compared whole. + pub identity: String, +} + +/// What a row's export was made with, so the row can say when the settings have moved on. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ExportedWith { + /// The loudness settings. + pub decide: DecideSettings, + /// The export settings. + pub settings: ExportSettings, + /// The folder, cut to [`MAX_EXPORTED_FOLDER_JSON_BYTES`] of JSON (a folder longer than that + /// only reads as a different one). + pub out_dir: Option, +} + +impl ExportedWith { + /// What an export with these settings is made with. + #[must_use] + pub fn new(decide: DecideSettings, settings: ExportSettings, out_dir: Option<&Path>) -> Self { + Self { + decide, + settings, + out_dir: out_dir.and_then(|dir| { + let text = dir.display().to_string(); + bounded_strings([text], MAX_EXPORTED_FOLDER_JSON_BYTES).pop() + }), + } + } +} + +/// What became of the latest batch, kept for a window that reloads. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ExportSummary { + /// The job it was. + pub job_id: JobId, + /// Files written. + pub written: u32, + /// Files only the rekordbox XML carries. + pub xml_only: u32, + /// Files skipped. + pub skipped: u32, + /// Files failed. + pub failed: u32, + /// It was cancelled. + pub cancelled: bool, + /// How long it took. + pub seconds: f64, +} + +/// What exporting would do with one file, before anything is written. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ExportPreviewRow { + /// The file. + pub file_id: u32, + /// 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. + pub error: Option, + /// The file as it was planned, to send back ([`ConfirmedFile`]); `None` without a plan. + pub identity: Option, + /// The write replaces an earlier copy of it SoundCheck wrote in the folder. + pub replaces_copy: bool, +} + +/// What `plan_export` found: the confirm step's numbers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ExportPreview { + /// One row per file named, in that order. + pub rows: Vec, + /// The settings revision the rows were planned with, to send back ([`ConfirmedExport`]). + pub revision: u32, + /// The loudness settings they were planned with. + pub decide: DecideSettings, + /// Tracks the rekordbox XML already in the folder lists, whose files are still there: they stay + /// listed. + pub xml_listed: u32, + /// Where the originals go, for an export in place. + pub backup_folder: Option, + /// Where the rekordbox XML and grid report go, with the XML on: the export's folder, or for + /// an export in place the folder a new dated folder is made in. + pub artefacts_folder: Option, } /// What exporting does with a file, as the row's Action column shows it. @@ -125,6 +247,8 @@ pub struct ExportRow { /// The file was changed in place, with a backup, so `revert_file` can put it back. A copy in /// a folder leaves the original as it was: nothing to revert. pub revertible: bool, + /// The settings it was made with; absent where the row was not made by a job or a preview. + pub exported_with: Option, /// What did not work after the write (the file stays written) and what the render could not /// do, in order; bounded to [`MAX_ROW_NOTES_JSON_BYTES`], so the grid report holds the rest. pub notes: Vec, diff --git a/crates/sc-core/src/ipc/mod.rs b/crates/sc-core/src/ipc/mod.rs index 08137f5..aa7d9ec 100644 --- a/crates/sc-core/src/ipc/mod.rs +++ b/crates/sc-core/src/ipc/mod.rs @@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; pub use export::{ - ExportAction, ExportArtefacts, ExportRequest, ExportRow, ExportVerification, ForgetResult, + ConfirmedExport, ConfirmedFile, ExportAction, ExportArtefacts, ExportPreview, ExportPreviewRow, + ExportRequest, ExportRow, ExportSummary, ExportVerification, ExportedWith, ForgetResult, MAX_ROW_NOTES_JSON_BYTES, MAX_STALE_TAGS_JSON_BYTES, RevertResult, WriteXmlRequest, XmlNotListed, XmlWritten, bounded_strings, }; @@ -30,7 +31,7 @@ use crate::units::{Bpm, DbTp, Lu, Lufs, Seconds}; pub const MAX_EVENT_BYTES: usize = 256; /// Largest JSON encoding allowed for one analysed row with its plan, and for one export row. -pub const MAX_ROW_BYTES: usize = 2048; +pub const MAX_ROW_BYTES: usize = 3072; /// Largest JSON encoding allowed for an `exportDone` event, which holds both of those. pub const MAX_EXPORT_DONE_BYTES: usize = 2 * MAX_ROW_BYTES; @@ -333,6 +334,11 @@ pub struct SessionRow { /// A change of it is in effect that `revert_file` can put back: also true with no `export` /// after a revert that left an earlier change standing. pub revertible: bool, + /// A grid different from what its export wrote was saved since: exporting it again would write + /// something new. + pub export_stale: bool, + /// The BPM range its analysis was made with, when analysed. + pub analysed_range: Option<(Bpm, Bpm)>, } /// Everything the session holds, in the order the files were added. @@ -344,6 +350,15 @@ pub struct SessionSnapshot { pub revision: u32, /// The rows. pub rows: Vec, + /// Jobs still winding down when this was read: they were cancelled, but until they end their + /// files are busy and their rows can still change. Read again until this is empty. + pub running_jobs: Vec, + /// Where the latest batch's rekordbox XML and grid report are, until the next batch. + pub artefacts: Option, + /// Why the latest batch's rekordbox XML and grid report could not be written. + pub artefacts_failed: Option, + /// What became of the latest batch. + pub last_export: Option, } /// A row's plan, sent when the settings change. @@ -572,6 +587,8 @@ pub enum IpcErrorKind { /// Another job or command is working on the file (`file_id`): exporting it, reverting it, /// or, for a change of it, analysing it. Nothing was done; asking again once that ends works. Busy, + /// The loudness settings changed after the plan the user confirmed. + SettingsChanged, } /// An error crossing IPC: a class the UI can branch on plus a human-readable message. diff --git a/crates/sc-core/src/ipc/tests.rs b/crates/sc-core/src/ipc/tests.rs index a9448a2..1316477 100644 --- a/crates/sc-core/src/ipc/tests.rs +++ b/crates/sc-core/src/ipc/tests.rs @@ -423,6 +423,11 @@ fn worst_case_export_row() -> ExportRow { }), edit_carried: true, revertible: true, + exported_with: Some(ExportedWith::new( + crate::plan::DecideSettings::dj(), + crate::export::ExportSettings::default(), + Some(std::path::Path::new(&long("\""))), + )), notes: bounded_strings( vec![long("\""), long("\u{1f3a7}")], MAX_ROW_NOTES_JSON_BYTES, @@ -430,6 +435,20 @@ fn worst_case_export_row() -> ExportRow { } } +#[test] +fn a_worst_case_preview_row_stays_within_budget() { + let row = ExportPreviewRow { + file_id: u32::MAX, + row: Some(Box::new(worst_case_export_row())), + error: None, + identity: Some( + "1:18446744073709551615:18446744073709551615:9223372036854775807.999999999".to_owned(), + ), + replaces_copy: true, + }; + assert!(json_len(&row) <= MAX_ROW_BYTES, "{} bytes", json_len(&row)); +} + #[test] fn a_worst_case_export_row_stays_within_budget() { let row = worst_case_export_row(); @@ -455,6 +474,7 @@ fn a_worst_case_export_done_stays_within_budget() { row: Box::new(row), plan, revision: u32::MAX, + export_stale: true, })), }; assert!( diff --git a/crates/sc-core/src/ipc/track.rs b/crates/sc-core/src/ipc/track.rs index 46758b7..dacc035 100644 --- a/crates/sc-core/src/ipc/track.rs +++ b/crates/sc-core/src/ipc/track.rs @@ -214,4 +214,6 @@ pub struct RowUpdate { pub plan: Plan, /// The settings revision the plan was decided with. pub revision: u32, + /// A grid different from what the file's export wrote has been saved since it. + pub export_stale: bool, } diff --git a/crates/sc-engine/src/analyze.rs b/crates/sc-engine/src/analyze.rs index e4ee7e0..55cd581 100644 --- a/crates/sc-engine/src/analyze.rs +++ b/crates/sc-engine/src/analyze.rs @@ -17,7 +17,7 @@ use sc_core::analysis::{ AnalysisRecord, AnalysisSettings, Grid, GridEdit, GridEvidence, Model, OnsetList, RECORD_SCHEMA, TagHints, }; -use sc_core::{AudioSpec, Error, Result, SampleIndex}; +use sc_core::{AudioSpec, Bpm, Error, Result, SampleIndex}; use sc_dsp::{KickBand, Onset, OnsetDetector, Resampler, to_mono}; use sc_io::cache::Cache; use sc_io::{Decoder, tags}; @@ -66,6 +66,13 @@ pub struct AnalyzeReport { pub cache: CacheStatus, /// The analysis. pub record: AnalysisRecord, + /// What the file was before it was read (`sc_io::txn::file_identity`): a file changed during + /// the analysis does not match it afterwards. + #[serde(skip)] + pub identity: Option, + /// The BPM range it was analysed with. + #[serde(skip)] + pub bpm_range: (Bpm, Bpm), } /// Wall time per stage of one analysis. @@ -185,6 +192,8 @@ impl Analyzer { read_cache: bool, ) -> Result { self.check_cancel()?; + let identity = sc_io::txn::file_identity(path).ok(); + let bpm_range = self.settings.bpm_range; let (nfc_path, key) = Cache::key_for(path, &self.settings)?; if read_cache && let Some(cache) = &self.cache @@ -194,6 +203,8 @@ impl Analyzer { schema: REPORT_SCHEMA, cache: CacheStatus::Hit, record, + identity, + bpm_range, }); } @@ -254,6 +265,8 @@ impl Analyzer { schema: REPORT_SCHEMA, cache, record, + identity, + bpm_range, }) } } diff --git a/crates/sc-engine/src/export/artefacts.rs b/crates/sc-engine/src/export/artefacts.rs index 0078c4a..de41685 100644 --- a/crates/sc-engine/src/export/artefacts.rs +++ b/crates/sc-engine/src/export/artefacts.rs @@ -33,6 +33,10 @@ use super::plan::grid_trusted; use super::process::{xml_grid, xml_info}; use crate::edits::EditState; +pub(super) mod merge; + +pub use merge::tracks_listed_in; + /// What happened to a file. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RowAction { @@ -377,35 +381,47 @@ pub struct Artefacts { /// Writes the grid report of `rows` and, unless `select` is [`XmlSelect::Off`], the rekordbox /// XML (its playlist named `playlist`, [`sc_io::rekordbox::playlist_name`]) into `dir`, each -/// replacing an older one SoundCheck wrote atomically; anything else of that name is left as -/// it is. +/// replacing an older one SoundCheck wrote atomically, with what that one says about other files +/// still there (see `merge`); anything else of that name is left as it is. /// /// # Errors /// [`sc_core::Error::AlreadyExists`] naming a file there that SoundCheck did not write; -/// [`sc_core::Error::Io`] naming the file that could not be written. +/// [`sc_core::Error::Corrupt`] naming one of its own that cannot be read whole; both left as they +/// are. [`sc_core::Error::Io`] naming the file that could not be written. pub fn write_artefacts( dir: &Path, rows: &[BatchRow], select: XmlSelect, playlist: &str, ) -> Result { - use sc_io::artefacts::write_artefact; - let tracks = xml_tracks(rows, select); - let xml = if select == XmlSelect::Off { + use sc_io::artefacts::{check_replaceable, write_artefact}; + let xml_path = dir.join(sc_io::rekordbox::XML_FILE_NAME); + let report = dir.join(sc_io::report::REPORT_FILE_NAME); + // Everything refused before anything is written, or read as ours. + check_replaceable(&report, sc_io::report::is_soundcheck_report)?; + let tracks = if select == XmlSelect::Off { None } else { - let path = dir.join(sc_io::rekordbox::XML_FILE_NAME); - let bytes = sc_io::rekordbox::xml_bytes(&tracks, sc_core::VERSION, playlist); - write_artefact(&path, &bytes, sc_io::rekordbox::is_soundcheck_xml)?; - Some(path) + check_replaceable(&xml_path, sc_io::rekordbox::is_soundcheck_xml)?; + Some(merge::merged_tracks(dir, rows, xml_tracks(rows, select))?) }; - let report = dir.join(sc_io::report::REPORT_FILE_NAME); - let csv = sc_io::report::csv_bytes(&report_rows(rows, select)); + let report_rows = merge::merged_report(dir, report_rows(rows, select))?; + let mut listed = 0; + let xml = match tracks { + Some(tracks) => { + let bytes = sc_io::rekordbox::xml_bytes(&tracks, sc_core::VERSION, playlist); + write_artefact(&xml_path, &bytes, sc_io::rekordbox::is_soundcheck_xml)?; + listed = tracks.len(); + Some(xml_path) + } + None => None, + }; + let csv = sc_io::report::csv_bytes(&report_rows); write_artefact(&report, &csv, sc_io::report::is_soundcheck_report)?; Ok(Artefacts { xml, report, - listed: tracks.len(), + listed, }) } diff --git a/crates/sc-engine/src/export/artefacts/merge.rs b/crates/sc-engine/src/export/artefacts/merge.rs new file mode 100644 index 0000000..6e399aa --- /dev/null +++ b/crates/sc-engine/src/export/artefacts/merge.rs @@ -0,0 +1,120 @@ +//! One folder, one rekordbox XML and one grid report, describing that folder: a batch adds to what +//! an earlier batch wrote there rather than replacing it, since the earlier copies are still there. +//! Entries are matched by location (the report by its file), the newest wins, and an entry whose +//! file is gone is dropped. Everything is sorted by that key and the XML's `TrackID`s numbered in +//! that order, so a folder's artefacts do not depend on the order its batches ran in. A file there +//! that starts like ours but cannot be read whole is refused, never taken for an empty one: writing +//! over it would lose what it lists. + +use std::collections::BTreeMap; +use std::path::Path; + +use sc_core::{Error, Result}; +use sc_io::rekordbox::{Speller, XmlTrack, location, read_tracks}; +use sc_io::report::{ReportRow, read_rows}; + +use super::{BatchRow, RowAction}; + +/// What reading `name` in `dir` as ours found: `read`'s answer, nothing when there is no such file. +/// +/// # Errors +/// [`Error::Corrupt`] when `read` cannot read it whole; [`Error::Io`] when it cannot be read at all. +fn read_ours( + dir: &Path, + name: &str, + read: impl FnOnce(&[u8]) -> Option, +) -> Result { + let path = dir.join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()), + Err(source) => return Err(Error::Io { path, source }), + }; + read(&bytes).ok_or_else(|| Error::Corrupt { + path, + detail: "it starts like one SoundCheck wrote but cannot be read, so what it lists would \ + be lost. Nothing was written: move it away or choose another folder" + .to_owned(), + }) +} + +/// The rekordbox XML in `dir`, read whole. +/// +/// # Errors +/// As [`read_ours`]. +pub(crate) fn listed_in(dir: &Path) -> Result> { + read_ours(dir, sc_io::rekordbox::XML_FILE_NAME, read_tracks) +} + +/// The grid report in `dir`, read whole. +/// +/// # Errors +/// As [`read_ours`]. +pub(crate) fn reported_in(dir: &Path) -> Result> { + read_ours(dir, sc_io::report::REPORT_FILE_NAME, read_rows) +} + +/// The tracks an XML in `dir` should list: `tracks`, what the one there lists for other files that +/// still exist. A row of this batch that the XML no longer lists takes its old entry with it. +/// +/// # Errors +/// As [`read_ours`]. +pub(super) fn merged_tracks( + dir: &Path, + rows: &[BatchRow], + tracks: Vec, +) -> Result> { + let existing = listed_in(dir)?; + let mut speller = Speller::new(); + let this_batch: Vec = rows + .iter() + .filter_map(|r| r.track.as_ref()) + .map(|t| location(&speller.spell(&t.path))) + .collect(); + let mut merged: BTreeMap = existing + .into_iter() + .filter(|t| t.path.is_file()) + .map(|t| (location(&t.path), t)) + .filter(|(key, _)| !this_batch.contains(key)) + .collect(); + merged.extend(tracks.into_iter().map(|t| (location(&t.path), t))); + Ok(merged.into_values().collect()) +} + +/// Whether a report row from an earlier batch still describes a file: the copy in `dir` for a file +/// written there, else the file itself. +fn still_there(row: &ReportRow, dir: &Path) -> bool { + let file = Path::new(&row.file); + let wrote_copy = [RowAction::Written, RowAction::Exported] + .iter() + .any(|a| a.as_str() == row.action); + if wrote_copy && let Some(name) = file.file_name() { + // A copy, or a file that batch replaced in place, which sits at its own path. + return dir.join(name).is_file() || file.is_file(); + } + file.is_file() +} + +/// The rows a report in `dir` should hold: `rows`, and what the one there says about other files +/// that still exist. +/// +/// # Errors +/// As [`read_ours`]. +pub(super) fn merged_report(dir: &Path, rows: Vec) -> Result> { + let existing = reported_in(dir)?; + let mut merged: BTreeMap = existing + .into_iter() + .filter(|r| still_there(r, dir)) + .map(|r| (r.file.clone(), r)) + .collect(); + merged.extend(rows.into_iter().map(|r| (r.file.clone(), r))); + Ok(merged.into_values().collect()) +} + +/// Tracks the XML in `dir` lists now, for the confirm step to say they stay. +#[must_use] +pub fn tracks_listed_in(dir: &Path) -> usize { + listed_in(dir).map_or(0, |tracks| { + tracks.iter().filter(|t| t.path.is_file()).count() + }) +} diff --git a/crates/sc-engine/src/export/artefacts/tests.rs b/crates/sc-engine/src/export/artefacts/tests.rs index d6d7371..ab3ce10 100644 --- a/crates/sc-engine/src/export/artefacts/tests.rs +++ b/crates/sc-engine/src/export/artefacts/tests.rs @@ -207,3 +207,243 @@ fn artefacts_are_written_together() { let off = write_artefacts(dir.path(), &rows, XmlSelect::Off, "SoundCheck t").expect("written"); assert_eq!((off.xml, off.listed), (None, 0)); } + +/// A row of a file in `dir` this batch wrote, with a grid. +fn written_in(dir: &Path, name: &str) -> BatchRow { + let copy = dir.join(name); + std::fs::write(©, b"audio").expect("written"); + BatchRow::written( + Path::new(&format!("/source/{name}")), + BatchMode::Prepare, + &plan(-1.0, 0), + ©, + 44_100, + SR, + info(grid(221, Meter::four_four(), false)), + ) +} + +fn listed(dir: &Path) -> Vec { + let xml = std::fs::read(dir.join(sc_io::rekordbox::XML_FILE_NAME)).expect("xml"); + sc_io::rekordbox::read_tracks(&xml) + .expect("our XML") + .iter() + .map(|t| { + t.path + .file_name() + .expect("a name") + .to_string_lossy() + .into_owned() + }) + .collect() +} + +fn report_files(dir: &Path) -> Vec { + let csv = std::fs::read(dir.join(sc_io::report::REPORT_FILE_NAME)).expect("report"); + sc_io::report::read_rows(&csv) + .expect("our report") + .into_iter() + .map(|r| r.file) + .collect() +} + +#[test] +fn a_second_batch_into_one_folder_keeps_what_the_first_listed() { + let dir = tempfile::tempdir().expect("temp dir"); + let (a, b) = ( + written_in(dir.path(), "a.wav"), + written_in(dir.path(), "b.wav"), + ); + write_artefacts(dir.path(), &[b], XmlSelect::Batch, "SoundCheck first").expect("written"); + let second = + write_artefacts(dir.path(), &[a], XmlSelect::Batch, "SoundCheck second").expect("written"); + assert_eq!(second.listed, 2, "both tracks are in the folder"); + assert_eq!(listed(dir.path()), ["a.wav", "b.wav"], "sorted by location"); + assert_eq!(report_files(dir.path()), ["/source/a.wav", "/source/b.wav"]); + // Ids follow the sort, and the playlist is the batch's. + let xml = std::fs::read_to_string(dir.path().join(sc_io::rekordbox::XML_FILE_NAME)).unwrap(); + let a_at = xml.find("a.wav").unwrap(); + assert!(xml[..a_at].contains(r#"TrackID="1""#), "{xml}"); + assert!(xml.contains(r#"Name="SoundCheck second""#), "{xml}"); + assert!(!xml.contains("SoundCheck first"), "{xml}"); +} + +#[test] +fn the_order_of_the_batches_does_not_change_the_files() { + let (one, two) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let same = |dir: &Path| dir.join("x"); + for dir in [one.path(), two.path()] { + std::fs::create_dir(same(dir)).unwrap(); + } + let (a1, b1) = ( + written_in(&same(one.path()), "a.wav"), + written_in(&same(one.path()), "b.wav"), + ); + let (a2, b2) = ( + written_in(&same(two.path()), "a.wav"), + written_in(&same(two.path()), "b.wav"), + ); + for (first, second, dir) in [(a1, b1, one.path()), (b2, a2, two.path())] { + write_artefacts(&same(dir), &[first], XmlSelect::Batch, "p").unwrap(); + write_artefacts(&same(dir), &[second], XmlSelect::Batch, "p").unwrap(); + } + assert_eq!(listed(&same(one.path())), listed(&same(two.path()))); +} + +#[test] +fn what_a_kept_entry_says_survives_with_the_same_bytes() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut a = written_in(dir.path(), "a, \"b\".wav"); + a.notes = vec!["=a note".to_owned(), "another".to_owned()]; + let b = written_in(dir.path(), "z.wav"); + write_artefacts(dir.path(), &[a], XmlSelect::Batch, "p").expect("written"); + let first_xml = + std::fs::read_to_string(dir.path().join(sc_io::rekordbox::XML_FILE_NAME)).unwrap(); + let first_csv = + std::fs::read_to_string(dir.path().join(sc_io::report::REPORT_FILE_NAME)).unwrap(); + write_artefacts(dir.path(), &[b], XmlSelect::Batch, "p").expect("written"); + let xml = std::fs::read_to_string(dir.path().join(sc_io::rekordbox::XML_FILE_NAME)).unwrap(); + let csv = std::fs::read_to_string(dir.path().join(sc_io::report::REPORT_FILE_NAME)).unwrap(); + let track_of = |text: &str| { + let at = text.find("").unwrap()].to_owned() + }; + assert_eq!(track_of(&xml), track_of(&first_xml)); + let row = first_csv.split("\r\n").nth(1).unwrap(); + assert!(csv.contains(row), "{row} in {csv}"); +} + +#[test] +fn an_entry_whose_file_is_gone_is_dropped() { + let dir = tempfile::tempdir().expect("temp dir"); + let (a, b) = ( + written_in(dir.path(), "a.wav"), + written_in(dir.path(), "b.wav"), + ); + write_artefacts(dir.path(), &[a], XmlSelect::Batch, "p").expect("written"); + std::fs::remove_file(dir.path().join("a.wav")).unwrap(); + let written = write_artefacts(dir.path(), &[b], XmlSelect::Batch, "p").expect("written"); + assert_eq!(written.listed, 1); + assert_eq!(listed(dir.path()), ["b.wav"]); + assert_eq!(report_files(dir.path()), ["/source/b.wav"]); +} + +#[test] +fn a_track_this_batch_no_longer_lists_takes_its_old_entry_with_it() { + let dir = tempfile::tempdir().expect("temp dir"); + let a = written_in(dir.path(), "a.wav"); + write_artefacts(dir.path(), std::slice::from_ref(&a), XmlSelect::Batch, "p").expect("written"); + let withheld = BatchRow { + track: a.track.map(|t| RowTrack { + grid: XmlGrid::NeedsReview, + ..t + }), + ..written_in(dir.path(), "a.wav") + }; + let written = write_artefacts(dir.path(), &[withheld], XmlSelect::Batch, "p").expect("written"); + assert_eq!(written.listed, 0); + assert_eq!(listed(dir.path()), Vec::::new()); +} + +#[test] +fn a_foreign_xml_there_is_neither_read_nor_replaced() { + let dir = tempfile::tempdir().expect("temp dir"); + let foreign = ""; + let xml = dir.path().join(sc_io::rekordbox::XML_FILE_NAME); + std::fs::write(&xml, foreign).unwrap(); + let a = written_in(dir.path(), "a.wav"); + let refused = write_artefacts(dir.path(), &[a], XmlSelect::Batch, "p"); + assert!( + matches!(refused, Err(sc_core::Error::AlreadyExists { .. })), + "{refused:?}" + ); + assert_eq!(std::fs::read_to_string(&xml).unwrap(), foreign); +} + +/// Writes `a.wav`'s artefacts, damages `name` with `damage`, and returns its damaged bytes. +fn damaged(dir: &Path, name: &str, damage: fn(Vec) -> Vec) -> Vec { + let a = written_in(dir, "a.wav"); + write_artefacts(dir, &[a], XmlSelect::Batch, "p").expect("written"); + let path = dir.join(name); + let bytes = damage(std::fs::read(&path).expect("read")); + std::fs::write(&path, &bytes).expect("damaged"); + bytes +} + +fn refused_and_left(dir: &Path, name: &str, bytes: &[u8]) { + let other = std::fs::read(dir.join(if name == sc_io::report::REPORT_FILE_NAME { + sc_io::rekordbox::XML_FILE_NAME + } else { + sc_io::report::REPORT_FILE_NAME + })) + .expect("the other file"); + let conflict = crate::export::artefact_conflict(dir).expect("refused before the job"); + assert_eq!(conflict.name, name); + let text = conflict.error.to_string(); + assert!( + text.contains(name) && text.contains("choose another folder"), + "{text}" + ); + + let b = written_in(dir, "b.wav"); + let refused = write_artefacts(dir, &[b], XmlSelect::Batch, "p"); + assert!( + matches!(refused, Err(sc_core::Error::Corrupt { .. })), + "{refused:?}" + ); + assert_eq!( + std::fs::read(dir.join(name)).expect("read"), + bytes, + "left as it is" + ); + // Nothing half done: the other file is not written either. + let name_of_other = if name == sc_io::report::REPORT_FILE_NAME { + sc_io::rekordbox::XML_FILE_NAME + } else { + sc_io::report::REPORT_FILE_NAME + }; + assert_eq!(std::fs::read(dir.join(name_of_other)).expect("read"), other); +} + +#[test] +fn an_xml_of_ours_cut_short_is_refused_and_left() { + let dir = tempfile::tempdir().expect("temp dir"); + let bytes = damaged(dir.path(), sc_io::rekordbox::XML_FILE_NAME, |b| { + b[..b.len() - 40].to_vec() + }); + refused_and_left(dir.path(), sc_io::rekordbox::XML_FILE_NAME, &bytes); +} + +#[test] +fn an_xml_of_ours_with_a_track_without_a_tempo_is_refused_and_left() { + let dir = tempfile::tempdir().expect("temp dir"); + let bytes = damaged(dir.path(), sc_io::rekordbox::XML_FILE_NAME, |b| { + String::from_utf8(b) + .unwrap() + .replace( + "", + " \n ", + ) + .into_bytes() + }); + refused_and_left(dir.path(), sc_io::rekordbox::XML_FILE_NAME, &bytes); +} + +#[test] +fn a_report_of_ours_with_a_short_row_is_refused_and_left() { + let dir = tempfile::tempdir().expect("temp dir"); + let bytes = damaged(dir.path(), sc_io::report::REPORT_FILE_NAME, |mut b| { + b.extend_from_slice(b"a,b\r\n"); + b + }); + refused_and_left(dir.path(), sc_io::report::REPORT_FILE_NAME, &bytes); +} + +#[test] +fn nothing_there_is_no_conflict() { + let dir = tempfile::tempdir().expect("temp dir"); + assert!(crate::export::artefact_conflict(dir.path()).is_none()); + let a = written_in(dir.path(), "a.wav"); + write_artefacts(dir.path(), &[a], XmlSelect::Batch, "p").expect("written"); + assert!(crate::export::artefact_conflict(dir.path()).is_none()); +} diff --git a/crates/sc-engine/src/export/job.rs b/crates/sc-engine/src/export/job.rs index c5ca565..11f324c 100644 --- a/crates/sc-engine/src/export/job.rs +++ b/crates/sc-engine/src/export/job.rs @@ -5,12 +5,12 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::{Mutex, PoisonError}; -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; use sc_core::Error; use sc_core::analysis::AnalysisSettings; use sc_core::export::{BatchMode, ExportPlan}; -use sc_core::ipc::{ExportArtefacts, IpcError, JobEvent, JobId}; +use sc_core::ipc::{ExportArtefacts, ExportSummary, ExportedWith, IpcError, JobEvent, JobId}; use sc_io::cache::Cache; use sc_io::txn::TxnReport; @@ -45,6 +45,8 @@ struct Rows<'a> { plans: BTreeMap>, reports: BTreeMap>, found: Vec>, + /// Files cancelled, which their rows count among the failed. + cancelled: u32, } impl<'a> Rows<'a> { @@ -60,6 +62,7 @@ impl<'a> Rows<'a> { plans: BTreeMap::new(), reports: BTreeMap::new(), found: files.iter().map(|_| None).collect(), + cancelled: 0, } } @@ -90,9 +93,12 @@ impl<'a> Rows<'a> { EngineEvent::Failed { file_id, error } => { self.set(*file_id, |file, mode| failed_row(file, Some(mode), error)); } - EngineEvent::Cancelled { file_id } => self.set(*file_id, |file, mode| { - failed_row(file, Some(mode), &Error::Cancelled) - }), + EngineEvent::Cancelled { file_id } => { + self.cancelled += 1; + self.set(*file_id, |file, mode| { + failed_row(file, Some(mode), &Error::Cancelled) + }); + } EngineEvent::Started { .. } | EngineEvent::Progress { .. } | EngineEvent::Analysed { .. } @@ -113,8 +119,30 @@ impl<'a> Rows<'a> { }); } - fn finish(self) -> Vec { - self.found.into_iter().flatten().collect() + fn finish(self) -> (Vec, u32) { + (self.found.into_iter().flatten().collect(), self.cancelled) + } +} + +/// What became of a batch, for a window that reloads. +fn summary( + job_id: JobId, + rows: &[BatchRow], + not_failed: u32, + was_cancelled: bool, + started: Instant, +) -> ExportSummary { + let count = |action| { + u32::try_from(rows.iter().filter(|r| r.action == action).count()).unwrap_or(u32::MAX) + }; + ExportSummary { + job_id, + written: count(RowAction::Written), + xml_only: count(RowAction::XmlOnly), + skipped: count(RowAction::Skipped), + failed: count(RowAction::Failed).saturating_sub(not_failed), + cancelled: was_cancelled, + seconds: started.elapsed().as_secs_f64(), } } @@ -134,6 +162,18 @@ pub fn run_export_job( send: &mut dyn FnMut(JobEvent), ) { let lock = || session.lock().unwrap_or_else(PoisonError::into_inner); + let started = Instant::now(); + let with = ExportedWith::new( + job.process.decide, + job.process.export, + job.process.out_dir.as_deref(), + ); + { + let mut session = lock(); + session.set_artefacts(None); + session.set_last_export(None); + session.begin_export(job_id, with); + } let files = lock().batch(file_ids); let settings = BatchSettings { analysis: job.analysis.clone(), @@ -163,16 +203,35 @@ pub fn run_export_job( } }); match result { - Ok(summary) => { - if let Some(event) = write_batch_artefacts(job_id, job, &rows.finish()) { + Ok(summary_of_batch) => { + let (rows, cancelled) = rows.finish(); + if let Some(event) = write_batch_artefacts(job_id, job, &rows) { + match &event { + JobEvent::Artefacts { artefacts, .. } => { + lock().set_artefacts(Some(Ok(artefacts.clone()))); + } + JobEvent::ArtefactsFailed { error, .. } => { + lock().set_artefacts(Some(Err(error.clone()))); + } + _ => {} + } send(event); } + lock().set_last_export(Some(summary( + job_id, + &rows, + cancelled, + summary_of_batch.was_cancelled, + started, + ))); + lock().end_export(job_id); send(JobEvent::Finished { job_id, - cancelled: summary.was_cancelled, + cancelled: summary_of_batch.was_cancelled, }); } Err(error) => { + lock().end_export(job_id); send(JobEvent::Aborted { job_id, error: IpcError::from(error), diff --git a/crates/sc-engine/src/export/mod.rs b/crates/sc-engine/src/export/mod.rs index 355c3b9..2f6b9b8 100644 --- a/crates/sc-engine/src/export/mod.rs +++ b/crates/sc-engine/src/export/mod.rs @@ -6,12 +6,14 @@ //! batch exports its files, one by one in `process` (the batch's `Task::Process`); //! [`grid_check`] compares each written file's grid with the exported one; [`artefacts`] //! turns what a batch did into its rekordbox XML and grid report, from the rows [`rows`] builds; -//! [`job`] runs a whole export for the app and [`wording`] says in words what a plan did. +//! [`job`] runs a whole export for the app, [`preview`] says what one would do before anything is +//! written, and [`wording`] says in words what a plan did. pub mod artefacts; pub mod grid_check; mod job; mod plan; +mod preview; pub(crate) mod process; pub(crate) mod row; mod rows; @@ -20,12 +22,14 @@ mod source; pub mod wording; pub use artefacts::{ - Artefacts, BatchRow, RowAction, RowTrack, XmlSelect, report_rows, write_artefacts, xml_tracks, + Artefacts, BatchRow, RowAction, RowTrack, XmlSelect, report_rows, tracks_listed_in, + write_artefacts, xml_tracks, }; pub use job::{ExportJob, export_artefacts, new_artefacts_folder, run_export_job}; pub use plan::{ ExportInput, ExportSource, SeratoPresence, plan_export, plan_snapped_cut, replaygain, }; +pub use preview::{PreviewInput, preview_row}; pub use process::{ProcessDone, ProcessSettings}; pub use rows::{ ArtefactConflict, XmlOutRefusal, analysed_row, artefact_conflict, failed_row, not_listed, diff --git a/crates/sc-engine/src/export/plan/tests.rs b/crates/sc-engine/src/export/plan/tests.rs index bc9b471..eae8fa1 100644 --- a/crates/sc-engine/src/export/plan/tests.rs +++ b/crates/sc-engine/src/export/plan/tests.rs @@ -110,7 +110,10 @@ fn written(outcome: ExportOutcome) -> ExportPlan { } fn prepare() -> ExportSettings { - ExportSettings::new(BatchMode::Prepare) + ExportSettings { + place: Place::InPlace, + ..ExportSettings::new(BatchMode::Prepare) + } } fn cut_of(rate: u32, anchor: u64) -> Cut { diff --git a/crates/sc-engine/src/export/preview.rs b/crates/sc-engine/src/export/preview.rs new file mode 100644 index 0000000..1a20fc9 --- /dev/null +++ b/crates/sc-engine/src/export/preview.rs @@ -0,0 +1,71 @@ +//! 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. + +use std::path::Path; + +use sc_core::Result; +use sc_core::analysis::AnalysisRecord; +use sc_core::export::{ExportOutcome, ExportSettings}; +use sc_core::ipc::ExportRow; +use sc_core::plan::DecideSettings; + +use super::plan::{ExportInput, ExportSource}; +use super::process::gate_folder; +use super::row; +use super::snap::plan_export_snapped; +use crate::decide::decide; +use crate::edits::EditState; + +/// One file to preview: its analysis with the grid as shown, and how it would be exported. +#[derive(Debug, Clone, Copy)] +pub struct PreviewInput<'a> { + /// The file. + pub path: &'a Path, + /// Its analysis with the grid as shown. + pub record: &'a AnalysisRecord, + /// The state of that grid. + pub edit: EditState, + /// The loudness settings. + pub decide: &'a DecideSettings, + /// The export settings. + pub settings: &'a ExportSettings, + /// The folder copies go to. + 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, +} + +/// 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. +/// +/// # 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)> { + let source = ExportSource::read(input.path); + let decided = decide( + input.record, + source.codec, + input.decide, + input.edit.confirmed, + ); + let planning = ExportInput { + record: input.record, + plan: &decided, + decide: input.decide, + source: &source, + 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)?; + Ok(( + match outcome { + ExportOutcome::Write { plan } => row::planned(&plan, input.revertible), + outcome => row::skipped(&outcome, input.revertible), + }, + replaces, + )) +} diff --git a/crates/sc-engine/src/export/process.rs b/crates/sc-engine/src/export/process.rs index 1044b3c..bec9c84 100644 --- a/crates/sc-engine/src/export/process.rs +++ b/crates/sc-engine/src/export/process.rs @@ -23,15 +23,18 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use std::time::Instant; +use std::collections::{BTreeMap, BTreeSet}; + use sc_core::analysis::{AnalysisRecord, AnalysisSettings, Grid}; use sc_core::export::{ - ExportOutcome, ExportPlan, ExportRecord, ExportSettings, ExportedGrid, GridCheck, + ExportOutcome, ExportPlan, ExportRecord, ExportSettings, ExportSkip, ExportedGrid, GridCheck, GridCheckSkip, Place as ExportPlace, SourceMeasurements, XmlGrid, XmlTrackInfo, }; use sc_core::plan::{DecideSettings, Plan}; -use sc_core::{Error, Result, Seconds}; +use sc_core::{Bpm, Error, Result, Seconds}; use sc_io::cache::Cache; use sc_io::edits::{EditStore, SavedEdit}; +use sc_io::txn::{CopyAt, copy_at}; use super::grid_check::{Choices, Exported, check_written, detector_grid, with_detector}; use super::plan::{Bars, ExportInput, ExportSource, bar1_after, grid_trusted}; @@ -58,6 +61,12 @@ pub struct ProcessSettings { pub edits: Option, /// The start-up recovery: no file is analysed or written while it runs. pub recovery: std::sync::Arc, + /// 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. + pub same_name: BTreeSet, } impl ProcessSettings { @@ -93,6 +102,7 @@ impl ProcessSettings { keep_mtime: self.export.batch_mode == sc_core::export::BatchMode::Library, sidecar: true, cache, + replace_own_copy: true, } } } @@ -110,6 +120,8 @@ pub struct ProcessDone { /// The output's own analysis, now in the cache, with the carried grid edit applied; `None` /// when it did not finish (see `notes`). pub analysis: Option>, + /// What the output was before that analysis read it, and the BPM range it used. + pub analysed: Option<(String, (Bpm, Bpm))>, /// What the carried grid edit does to the output's grid. pub edit: EditState, /// The user's grid edit was carried over to the output. @@ -143,6 +155,8 @@ pub(crate) fn process_file( if source.codec.is_writable() { source.blake3 = Some(sc_io::txn::hash_file(path)?.1); } + // After the hash: what was hashed is what was confirmed, and from here on the hash guards it. + check_confirmed(file_id, path, settings)?; let mut record = analyzer .analyze_with(path, &mut Timings::default(), progress)? .record; @@ -163,7 +177,7 @@ pub(crate) fn process_file( source: &source, grid_confirmed: edit.confirmed, }; - let plan = match plan_export_snapped(path, &input, &settings.export)? { + let plan = match planned_outcome(file_id, path, &input, settings)? { ExportOutcome::Write { plan } => plan, outcome => { return Ok(EngineEvent::ExportSkipped { @@ -241,6 +255,65 @@ pub(crate) fn process_file( }) } +/// What the plan says of the file, given what is at the path its copy would take. +fn planned_outcome( + file_id: u32, + path: &Path, + input: &ExportInput<'_>, + settings: &ProcessSettings, +) -> Result { + let outcome = plan_export_snapped(path, input, &settings.export)?; + let same_name = settings.same_name.contains(&file_id); + Ok(gate_folder(outcome, path, settings.out_dir.as_deref(), same_name)?.0) +} + +/// Refuses a file that is no longer the one the user confirmed. +/// +/// # Errors +/// [`Error::FileChanged`]; [`Error::Io`] when it cannot be read. +fn check_confirmed(file_id: u32, path: &Path, settings: &ProcessSettings) -> Result<()> { + let Some(confirmed) = &settings.confirmed else { + return Ok(()); + }; + if confirmed.get(&file_id).map(String::as_str) == Some(&*sc_io::txn::file_identity(path)?) { + return Ok(()); + } + Err(Error::FileChanged { + path: path.to_path_buf(), + detail: "since you confirmed the export; it was left as it is".to_owned(), + cause: sc_core::ChangeCause::SincePlanned, + }) +} + +/// `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 +/// copy keeps the file from being written. Also says whether the write replaces that copy. +/// +/// # Errors +/// [`Error::Io`] when that file cannot be read to tell. +pub(super) fn gate_folder( + outcome: ExportOutcome, + path: &Path, + out_dir: Option<&Path>, + same_name: bool, +) -> Result<(ExportOutcome, bool)> { + let (ExportOutcome::Write { .. }, Some(dir), Some(name)) = + (&outcome, out_dir, path.file_name()) + else { + return Ok((outcome, false)); + }; + let skip = |reason| (ExportOutcome::Skip { reason }, false); + if same_name { + return Ok(skip(ExportSkip::CopyOfOtherTrack)); + } + Ok(match copy_at(&dir.join(name), path)? { + CopyAt::Nothing => (outcome, false), + CopyAt::Own => (outcome, true), + CopyAt::OtherTrack => skip(ExportSkip::CopyOfOtherTrack), + CopyAt::Foreign => skip(ExportSkip::OtherFileInFolder), + }) +} + /// [`after_write`], with a panic there turned into a note: the file is written, so it is done. fn after_write_guarded( analyzer: &mut Analyzer, @@ -257,6 +330,7 @@ fn after_write_guarded( duration, xml: xml_info_absent(), analysis: None, + analysed: None, edit: EditState::default(), edit_carried: false, grid_check: not_analysed(), @@ -398,9 +472,11 @@ fn after_write( _ => false, }; let started = Instant::now(); + let mut analysed = None; let (analysis, edit, grid_check) = match analyzer.analyze_fresh(output, &mut Timings::default(), None) { Ok(report) => { + analysed = report.identity.clone().map(|id| (id, report.bpm_range)); let mut record = report.record; // The detector's own grid, before the carried edit pins it. let check = check_written(&record, &carry.exported); @@ -453,6 +529,7 @@ fn after_write( // Set by the caller, which knows the plan. xml: xml_info_absent(), analysis, + analysed, edit, edit_carried, grid_check, diff --git a/crates/sc-engine/src/export/row.rs b/crates/sc-engine/src/export/row.rs index 524041e..7bbc372 100644 --- a/crates/sc-engine/src/export/row.rs +++ b/crates/sc-engine/src/export/row.rs @@ -10,6 +10,15 @@ 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}; + +/// What the plan says the user should know, in words, for the row's notes. +fn plan_notes(plan: &ExportPlan) -> Vec { + let notices = plan.notices.iter().map(|n| notice_text(*n)); + notices + .chain(plan.grid_withheld.then(|| GRID_WITHHELD.to_owned())) + .collect() +} /// A file planned, its write starting; `revertible`: an earlier change of it is in effect. pub(crate) fn planned(plan: &ExportPlan, revertible: bool) -> Box { @@ -26,19 +35,26 @@ pub(crate) fn planned(plan: &ExportPlan, revertible: bool) -> Box { grid_check: None, edit_carried: false, revertible, - notes: Vec::new(), + exported_with: None, + notes: bounded_strings(plan_notes(plan), MAX_ROW_NOTES_JSON_BYTES), }) } -/// A file not written; `revertible`: an earlier change of it is in effect. +/// A file not written, with why in words; `revertible`: an earlier change of it is in effect. pub(crate) fn skipped(outcome: &ExportOutcome, revertible: bool) -> Box { - let (action, notices, grid_withheld) = match outcome { - ExportOutcome::XmlOnly { reason } => { - (ExportAction::XmlOnly { reason: *reason }, Vec::new(), false) - } - ExportOutcome::Skip { reason } => { - (ExportAction::Skip { reason: *reason }, Vec::new(), false) - } + let (action, notices, grid_withheld, why) = match outcome { + ExportOutcome::XmlOnly { reason } => ( + ExportAction::XmlOnly { reason: *reason }, + Vec::new(), + false, + vec![xml_only_text(*reason)], + ), + ExportOutcome::Skip { reason } => ( + ExportAction::Skip { reason: *reason }, + Vec::new(), + false, + vec![skip_why(*reason)], + ), // A batch reports a written file through its other events. ExportOutcome::Write { plan } => ( ExportAction::Write { @@ -47,6 +63,7 @@ pub(crate) fn skipped(outcome: &ExportOutcome, revertible: bool) -> Box Box, report: &TxnReport) -> Box verification: Some(verification(&report.render)), revertible: row.revertible || report.kind == TxnKind::InPlace, notes: bounded_strings( - not_added.into_iter().chain(report.notes.iter().cloned()), + row.notes + .iter() + .cloned() + .chain(not_added) + .chain(report.notes.iter().cloned()), MAX_ROW_NOTES_JSON_BYTES, ), ..*row diff --git a/crates/sc-engine/src/export/row/tests.rs b/crates/sc-engine/src/export/row/tests.rs index d861b4b..cec56ef 100644 --- a/crates/sc-engine/src/export/row/tests.rs +++ b/crates/sc-engine/src/export/row/tests.rs @@ -89,6 +89,7 @@ fn process_done(notes: Vec) -> ProcessDone { artist: None, }, analysis: None, + analysed: None, edit: EditState::default(), edit_carried: false, grid_check: GridCheck::NotChecked { @@ -197,3 +198,57 @@ fn the_row_tells_the_grid_check_outcomes_apart_by_type_alone() { assert_eq!(reported["result"], "offBy"); assert_eq!(reported["offsetMs"], 7.2); } + +#[test] +fn a_skipped_row_says_why_in_the_same_words_as_the_report() { + use crate::export::wording::{skip_why, xml_only_text}; + use sc_core::export::XmlOnlyReason; + use sc_core::plan::Codec; + + let reason = ExportSkip::NotDjSafeRate { + sample_rate_hz: 96_000, + }; + let row = skipped(&ExportOutcome::Skip { reason }, false); + assert_eq!(row.notes, [skip_why(reason)]); + + let reason = XmlOnlyReason::Mp3OrAac { codec: Codec::Mp3 }; + let row = skipped(&ExportOutcome::XmlOnly { reason }, false); + assert_eq!(row.notes, [xml_only_text(reason)]); +} + +#[test] +fn what_the_plan_says_is_worded_and_kept_through_every_step() { + use crate::export::wording::{GRID_WITHHELD, notice_text}; + + let plan = ExportPlan { + grid_withheld: true, + ..plan() + }; + let notice = notice_text(plan.notices[0]); + let row = planned(&plan, false); + assert_eq!(row.notes, [notice.clone(), GRID_WITHHELD.to_owned()]); + + let row = written( + row, + &report(TxnKind::InPlace, vec!["a note".to_owned()], Vec::new()), + ); + assert_eq!( + row.notes, + [ + notice.clone(), + GRID_WITHHELD.to_owned(), + "a note".to_owned() + ] + ); + + let row = done(row, &process_done(vec!["after".to_owned()])); + assert_eq!( + row.notes, + [ + notice, + GRID_WITHHELD.to_owned(), + "a note".to_owned(), + "after".to_owned() + ] + ); +} diff --git a/crates/sc-engine/src/export/rows.rs b/crates/sc-engine/src/export/rows.rs index fdc50fe..8b7e9e8 100644 --- a/crates/sc-engine/src/export/rows.rs +++ b/crates/sc-engine/src/export/rows.rs @@ -14,6 +14,7 @@ use sc_io::txn::sidecar::{self, SidecarDoc}; use sc_io::txn::{TxnReport, hash_file, hex}; use super::ProcessDone; +use super::artefacts::merge::{listed_in, reported_in}; use super::artefacts::{BatchRow, XmlSelect, is_listed, report_rows}; use super::wording::{GRID_WITHHELD, UNVERIFIED, notice_text, skip_why, xml_only_text}; use crate::analyze::AnalyzeReport; @@ -156,7 +157,8 @@ pub struct ArtefactConflict { } /// The first artefact name in the folder `dir` that a batch could not write, because a file -/// SoundCheck did not write is there. Asked before anything is exported. +/// SoundCheck did not write is there, or one of its own that cannot be read whole, whose entries a +/// new one could not keep. Asked before anything is exported. #[must_use] pub fn artefact_conflict(dir: &Path) -> Option { use sc_io::artefacts::check_replaceable; @@ -173,7 +175,13 @@ pub fn artefact_conflict(dir: &Path) -> Option { ]; checks.into_iter().find_map(|(name, is_ours)| { let path = dir.join(name); - let checked: Result<()> = check_replaceable(&path, is_ours); + let checked: Result<()> = check_replaceable(&path, is_ours).and_then(|()| { + if name == sc_io::rekordbox::XML_FILE_NAME { + listed_in(dir).map(|_| ()) + } else { + reported_in(dir).map(|_| ()) + } + }); checked .err() .map(|error| ArtefactConflict { path, name, error }) diff --git a/crates/sc-engine/src/export/wording.rs b/crates/sc-engine/src/export/wording.rs index a9cb5dc..d99a785 100644 --- a/crates/sc-engine/src/export/wording.rs +++ b/crates/sc-engine/src/export/wording.rs @@ -102,6 +102,14 @@ pub fn skip_why(reason: ExportSkip) -> String { grid)", codec.label() ), + ExportSkip::OtherFileInFolder => { + "a file of this name is already in the folder, and it is not a copy SoundCheck wrote \ + (or it changed since), so it is left as it is" + .to_owned() + } + ExportSkip::CopyOfOtherTrack => { + "a copy of another track already has 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 \ else to write" diff --git a/crates/sc-engine/src/lib.rs b/crates/sc-engine/src/lib.rs index 55cde11..86c0ffb 100644 --- a/crates/sc-engine/src/lib.rs +++ b/crates/sc-engine/src/lib.rs @@ -36,7 +36,7 @@ pub use export::{ RowAction, RowTrack, SeratoPresence, XmlSelect, plan_export, plan_export_snapped, plan_snapped_cut, run_export_job, write_artefacts, }; -pub use session::{Session, run_job}; +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, diff --git a/crates/sc-engine/src/session.rs b/crates/sc-engine/src/session.rs index d613f6b..679c6aa 100644 --- a/crates/sc-engine/src/session.rs +++ b/crates/sc-engine/src/session.rs @@ -7,27 +7,27 @@ //! analysed grid before the evidence is dropped, and a confirmed grid needs no review. use std::collections::{BTreeMap, HashMap}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::{Mutex, PoisonError}; -use std::time::Duration; use sc_core::analysis::{AnalysisRecord, Grid}; +use sc_core::export::SkipCure; use sc_core::ipc::{ - ExportRow, FileEntry, FileInfo, IpcError, JobEvent, JobId, Replan, RowAnalysis, RowPlan, - RowUpdate, SessionRow, SessionSnapshot, + ExportAction, ExportArtefacts, ExportRow, ExportSummary, ExportedWith, FileEntry, FileInfo, + IpcError, JobEvent, JobId, Replan, RowAnalysis, RowPlan, RowUpdate, SessionRow, + SessionSnapshot, }; use sc_core::plan::{DecideSettings, GainPlan, Plan}; -use sc_core::{Lufs, Result}; +use sc_core::{Bpm, Lufs, Result}; use sc_io::edits::EditStore; use unicode_normalization::UnicodeNormalization; -use crate::analyze::CacheStatus; use crate::batch::{BatchFile, BatchSettings, EngineEvent, Task, run_batch}; use crate::cancel::CancelToken; use crate::decide::decide; use crate::edits::{EditState, apply_saved}; -use crate::export::ProcessDone; -use crate::export::row as export_row; + +mod events; /// One file the user added. #[derive(Debug, Clone)] @@ -41,6 +41,14 @@ struct Tracked { export_running: Option>, /// A change of the file made in place is in effect: `revert_file` can put it back. revertible: bool, + /// A grid different from what the standing export wrote was saved since. + export_stale: bool, + /// The copy the standing export wrote to a folder. + copy: Option, + /// What the file was when `record`'s analysis read it (`sc_io::txn::file_identity`). + identity: Option, + /// The BPM range `record` was analysed with. + analysed_range: Option<(Bpm, Bpm)>, } impl Tracked { @@ -65,6 +73,7 @@ impl Tracked { row: Box::new(self.row(false)?), plan: self.plan(settings)?, revision, + export_stale: self.export_stale, }) } } @@ -78,6 +87,32 @@ fn row_of(record: &AnalysisRecord, edit: EditState, cached: bool) -> RowAnalysis } } +/// A reverted file's own analysis. +#[derive(Debug, Clone)] +pub struct RevertedAnalysis { + /// The analysis, with the saved edit applied. + pub record: AnalysisRecord, + /// The state of that edit. + pub edit: EditState, + /// What the file was before the analysis read it. + pub identity: Option, + /// The BPM range it used. + pub bpm_range: (Bpm, Bpm), +} + +/// What planning one file's export takes. +#[derive(Debug, Clone)] +pub struct PlanningRow { + /// Its analysis with the grid as shown. + pub record: AnalysisRecord, + /// The state of that grid. + pub edit: EditState, + /// An earlier change of it is in effect. + pub revertible: bool, + /// What the file was when that analysis was made. + pub identity: Option, +} + /// Files, analyses and settings of one app session. #[derive(Debug)] pub struct Session { @@ -91,6 +126,13 @@ pub struct Session { revision: u32, /// Where the user's grid edits are saved; `None` applies none. edits: Option, + /// The latest batch's rekordbox XML and grid report, or why they could not be written, for a + /// window that reloads and never saw the event. + artefacts: Option>, + /// What became of the latest batch. + last_export: Option, + /// What each running export is made with. + export_jobs: BTreeMap, } impl Session { @@ -105,6 +147,9 @@ impl Session { settings, revision: 0, edits: None, + artefacts: None, + last_export: None, + export_jobs: BTreeMap::new(), } } @@ -141,6 +186,10 @@ impl Session { export: None, export_running: None, revertible: false, + export_stale: false, + copy: None, + identity: None, + analysed_range: None, }, ); added.push(entry); @@ -153,6 +202,56 @@ impl Session { pub fn clear(&mut self) { self.files.clear(); self.by_path.clear(); + self.artefacts = None; + self.last_export = None; + } + + /// What became of the latest batch; `None` while one runs, and when it could not start. + pub fn set_last_export(&mut self, summary: Option) { + self.last_export = summary; + } + + /// An export starts as job `job_id`, made with `with`: its rows say so. + pub fn begin_export(&mut self, job_id: JobId, with: ExportedWith) { + self.export_jobs.insert(job_id, with); + } + + /// The export `job_id` has ended. + pub fn end_export(&mut self, job_id: JobId) { + self.export_jobs.remove(&job_id); + } + + /// The settings revision now. + #[must_use] + pub fn revision(&self) -> u32 { + self.revision + } + + /// The copy the file's standing export wrote to a folder. + #[must_use] + pub fn exported_copy(&self, file_id: u32) -> Option { + self.files.get(&file_id)?.copy.clone() + } + + /// The latest batch's artefacts, or why they could not be written; `None` for a batch that + /// has none (yet). + pub fn set_artefacts( + &mut self, + artefacts: Option>, + ) { + self.artefacts = artefacts; + } + + /// What planning the file's export takes. `None` until it is analysed. + #[must_use] + pub fn planning_row(&self, file_id: u32) -> Option { + let t = self.files.get(&file_id)?; + Some(PlanningRow { + record: t.record.clone()?, + edit: t.edit, + revertible: t.revertible, + identity: t.identity.clone(), + }) } /// Files in the session. @@ -233,8 +332,22 @@ impl Session { plan: t.plan(&self.settings), export: t.export_running.clone().or_else(|| t.export.clone()), revertible: t.revertible, + export_stale: t.export_stale, + analysed_range: t.analysed_range, }) .collect(), + running_jobs: Vec::new(), + last_export: self.last_export.clone(), + artefacts: self + .artefacts + .as_ref() + .and_then(|a| a.as_ref().ok()) + .cloned(), + artefacts_failed: self + .artefacts + .as_ref() + .and_then(|a| a.as_ref().err()) + .cloned(), } } @@ -265,6 +378,17 @@ impl Session { ) -> Option { let t = self.files.get_mut(&file_id)?; let record = t.record.as_mut()?; + // What the export wrote no longer holds: another grid, or one withheld now confirmed. + let released = t.export.as_ref().is_some_and(|e| e.grid_withheld) && edit.confirmed; + let skip = t.export.as_ref().and_then(|e| match e.action { + ExportAction::Skip { reason } => Some(reason), + _ => None, + }); + let about_file = skip.is_some_and(|reason| reason.cure() == SkipCure::File); + let flipped = skip.is_some() && edit.confirmed != t.edit.confirmed; + if t.export.is_some() && !about_file && (record.grid != grid || released || flipped) { + t.export_stale = true; + } if grid.is_some() { record.grid_skipped = None; } @@ -277,6 +401,7 @@ impl Session { row, plan, revision: self.revision, + export_stale: t.export_stale, }) } @@ -288,14 +413,14 @@ impl Session { } /// The file `file_id` is its original again (a revert): `info` is what its headers say and - /// `analysis` its own analysis with its saved edit applied, or `None` when it could not be - /// analysed. No export of it stands any more; with `earlier_changes` still in effect, another + /// `analysis` its own analysis with its saved edit applied, what the file was before that read + /// it and the BPM range it used, or `None` when it could not be analysed. No export of it stands any more; with `earlier_changes` still in effect, another /// revert can go further back. Returns the row and its plan, when analysed. pub fn set_reverted( &mut self, file_id: u32, info: FileInfo, - analysis: Option<(AnalysisRecord, EditState)>, + analysis: Option, earlier_changes: usize, ) -> Option { let revision = self.revision; @@ -305,12 +430,16 @@ impl Session { t.export = None; t.export_running = None; t.revertible = earlier_changes > 0; - let (record, edit) = analysis.unzip(); - t.record = record.map(|mut record| { - record.evidence = None; - record + t.export_stale = false; + t.copy = None; + let analysed = analysis.map(|mut a| { + a.record.evidence = None; + a }); - t.edit = edit.unwrap_or_default(); + t.identity = analysed.as_ref().and_then(|a| a.identity.clone()); + t.analysed_range = analysed.as_ref().map(|a| a.bpm_range); + t.edit = analysed.as_ref().map(|a| a.edit).unwrap_or_default(); + t.record = analysed.map(|a| a.record); t.update(file_id, &settings, revision) } @@ -341,163 +470,6 @@ impl Session { }; Some(Lufs(median)) } - - /// Turns an engine event of a job into what the UI receives, storing analyses and export - /// rows on the way; the evidence of an analysis stays out of both. `None` for an export event - /// of a file no longer in the session (the list was cleared meanwhile). - pub fn on_engine_event(&mut self, job_id: JobId, event: EngineEvent) -> Option { - Some(match event { - EngineEvent::Started { file_id } => JobEvent::Started { job_id, file_id }, - EngineEvent::Progress { file_id, fraction } => JobEvent::Progress { - job_id, - file_id, - fraction, - }, - EngineEvent::Analysed { - file_id, - report, - edit, - } => { - 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; - } - JobEvent::Analysed { - job_id, - file_id, - row, - plan, - revision: self.revision, - } - } - EngineEvent::Failed { file_id, error } => { - self.end_running_export(file_id); - failed_event(job_id, file_id, error) - } - EngineEvent::Cancelled { file_id } => { - self.end_running_export(file_id); - JobEvent::Cancelled { job_id, file_id } - } - EngineEvent::Batch(p) => JobEvent::Batch { - job_id, - done: saturating_u32(p.done), - total: saturating_u32(p.total), - eta_ms: p.eta.map(millis), - // Display precision. - #[allow(clippy::cast_possible_truncation)] - realtime_x: p.realtime_x.map(|x| x as f32), - }, - EngineEvent::Processing { file_id, plan } => { - let t = self.files.get_mut(&file_id)?; - let row = export_row::planned(&plan, t.revertible); - t.export_running = Some(row.clone()); - JobEvent::Processing { - job_id, - file_id, - row, - } - } - EngineEvent::Written { file_id, report } => { - let t = self.files.get_mut(&file_id)?; - let Some(row) = t.export_running.take() else { - return Some(lost_report(job_id, file_id)); - }; - let row = export_row::written(row, &report); - t.revertible = row.revertible; - t.export_running = Some(row.clone()); - JobEvent::Written { - job_id, - file_id, - row, - } - } - EngineEvent::ExportSkipped { - file_id, outcome, .. - } => { - let t = self.files.get_mut(&file_id)?; - let row = export_row::skipped(&outcome, t.revertible); - t.export_running = None; - t.export = Some(row.clone()); - JobEvent::ExportSkipped { - job_id, - file_id, - row, - } - } - EngineEvent::Done { file_id, done } => return self.on_done(job_id, file_id, *done), - }) - } - - /// 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) { - t.export_running = None; - } - } - - /// A file is written and checked. Written in place, the row now is the written file: its own - /// analysis (none when that did not finish: what was measured before no longer holds). - fn on_done(&mut self, job_id: JobId, file_id: u32, mut done: ProcessDone) -> Option { - let revision = self.revision; - let settings = self.settings; - let t = self.files.get_mut(&file_id)?; - let Some(row) = t.export_running.take() else { - return Some(lost_report(job_id, file_id)); - }; - let row = export_row::done(row, &done); - t.export = Some(row.clone()); - let in_place = done.output == Path::new(&t.entry.path); - let update = if in_place { - t.record = done.analysis.take().map(|mut record| { - record.evidence = None; - *record - }); - t.edit = if t.record.is_some() { - done.edit - } else { - EditState::default() - }; - t.update(file_id, &settings, revision).map(Box::new) - } else { - None - }; - Some(JobEvent::ExportDone { - job_id, - file_id, - row, - in_place, - update, - }) - } -} - -/// The failed event of `file_id`. -fn failed_event(job_id: JobId, file_id: u32, error: sc_core::Error) -> JobEvent { - JobEvent::Failed { - job_id, - file_id, - error: IpcError { - file_id: Some(file_id), - ..IpcError::from(error) - }, - } -} - -/// An export event that came without the ones before it, which a batch never sends. -fn lost_report(job_id: JobId, file_id: u32) -> JobEvent { - failed_event( - job_id, - file_id, - sc_core::Error::Internal("a written file lost its report".into()), - ) } /// Runs one analysis job over `file_ids` (whatever `settings.task` says; an export runs through @@ -551,11 +523,3 @@ pub fn run_job( } } } - -fn saturating_u32(n: usize) -> u32 { - u32::try_from(n).unwrap_or(u32::MAX) -} - -fn millis(d: Duration) -> u32 { - u32::try_from(d.as_millis()).unwrap_or(u32::MAX) -} diff --git a/crates/sc-engine/src/session/events.rs b/crates/sc-engine/src/session/events.rs new file mode 100644 index 0000000..2db0105 --- /dev/null +++ b/crates/sc-engine/src/session/events.rs @@ -0,0 +1,199 @@ +//! What an engine event of a job does to the session, and what the UI receives for it. + +use std::path::Path; +use std::time::Duration; + +use sc_core::ipc::{IpcError, JobEvent, JobId}; + +use super::{Session, row_of}; +use crate::analyze::CacheStatus; +use crate::batch::EngineEvent; +use crate::decide::decide; +use crate::edits::EditState; +use crate::export::ProcessDone; +use crate::export::row as export_row; + +impl Session { + /// Turns an engine event of a job into what the UI receives, storing analyses and export + /// rows on the way; the evidence of an analysis stays out of both. `None` for an export event + /// of a file no longer in the session (the list was cleared meanwhile). + pub fn on_engine_event(&mut self, job_id: JobId, event: EngineEvent) -> Option { + Some(match event { + EngineEvent::Started { file_id } => JobEvent::Started { job_id, file_id }, + EngineEvent::Progress { file_id, fraction } => JobEvent::Progress { + job_id, + file_id, + fraction, + }, + EngineEvent::Analysed { + 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, + } + } + EngineEvent::Failed { file_id, error } => { + self.end_running_export(file_id); + failed_event(job_id, file_id, error) + } + EngineEvent::Cancelled { file_id } => { + self.end_running_export(file_id); + JobEvent::Cancelled { job_id, file_id } + } + EngineEvent::Batch(p) => JobEvent::Batch { + job_id, + done: saturating_u32(p.done), + total: saturating_u32(p.total), + eta_ms: p.eta.map(millis), + // Display precision. + #[allow(clippy::cast_possible_truncation)] + realtime_x: p.realtime_x.map(|x| x as f32), + }, + EngineEvent::Processing { file_id, plan } => { + let with = self.export_jobs.get(&job_id).cloned(); + let t = self.files.get_mut(&file_id)?; + let mut row = export_row::planned(&plan, t.revertible); + row.exported_with = with; + t.export_running = Some(row.clone()); + JobEvent::Processing { + job_id, + file_id, + row, + } + } + EngineEvent::Written { file_id, report } => { + let t = self.files.get_mut(&file_id)?; + let Some(row) = t.export_running.take() else { + return Some(lost_report(job_id, file_id)); + }; + let row = export_row::written(row, &report); + t.revertible = row.revertible; + t.export_running = Some(row.clone()); + JobEvent::Written { + job_id, + file_id, + row, + } + } + EngineEvent::ExportSkipped { + file_id, outcome, .. + } => { + let with = self.export_jobs.get(&job_id).cloned(); + let t = self.files.get_mut(&file_id)?; + let mut row = export_row::skipped(&outcome, t.revertible); + row.exported_with = with; + t.export_running = None; + t.export = Some(row.clone()); + t.export_stale = false; + JobEvent::ExportSkipped { + job_id, + file_id, + row, + } + } + EngineEvent::Done { file_id, done } => return self.on_done(job_id, file_id, *done), + }) + } + + /// 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) { + t.export_running = None; + } + } + + /// A file is written and checked. Written in place, the row now is the written file: its own + /// analysis (none when that did not finish: what was measured before no longer holds). + fn on_done(&mut self, job_id: JobId, file_id: u32, mut done: ProcessDone) -> Option { + let revision = self.revision; + let settings = self.settings; + let t = self.files.get_mut(&file_id)?; + let Some(row) = t.export_running.take() else { + return Some(lost_report(job_id, file_id)); + }; + let row = export_row::done(row, &done); + t.export = Some(row.clone()); + t.export_stale = false; + let in_place = done.output == Path::new(&t.entry.path); + t.copy = (!in_place).then(|| done.output.clone()); + let update = if in_place { + let (identity, bpm_range) = done.analysed.take().unzip(); + t.identity = identity; + t.analysed_range = bpm_range; + t.record = done.analysis.take().map(|mut record| { + record.evidence = None; + *record + }); + t.edit = if t.record.is_some() { + done.edit + } else { + EditState::default() + }; + t.update(file_id, &settings, revision).map(Box::new) + } else { + None + }; + Some(JobEvent::ExportDone { + job_id, + file_id, + row, + in_place, + update, + }) + } +} + +/// The failed event of `file_id`. +fn failed_event(job_id: JobId, file_id: u32, error: sc_core::Error) -> JobEvent { + JobEvent::Failed { + job_id, + file_id, + error: IpcError { + file_id: Some(file_id), + ..IpcError::from(error) + }, + } +} + +/// An export event that came without the ones before it, which a batch never sends. +fn lost_report(job_id: JobId, file_id: u32) -> JobEvent { + failed_event( + job_id, + file_id, + sc_core::Error::Internal("a written file lost its report".into()), + ) +} + +fn saturating_u32(n: usize) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} + +fn millis(d: Duration) -> u32 { + u32::try_from(d.as_millis()).unwrap_or(u32::MAX) +} diff --git a/crates/sc-engine/src/txn.rs b/crates/sc-engine/src/txn.rs index 5c2459b..a8358c5 100644 --- a/crates/sc-engine/src/txn.rs +++ b/crates/sc-engine/src/txn.rs @@ -58,7 +58,7 @@ pub enum Place { /// Replaces the original, after backing it up into the backup root. InPlace, /// A copy with the same name in this folder (created if needed); the original is untouched - /// and an existing file is never replaced. + /// and an existing file is replaced only as [`ApplyOptions::replace_own_copy`] says. Folder(PathBuf), } @@ -77,6 +77,9 @@ pub struct ApplyOptions { /// The analysis cache whose entry for the file written is removed before and after the /// change (see [`forget_cached`]); `None` touches no cache. pub cache: Option, + /// A copy may replace an earlier copy SoundCheck wrote there, proven by its sidecar + /// (`sc_io::txn::copy_at`); any other file of that name is refused either way. + pub replace_own_copy: bool, } impl ApplyOptions { @@ -89,6 +92,7 @@ impl ApplyOptions { keep_mtime: true, sidecar: true, cache: None, + replace_own_copy: false, } } } @@ -151,6 +155,7 @@ pub fn apply_file( sidecar: opts.sidecar, export: req.export.clone(), expect_original_blake3: req.source_blake3, + replace_own_copy: opts.replace_own_copy, }; let flag = cancel.flag(); let mut report = match &opts.place { diff --git a/crates/sc-engine/src/txn/inputs.rs b/crates/sc-engine/src/txn/inputs.rs index 1c68afa..ad9b0ed 100644 --- a/crates/sc-engine/src/txn/inputs.rs +++ b/crates/sc-engine/src/txn/inputs.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; use std::path::PathBuf; use sc_core::Error; -use sc_io::txn::resolve_file; +use sc_io::txn::{folder_folds_case, resolve_file}; use unicode_normalization::UnicodeNormalization; use super::Place; @@ -21,6 +21,7 @@ use super::Place; pub fn check_inputs(files: &[PathBuf], place: &Place) -> Vec> { let mut seen: BTreeMap = BTreeMap::new(); let mut names: BTreeMap = BTreeMap::new(); + let folds = matches!(place, Place::Folder(dir) if folder_folds_case(dir)); files .iter() .enumerate() @@ -37,11 +38,7 @@ pub fn check_inputs(files: &[PathBuf], place: &Place) -> Vec> { return None; }; let name = path.file_name()?; - let key: String = name - .to_string_lossy() - .nfc() - .collect::() - .to_lowercase(); + let key = name_key(&name.to_string_lossy(), folds); if let Some(&other) = names.get(&key) { return Some(Error::SameOutputName { path: path.clone(), @@ -55,5 +52,13 @@ pub fn check_inputs(files: &[PathBuf], place: &Place) -> Vec> { .collect() } +/// 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. +fn name_key(name: &str, folds: bool) -> String { + let nfc: String = name.nfc().collect(); + if folds { nfc.to_lowercase() } else { nfc } +} + #[cfg(test)] mod tests; diff --git a/crates/sc-engine/src/txn/inputs/tests.rs b/crates/sc-engine/src/txn/inputs/tests.rs index 707737b..ff9cf2e 100644 --- a/crates/sc-engine/src/txn/inputs/tests.rs +++ b/crates/sc-engine/src/txn/inputs/tests.rs @@ -66,6 +66,11 @@ fn copies_into_one_folder_may_not_share_a_name() { let out = base.join("out"); let files = vec![a.clone(), b.clone(), c, a.clone()]; let found = check_inputs(&files, &Place::Folder(out.clone())); + // As the volume compares names: `Track.wav` and `TRACK.wav` collide where it folds case. + if !sc_io::txn::folder_folds_case(&out) { + assert_eq!(kinds(&found), vec!["ok", "ok", "ok", "twice"]); + return; + } assert_eq!(kinds(&found), vec!["ok", "same name", "ok", "twice"]); match &found[1] { Some(Error::SameOutputName { @@ -84,3 +89,20 @@ fn copies_into_one_folder_may_not_share_a_name() { vec!["ok", "ok", "ok"] ); } + +#[test] +fn a_name_is_compared_as_the_volume_compares_it() { + assert_eq!( + super::name_key("Track.wav", true), + super::name_key("track.wav", true) + ); + assert_ne!( + super::name_key("Track.wav", false), + super::name_key("track.wav", false) + ); + // Two Unicode forms of one name are one name either way. + assert_eq!( + super::name_key("Şarkı.wav", false), + super::name_key("S\u{327}arkı.wav", false) + ); +} diff --git a/crates/sc-engine/tests/common/export.rs b/crates/sc-engine/tests/common/export.rs index 4925532..25062ba 100644 --- a/crates/sc-engine/tests/common/export.rs +++ b/crates/sc-engine/tests/common/export.rs @@ -72,6 +72,8 @@ pub fn process(lib: &Lib, mode: BatchMode, out: Option) -> ProcessSetti backup_root: lib.backups(), edits: Some(lib.edits()), recovery: Arc::new(RecoveryGate::new(finished())), + confirmed: None, + same_name: std::collections::BTreeSet::new(), } } diff --git a/crates/sc-engine/tests/process.rs b/crates/sc-engine/tests/process.rs index 7cd0ef8..b297fd3 100644 --- a/crates/sc-engine/tests/process.rs +++ b/crates/sc-engine/tests/process.rs @@ -386,3 +386,149 @@ fn a_file_that_changes_during_its_analysis_is_refused_not_written_from_a_stale_p "the plan was made from other bytes: {error}" ); } + +/// What became of `wav` in one export to `out` (in place without one), at `target` LUFS. +fn exported(lib: &Lib, wav: &std::path::Path, out: Option, target: f64) -> &'static str { + let mut p = process(lib, BatchMode::Prepare, out); + p.decide.target = sc_core::Lufs(target); + let s = settings(lib, common::loudness_only(), p); + let events = run(&batch(&[wav.to_path_buf()]), &s, &CancelToken::new()); + events + .iter() + .find_map(|e| match e { + EngineEvent::Done { .. } => Some("done"), + EngineEvent::ExportSkipped { .. } => Some("skipped"), + EngineEvent::Failed { .. } => Some("failed"), + _ => None, + }) + .unwrap_or("none") +} + +#[test] +fn an_earlier_copy_is_replaced_and_the_source_left_alone() { + let lib = Lib::new(); + let wav = common::tone_wav(&lib.music(), "a.wav", 3.0, 0.1); + let source = std::fs::read(&wav).unwrap(); + let out = lib.dir.path().join("copies"); + + assert_eq!(exported(&lib, &wav, Some(out.clone()), -11.0), "done"); + let copy = out.join("a.wav"); + let first = std::fs::read(©).unwrap(); + + // Exported again at another gain: the same path, the new bytes, a sidecar proving them. + assert_eq!(exported(&lib, &wav, Some(out.clone()), -16.0), "done"); + assert_ne!( + std::fs::read(©).unwrap(), + first, + "the copy was replaced" + ); + assert_eq!( + std::fs::read(&wav).unwrap(), + source, + "the source is untouched" + ); + assert_eq!(txn::copy_at(©, &wav).unwrap(), txn::CopyAt::Own); +} + +#[test] +fn a_file_of_that_name_its_sidecar_does_not_prove_is_skipped() { + let lib = Lib::new(); + let wav = common::tone_wav(&lib.music(), "a.wav", 3.0, 0.1); + let out = lib.dir.path().join("copies"); + assert_eq!(exported(&lib, &wav, Some(out.clone()), -11.0), "done"); + let copy = out.join("a.wav"); + + // Changed by someone since: the sidecar no longer proves it. + let mut edited = std::fs::read(©).unwrap(); + let at = edited.len() - 1; + edited[at] ^= 1; + std::fs::write(©, &edited).unwrap(); + assert_eq!(exported(&lib, &wav, Some(out.clone()), -11.0), "skipped"); + assert_eq!(std::fs::read(©).unwrap(), edited, "left as it is"); + + // No sidecar at all. + std::fs::remove_file(txn::sidecar_path(©)).unwrap(); + assert_eq!(exported(&lib, &wav, Some(out), -11.0), "skipped"); + assert_eq!(std::fs::read(©).unwrap(), edited, "left as it is"); +} + +#[test] +fn an_export_into_the_sources_own_folder_never_replaces_the_source() { + let lib = Lib::new(); + let wav = common::tone_wav(&lib.music(), "a.wav", 3.0, 0.1); + assert_eq!(exported(&lib, &wav, None, -11.0), "done"); + let written = std::fs::read(&wav).unwrap(); + // A sidecar that proves the file in every other way: a copy, of this very path. Only the + // file being the source itself keeps it from being replaced. + let path = txn::sidecar_path(&wav); + let mut doc = sidecar::read(&path).unwrap(); + doc.mode = txn::TxnKind::ToFolder; + doc.source_path_hash = Some(sidecar::hash_of_path(&wav.canonicalize().unwrap())); + std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap(); + + assert_eq!(exported(&lib, &wav, Some(lib.music()), -14.0), "skipped"); + assert_eq!(std::fs::read(&wav).unwrap(), written); +} + +#[test] +fn a_copy_of_another_track_of_that_name_is_left_and_the_row_says_why() { + let lib = Lib::new(); + let a = common::tone_wav(&lib.music(), "track.wav", 3.0, 0.1); + let other = lib.dir.path().join("other"); + std::fs::create_dir_all(&other).unwrap(); + let b = common::tone_wav(&other, "track.wav", 4.0, 0.2); + let out = lib.dir.path().join("copies"); + assert_eq!(exported(&lib, &a, Some(out.clone()), -11.0), "done"); + let copy = out.join("track.wav"); + let first = std::fs::read(©).unwrap(); + + let s = settings( + &lib, + common::loudness_only(), + process(&lib, BatchMode::Prepare, Some(out.clone())), + ); + let events = run(&batch(std::slice::from_ref(&b)), &s, &CancelToken::new()); + let skipped = events.iter().find_map(|e| match e { + EngineEvent::ExportSkipped { outcome, .. } => Some(*outcome.clone()), + _ => None, + }); + assert_eq!( + skipped, + Some(ExportOutcome::Skip { + reason: ExportSkip::CopyOfOtherTrack + }) + ); + assert_eq!(std::fs::read(©).unwrap(), first, "left as it is"); +} + +#[test] +fn a_file_moved_unchanged_still_replaces_its_copy() { + let lib = Lib::new(); + let a = common::tone_wav(&lib.music(), "track.wav", 3.0, 0.1); + let out = lib.dir.path().join("copies"); + assert_eq!(exported(&lib, &a, Some(out.clone()), -11.0), "done"); + let moved_to = lib.dir.path().join("moved"); + std::fs::create_dir_all(&moved_to).unwrap(); + let moved = moved_to.join("track.wav"); + std::fs::rename(&a, &moved).unwrap(); + let first = std::fs::read(out.join("track.wav")).unwrap(); + assert_eq!(exported(&lib, &moved, Some(out.clone()), -16.0), "done"); + assert_ne!(std::fs::read(out.join("track.wav")).unwrap(), first); +} + +#[test] +fn a_plain_apply_never_replaces_a_copy() { + let lib = Lib::new(); + let wav = common::tone_wav(&lib.music(), "a.wav", 3.0, 0.1); + let out = lib.dir.path().join("copies"); + assert_eq!(exported(&lib, &wav, Some(out.clone()), -11.0), "done"); + let opts = ApplyOptions { + place: sc_engine::Place::Folder(out), + ..ApplyOptions::in_place(lib.backups()) + }; + let refused = apply_file(&wav, &ApplyRequest::default(), &opts, &CancelToken::new()); + assert!( + matches!(refused, Err(Error::AlreadyExists { .. })), + "{refused:?}" + ); +} diff --git a/crates/sc-engine/tests/session.rs b/crates/sc-engine/tests/session.rs index ec06cdc..244cf7c 100644 --- a/crates/sc-engine/tests/session.rs +++ b/crates/sc-engine/tests/session.rs @@ -246,3 +246,118 @@ fn median_of_nothing_is_none() { None:: ); } + +/// A session holding one analysed tone, and its id. +fn analysed_tone(dir: &std::path::Path) -> (Mutex, u32) { + common::tone_wav(dir, "1.wav", 5.0, 0.1); + let session = Mutex::new(Session::new(DecideSettings::dj())); + let ids = add(&mut session.lock().unwrap(), &[dir.to_path_buf()]); + run_job( + &session, + 1, + &ids, + &settings(), + &CancelToken::new(), + &mut |_| {}, + ); + (session, ids[0]) +} + +#[test] +fn identity_is_of_the_file_before_its_analysis_read_it() { + let dir = tempfile::tempdir().unwrap(); + let (session, id) = analysed_tone(dir.path()); + let path = dir.path().join("1.wav"); + let before = sc_io::txn::file_identity(&path).unwrap(); + assert_eq!( + session + .lock() + .unwrap() + .planning_row(id) + .unwrap() + .identity + .as_deref(), + Some(before.as_str()) + ); + // An analysis that read an older version keeps that version's identity, whatever is on disk. + 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("read earlier".to_owned()), + bpm_range: (sc_core::Bpm(60.0), sc_core::Bpm(120.0)), + }; + session.lock().unwrap().on_engine_event( + 2, + sc_engine::EngineEvent::Analysed { + file_id: id, + report: Box::new(report), + edit: sc_engine::EditState::default(), + }, + ); + let s = session.lock().unwrap(); + assert_eq!( + s.planning_row(id).unwrap().identity.as_deref(), + Some("read earlier") + ); + assert_eq!( + s.snapshot().rows[0].analysed_range, + Some((sc_core::Bpm(60.0), sc_core::Bpm(120.0))), + "each row keeps the range it was analysed with" + ); +} + +fn skipped_event(file_id: u32, reason: sc_core::export::ExportSkip) -> sc_engine::EngineEvent { + sc_engine::EngineEvent::ExportSkipped { + file_id, + outcome: Box::new(sc_core::export::ExportOutcome::Skip { reason }), + duration: sc_core::Seconds(5.0), + codec: sc_core::plan::Codec::Wav, + xml: Box::new(sc_core::export::XmlTrackInfo { + grid: sc_core::export::XmlGrid::Absent, + title: None, + artist: None, + }), + } +} + +#[test] +fn confirming_a_grid_marks_a_row_skipped_for_its_grid() { + use sc_core::export::ExportSkip; + let dir = tempfile::tempdir().unwrap(); + let (session, id) = analysed_tone(dir.path()); + let mut s = session.lock().unwrap(); + s.on_engine_event(2, skipped_event(id, ExportSkip::GridNeedsReview)); + assert!(!s.snapshot().rows[0].export_stale); + let edit = sc_engine::EditState { + confirmed: true, + ..sc_engine::EditState::default() + }; + // The same grid: only the confirmation changes. + let update = s.set_grid(id, None, edit).unwrap(); + assert!(update.export_stale); + assert!(s.snapshot().rows[0].export_stale); +} + +#[test] +fn confirming_a_grid_does_not_mark_a_row_skipped_for_the_file_itself() { + use sc_core::export::ExportSkip; + let dir = tempfile::tempdir().unwrap(); + let (session, id) = analysed_tone(dir.path()); + let mut s = session.lock().unwrap(); + s.on_engine_event( + 2, + skipped_event( + id, + ExportSkip::NotDjSafeRate { + sample_rate_hz: 96_000, + }, + ), + ); + let edit = sc_engine::EditState { + confirmed: true, + ..sc_engine::EditState::default() + }; + assert!(!s.set_grid(id, None, edit).unwrap().export_stale); +} diff --git a/crates/sc-io/examples/txn_apply.rs b/crates/sc-io/examples/txn_apply.rs index acbb5c7..087efe6 100644 --- a/crates/sc-io/examples/txn_apply.rs +++ b/crates/sc-io/examples/txn_apply.rs @@ -5,6 +5,7 @@ //! Usage: //! - `txn_apply apply `: change the file in place by -3 dB; //! - `txn_apply folder `: write it changed into the folder; +//! - `txn_apply replace `: write it at -6 dB over its earlier copy there; //! - `txn_apply undo `: undo its newest change. //! //! Prints the BLAKE3 of the file written. @@ -21,7 +22,7 @@ fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let arg = |i: usize| args.get(i).map(PathBuf::from); let (Some(mode), Some(file), Some(root)) = (args.first(), arg(1), arg(2)) else { - eprintln!("usage: txn_apply apply|folder|undo [folder]"); + eprintln!("usage: txn_apply apply|folder|replace|undo [folder]"); return ExitCode::from(2); }; let req = RenderRequest { @@ -35,6 +36,17 @@ fn main() -> ExitCode { ("folder", Some(out)) => { apply_to_folder(&file, &out, &req, &opts, &cancel).map(|r| r.output_blake3) } + ("replace", Some(out)) => { + let req = RenderRequest { + gain_db: -6.0, + ..RenderRequest::default() + }; + let opts = TxnOptions { + replace_own_copy: true, + ..opts + }; + apply_to_folder(&file, &out, &req, &opts, &cancel).map(|r| r.output_blake3) + } ("undo", _) => undo(&file, &root).map(|r| r.restored_blake3), _ => { eprintln!("unknown mode {mode}"); diff --git a/crates/sc-io/src/rekordbox.rs b/crates/sc-io/src/rekordbox.rs index 5228657..d23c966 100644 --- a/crates/sc-io/src/rekordbox.rs +++ b/crates/sc-io/src/rekordbox.rs @@ -41,8 +41,10 @@ use sc_core::export::XmlGrid; use sc_core::{Bpm, SampleIndex, Seconds}; mod location; +mod read; pub use location::{Speller, decode_location, location}; +pub use read::read_tracks; /// File name of a batch's rekordbox XML. pub const XML_FILE_NAME: &str = "soundcheck-rekordbox.xml"; diff --git a/crates/sc-io/src/rekordbox/read.rs b/crates/sc-io/src/rekordbox/read.rs new file mode 100644 index 0000000..45f30e2 --- /dev/null +++ b/crates/sc-io/src/rekordbox/read.rs @@ -0,0 +1,81 @@ +//! Reading back an XML SoundCheck wrote, so a later batch into the same folder can keep what it +//! lists: every field of a track comes back as [`super::write_xml`] writes it, so an entry that is +//! kept comes out with the same attributes. + +use quick_xml::events::{BytesStart, Event}; + +use sc_core::{Bpm, Seconds}; + +use super::{Tempo, XmlTrack, decode_location}; + +fn attr(e: &BytesStart<'_>, name: &str) -> Option { + let found = e.try_get_attribute(name).ok()??; + let value = found + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .ok()?; + Some(value.into_owned()) +} + +/// The track a collection `TRACK` element starts, without its tempo yet. +fn track_of(e: &BytesStart<'_>) -> Option { + Some(XmlTrack { + path: decode_location(&attr(e, "Location")?)?, + title: attr(e, "Name"), + artist: attr(e, "Artist"), + duration: Seconds(attr(e, "TotalTime")?.parse().ok()?), + tempo: Tempo { + inizio: Seconds(0.0), + bpm: Bpm(0.0), + battito: 1, + }, + }) +} + +fn tempo_of(e: &BytesStart<'_>) -> Option { + Some(Tempo { + inizio: Seconds(attr(e, "Inizio")?.parse().ok()?), + bpm: Bpm(attr(e, "Bpm")?.parse().ok()?), + battito: attr(e, "Battito")?.parse().ok()?, + }) +} + +/// The tracks of the XML `bytes`, in order; `None` when it is not readable whole as one SoundCheck +/// wrote: cut short, or a track without the `TEMPO` every listed track has. A caller must not take +/// that for an empty list: writing over it would lose what it lists. +#[must_use] +pub fn read_tracks(bytes: &[u8]) -> Option> { + let mut reader = quick_xml::Reader::from_reader(bytes); + let mut buf = Vec::new(); + let mut tracks = Vec::new(); + let mut in_collection = false; + let mut current: Option = None; + let mut has_tempo = false; + let mut ended = false; + loop { + match reader.read_event_into(&mut buf).ok()? { + Event::Start(e) if e.name().as_ref() == "COLLECTION" => in_collection = true, + Event::End(e) if e.name().as_ref() == "COLLECTION" => in_collection = false, + Event::Start(e) if in_collection && e.name().as_ref() == "TRACK" => { + current = Some(track_of(&e)?); + has_tempo = false; + } + Event::Empty(e) if current.is_some() && e.name().as_ref() == "TEMPO" => { + if let Some(track) = &mut current { + track.tempo = tempo_of(&e)?; + has_tempo = true; + } + } + Event::End(e) if in_collection && e.name().as_ref() == "TRACK" => { + if !has_tempo { + return None; + } + tracks.push(current.take()?); + } + Event::Empty(e) if in_collection && e.name().as_ref() == "TRACK" => return None, + Event::End(e) if e.name().as_ref() == "DJ_PLAYLISTS" => ended = true, + Event::Eof => return ended.then_some(tracks), + _ => {} + } + buf.clear(); + } +} diff --git a/crates/sc-io/src/rekordbox/tests.rs b/crates/sc-io/src/rekordbox/tests.rs index 9e74405..d7ced8f 100644 --- a/crates/sc-io/src/rekordbox/tests.rs +++ b/crates/sc-io/src/rekordbox/tests.rs @@ -364,3 +364,57 @@ fn parse_back() { assert_eq!(battito.parse::().expect("number"), t.battito); } } + +#[test] +fn what_it_writes_reads_back_as_the_same_bytes() { + let tracks = [ + track("/Music/Şarkı & Co/a.wav", 245.0, tempo(221, 128.0)), + track("/Music/c%20d#e.flac", 10.0, tempo(5_000, 174.0)), + ]; + let bytes = xml_bytes(&tracks, "9.9.9-test", "SoundCheck & "); + let read = read_tracks(&bytes).expect("ours"); + assert_eq!(read.len(), 2); + assert_eq!(read[0].path, tracks[0].path); + assert_eq!(xml_bytes(&read, "9.9.9-test", "SoundCheck & "), bytes); +} + +#[test] +fn an_xml_cut_short_is_not_an_empty_one() { + let bytes = xml_bytes( + &[track("/Music/a.wav", 245.0, tempo(221, 128.0))], + "9.9.9-test", + "SoundCheck", + ); + assert!(read_tracks(&bytes).is_some()); + let cut = |at: usize| read_tracks(&bytes[..at]); + assert_eq!(cut(bytes.len() / 2), None, "cut in the collection"); + let collection_end = bytes + .windows(13) + .position(|w| w == b"") + .unwrap(); + assert_eq!(cut(collection_end + 13), None, "cut after the collection"); + let empty = xml_bytes(&[], "9.9.9-test", "SoundCheck"); + assert_eq!( + read_tracks(&empty), + Some(Vec::new()), + "an empty one is fine" + ); +} + +#[test] +fn what_it_cannot_read_as_ours_it_says_so() { + assert_eq!( + read_tracks(b""), + None + ); + assert_eq!( + read_tracks(b""), + None, + "a track with no tempo" + ); + assert_eq!( + read_tracks(b""), + None, + "an empty track" + ); +} diff --git a/crates/sc-io/src/report.rs b/crates/sc-io/src/report.rs index 7acf970..56c95ad 100644 --- a/crates/sc-io/src/report.rs +++ b/crates/sc-io/src/report.rs @@ -1,5 +1,5 @@ -//! The batch's grid report, `grid-report.csv`: one row per file, in the order the files were -//! given, with what the export did and the grid it carried. +//! The grid report, `grid-report.csv`: one row per file, in the order given, with what the export +//! did and the grid it carried. //! //! The format is RFC 4180: comma-separated, CRLF line ends, a header row, a field quoted when it //! holds a comma, a quote, a line break or a leading or trailing space, quotes doubled inside. @@ -22,6 +22,10 @@ use std::io::Write; use sc_core::export::positive_zero; use sc_core::{Bpm, Seconds}; +mod read; + +pub use read::read_rows; + /// File name of a batch's grid report. pub const REPORT_FILE_NAME: &str = "grid-report.csv"; @@ -41,7 +45,7 @@ pub const COLUMNS: [&str; 10] = [ /// Leading characters a spreadsheet reads as the start of a formula (OWASP, "CSV Injection"): /// a text field starting with one gets a leading `'`. -const FORMULA_STARTS: [char; 6] = ['=', '+', '-', '@', '\t', '\r']; +pub(crate) const FORMULA_STARTS: [char; 6] = ['=', '+', '-', '@', '\t', '\r']; /// The UTF-8 byte-order mark the report starts with. pub const BOM: &[u8] = b"\xEF\xBB\xBF"; diff --git a/crates/sc-io/src/report/read.rs b/crates/sc-io/src/report/read.rs new file mode 100644 index 0000000..abacc82 --- /dev/null +++ b/crates/sc-io/src/report/read.rs @@ -0,0 +1,93 @@ +//! Reading back a report SoundCheck wrote, so a later batch into the same folder can keep its rows: +//! RFC 4180 fields, the formula guard taken off again (the writer puts it back), numbers parsed +//! from their fixed format, so a row that is kept comes out with the same bytes. + +use sc_core::{Bpm, Seconds}; + +use super::{BOM, COLUMNS, FORMULA_STARTS, ReportRow}; + +/// The records of `text`: RFC 4180, comma-separated, CRLF or LF line ends; `None` for one cut short +/// inside a quoted field. +fn records(text: &str) -> Option>> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if quoted && chars.peek() == Some(&'"') => { + chars.next(); + field.push('"'); + } + '"' => quoted = !quoted, + ',' if !quoted => row.push(std::mem::take(&mut field)), + '\r' if !quoted && chars.peek() == Some(&'\n') => {} + '\n' if !quoted => { + row.push(std::mem::take(&mut field)); + rows.push(std::mem::take(&mut row)); + } + c => field.push(c), + } + } + if quoted { + return None; + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + rows.push(row); + } + Some(rows) +} + +/// A text field as it was before the writer guarded it against spreadsheet formulas. +fn text(field: &str) -> String { + match field.strip_prefix('\'') { + Some(rest) if rest.starts_with(FORMULA_STARTS) => rest.to_owned(), + _ => field.to_owned(), + } +} + +/// A number field: `Ok(None)` when empty, `Err` when it is not a number. +fn number(field: &str) -> Result, std::num::ParseFloatError> { + if field.is_empty() { + return Ok(None); + } + field.parse().map(Some) +} + +/// The rows of the report `bytes`, in order; `None` when it is not readable whole as one with +/// today's columns: a row cut short, a number that is none. A caller must not take that for an +/// empty report: writing over it would lose what it says. +#[must_use] +pub fn read_rows(bytes: &[u8]) -> Option> { + let text_of = std::str::from_utf8(bytes.strip_prefix(BOM)?).ok()?; + let mut records = records(text_of)?.into_iter(); + if records.next()? != COLUMNS { + return None; + } + records + .map(|r| { + let [file, mode, action, gain, cut, bpm, bar1, grid, check, notes]: [String; 10] = + r.try_into().ok()?; + let notes = text(¬es); + let check = text(&check); + Some(ReportRow { + file: text(&file), + mode: text(&mode), + action: text(&action), + gain_db: number(&gain).ok()?, + cut: number(&cut).ok()?.map(Seconds), + bpm: number(&bpm).ok()?.map(Bpm), + bar1: number(&bar1).ok()?.map(Seconds), + grid: text(&grid), + grid_check: (!check.is_empty()).then_some(check), + notes: if notes.is_empty() { + Vec::new() + } else { + vec![notes] + }, + }) + }) + .collect() +} diff --git a/crates/sc-io/src/report/tests.rs b/crates/sc-io/src/report/tests.rs index dd51717..47c3764 100644 --- a/crates/sc-io/src/report/tests.rs +++ b/crates/sc-io/src/report/tests.rs @@ -66,3 +66,72 @@ fn csv_columns() { // An empty report is the header alone. assert_eq!(text(&[]), format!("{}\r\n", COLUMNS.join(","))); } + +#[test] +fn what_it_writes_reads_back_as_the_same_bytes() { + let rows = [ + ReportRow { + file: "/Music/a, \"b\"\nc.wav".to_owned(), + mode: "prepare".to_owned(), + action: "written".to_owned(), + gain_db: Some(-2.25), + cut: Some(Seconds(0.21)), + bpm: Some(Bpm(128.0)), + bar1: Some(Seconds(0.005)), + grid: "tempo: beat 1 at 0.005 s".to_owned(), + grid_check: Some("off by 12.0 ms".to_owned()), + notes: vec!["=formula".to_owned(), "another".to_owned()], + }, + ReportRow { + file: "=b.wav".to_owned(), + ..ReportRow::default() + }, + ]; + let bytes = csv_bytes(&rows); + let read = read_rows(&bytes).expect("ours"); + assert_eq!(read[0].file, rows[0].file); + assert_eq!(read[1].file, "=b.wav"); + assert_eq!(read[1].gain_db, None); + assert_eq!(csv_bytes(&read), bytes); +} + +#[test] +fn another_shape_of_report_is_not_read() { + assert_eq!(read_rows(b"file,mode\r\n"), None); + assert_eq!(read_rows(b"\xEF\xBB\xBFfile,mode\r\n"), None); +} + +#[test] +fn a_report_cut_short_or_damaged_is_not_an_empty_one() { + let rows = [ReportRow { + file: "/a, \"b\".wav".to_owned(), + gain_db: Some(-1.0), + ..ReportRow::default() + }]; + let bytes = csv_bytes(&rows); + assert!(read_rows(&bytes).is_some()); + assert_eq!( + read_rows(&bytes[..bytes.len() - 6]), + None, + "a row cut short" + ); + let quote = bytes.iter().rposition(|b| *b == b'"').unwrap(); + assert_eq!(read_rows(&bytes[..quote]), None, "cut in a quoted field"); + let mut short = bytes.clone(); + short.extend_from_slice(b"just one field\r\n"); + assert_eq!(read_rows(&short), None, "a row with too few fields"); + // Cut inside the last field, quoted: every field is there, but not all of it. + let noted = csv_bytes(&[ReportRow { + file: "/a.wav".to_owned(), + notes: vec!["a, b".to_owned()], + ..ReportRow::default() + }]); + assert!(read_rows(¬ed).is_some()); + assert_eq!( + read_rows(¬ed[..noted.len() - 4]), + None, + "cut in the last field" + ); + let text = String::from_utf8(bytes).unwrap().replace("-1.00", "oops"); + assert_eq!(read_rows(text.as_bytes()), None, "a number that is none"); +} diff --git a/crates/sc-io/src/txn/apply.rs b/crates/sc-io/src/txn/apply.rs index b64a013..fa21841 100644 --- a/crates/sc-io/src/txn/apply.rs +++ b/crates/sc-io/src/txn/apply.rs @@ -15,6 +15,7 @@ use std::time::{Duration, Instant}; use sc_core::{ChangeCause, Error, RenderRequest, Result}; +use super::copy_at::{CopyAt, proven_copy}; use super::crash; use super::finish::finish; use super::fsx::{ @@ -25,7 +26,7 @@ use super::journal::{Journal, Line, State, TargetLock, TxnKind, TxnLock}; use super::meta::{FileId, FileMeta, folder_id, restore, snapshot}; use super::preflight::{ Container, Source, check_in_place, check_not_rekordbox, check_outside_backups, check_space, - name_in_dir, nearest_existing, resolve, source, + name_in_dir, nearest_existing, real_path, resolve, source, }; use super::recover::settle_failure; use super::sidecar::{Record, RenderSummary}; @@ -51,6 +52,8 @@ struct Checked { /// The source's identity when it was checked, before waiting for the target lock. source_id: FileId, volume: Volume, + /// The earlier copy at the target this one replaces, proven SoundCheck's by its sidecar. + replaces: Option, } /// What a transaction writes where. @@ -63,6 +66,8 @@ pub(super) struct Plan<'a> { pub target: PathBuf, pub target_dir: PathBuf, pub temp: PathBuf, + /// The earlier copy at the target this one replaces. + pub replaces: Option, /// In place: the backup's first-choice path and its temp file. pub backup: Option<(PathBuf, PathBuf)>, pub opts: &'a TxnOptions, @@ -148,7 +153,15 @@ fn check( req: &RenderRequest, opts: &TxnOptions, ) -> Result { - let (full, dir, name) = resolve(path)?; + let (mut full, mut dir, mut name) = resolve(path)?; + // A copy is made of the file itself: a link is only kept to refuse it in place. + if out_dir.is_some() && full.is_symlink() { + full = real_path(&full)?; + if let (Some(folder), Some(file)) = (full.parent(), full.file_name()) { + dir = folder.to_path_buf(); + name = file.to_os_string(); + } + } check_outside_backups(&full, &opts.backup_root)?; let (kind, target_dir, create_dir, volume) = match out_dir { None => { @@ -185,10 +198,19 @@ fn check( _ => name.clone(), }; let target = target_dir.join(&out_name); + let mut replaces = None; if kind == TxnKind::ToFolder { check_outside_backups(&target, &opts.backup_root)?; if exists(&target) { - return Err(Error::AlreadyExists { path: target }); + let proven = if opts.replace_own_copy { + proven_copy(&target, &full)? + } else { + (CopyAt::Foreign, None) + }; + let (CopyAt::Own, Some(id)) = proven else { + return Err(Error::AlreadyExists { path: target }); + }; + replaces = Some(id); } } let source_id = FileId::read(&full)?; @@ -209,6 +231,7 @@ fn check( create_dir, source_id, volume, + replaces, }) } @@ -222,7 +245,8 @@ fn plan<'a>( ) -> Result> { if c.kind == TxnKind::InPlace { check_in_place(&c.src.path, &c.target_dir, tx.volumes)?; - } else if exists(&c.target) { + } else if exists(&c.target) && Some(FileId::read(&c.target)?) != c.replaces { + // Something took the name, or changed the copy, while this waited for the lock. return Err(Error::AlreadyExists { path: c.target }); } let meta = snapshot(&c.src.path)?; @@ -254,6 +278,7 @@ fn plan<'a>( target: c.target, target_dir: c.target_dir, temp, + replaces: c.replaces, backup, opts, journal, @@ -487,6 +512,15 @@ fn rename_into_place(tx: &Transaction<'_>, plan: &Plan<'_>) -> Result<()> { #[cfg(not(test))] let _ = tx; match plan.kind { + // An earlier copy, still what it was when it was proven ours, is replaced atomically. + TxnKind::ToFolder if plan.replaces.is_some() => { + if FileId::read(&plan.target).ok() != plan.replaces { + return Err(Error::AlreadyExists { + path: plan.target.clone(), + }); + } + std::fs::rename(&plan.temp, &plan.target).map_err(|e| io_err(&plan.target, e)) + } TxnKind::ToFolder => rename_noreplace(&plan.temp, &plan.target), _ => std::fs::rename(&plan.temp, &plan.target).map_err(|e| io_err(&plan.target, e)), } diff --git a/crates/sc-io/src/txn/apply/tests.rs b/crates/sc-io/src/txn/apply/tests.rs index 2c1ad56..0338e65 100644 --- a/crates/sc-io/src/txn/apply/tests.rs +++ b/crates/sc-io/src/txn/apply/tests.rs @@ -332,3 +332,148 @@ fn a_folder_on_a_rekordbox_export_is_refused_whether_it_exists_or_not() { "{refused}" ); } + +/// A copy of `s.path` in `music/../out`, and the options that may replace it. +fn copied(s: &Scene) -> (PathBuf, PathBuf, TxnOptions) { + let out = s.music.with_file_name("out"); + let opts = TxnOptions { + replace_own_copy: true, + ..TxnOptions::new(&s.backups) + }; + let req = RenderRequest { + gain_db: -2.0, + ..RenderRequest::default() + }; + Transaction::default() + .apply_to_folder(&s.path, &out, &req, &opts, &AtomicBool::new(false)) + .expect("the first copy"); + let copy = out.join("a.wav"); + (out, copy, opts) +} + +fn copy_again( + s: &Scene, + out: &Path, + opts: &TxnOptions, + hooks: Hooks, +) -> sc_core::Result { + let tx = Transaction { + hooks, + ..Transaction::default() + }; + let req = RenderRequest { + gain_db: -4.0, + ..RenderRequest::default() + }; + tx.apply_to_folder(&s.path, out, &req, opts, &AtomicBool::new(false)) +} + +fn write_over_copy_before_rename(_temp: &Path, target: &Path) { + std::fs::write(target, b"edited while the new copy was being written").expect("edited"); +} + +#[test] +fn a_copy_changed_while_its_replacement_is_written_is_left_as_it_is() { + let s = scene(); + let (out, copy, opts) = copied(&s); + let hooks = Hooks { + before_rename: Some(write_over_copy_before_rename), + ..Hooks::default() + }; + match copy_again(&s, &out, &opts, hooks) { + Err(Error::AlreadyExists { path }) => assert_eq!(path, copy), + other => panic!("{other:?}"), + } + assert_eq!( + std::fs::read(©).expect("copy"), + b"edited while the new copy was being written" + ); + assert_eq!(std::fs::read(&s.path).expect("source"), s.bytes); + let temps = others(&out) + .into_iter() + .filter(|n| n.contains(crate::txn::TEMP_MARKER)) + .count(); + assert_eq!(temps, 0, "no temp left"); +} + +/// The copy the next [`edit_copy_after_check`] changes. +static COPY_TO_EDIT: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn edit_copy_after_check() { + let copy = COPY_TO_EDIT.lock().expect("path").clone().expect("set"); + std::fs::write(©, b"edited while waiting for the lock").expect("edited"); +} + +#[test] +fn a_copy_changed_while_waiting_for_its_lock_is_left_as_it_is() { + let s = scene(); + let (out, copy, opts) = copied(&s); + *COPY_TO_EDIT.lock().expect("path") = Some(copy.clone()); + let hooks = Hooks { + after_check: Some(edit_copy_after_check), + ..Hooks::default() + }; + assert!( + matches!( + copy_again(&s, &out, &opts, hooks), + Err(Error::AlreadyExists { .. }) + ), + "refused" + ); + assert_eq!( + std::fs::read(©).expect("copy"), + b"edited while waiting for the lock" + ); + assert_eq!( + journal_entries(&s.backups).expect("journal").len(), + 1, + "refused before anything was written or journaled" + ); +} + +#[test] +fn a_transaction_not_asked_to_replace_leaves_its_copy() { + let s = scene(); + let (out, copy, _) = copied(&s); + let first = std::fs::read(©).expect("copy"); + let opts = TxnOptions::new(&s.backups); + assert!(matches!( + copy_again(&s, &out, &opts, Hooks::default()), + Err(Error::AlreadyExists { .. }) + )); + assert_eq!(std::fs::read(©).expect("copy"), first); +} + +#[test] +fn a_copy_of_a_link_is_a_copy_of_its_file_named_as_the_link() { + let s = scene(); + let link = s.music.join("link.wav"); + std::os::unix::fs::symlink(&s.path, &link).expect("link"); + let out = s.music.with_file_name("out"); + let opts = TxnOptions::new(&s.backups); + let req = RenderRequest { + gain_db: -2.0, + ..RenderRequest::default() + }; + Transaction::default() + .apply_to_folder(&link, &out, &req, &opts, &AtomicBool::new(false)) + .expect("an unchanged link is written"); + assert!(out.join("link.wav").is_file()); + assert_eq!(std::fs::read(&s.path).expect("source"), s.bytes); +} + +#[test] +fn a_copy_records_where_it_came_from_as_a_hash_never_the_path() { + let s = scene(); + let (_, copy, _) = copied(&s); + let text = std::fs::read_to_string(sidecar_path(©)).expect("sidecar"); + assert!( + text.contains(&crate::txn::sidecar::hash_of_path(&s.path)), + "{text}" + ); + assert!(!text.contains(s.path.to_str().expect("utf-8")), "{text}"); + // An export in place records no such thing. + let report = run(&s, Hooks::default()).expect("in place"); + let text = std::fs::read_to_string(sidecar_path(&report.output)).expect("sidecar"); + assert!(!text.contains("source_path_hash"), "{text}"); +} diff --git a/crates/sc-io/src/txn/copy_at.rs b/crates/sc-io/src/txn/copy_at.rs new file mode 100644 index 0000000..bc0db05 --- /dev/null +++ b/crates/sc-io/src/txn/copy_at.rs @@ -0,0 +1,98 @@ +//! What is at the path a copy to a folder would take. A copy SoundCheck wrote there of this same +//! file may be replaced, which keeps its path and so whatever a DJ app has keyed to that path, but +//! only when its sidecar proves it: the sidecar says it wrote a copy, the file's bytes are still +//! those it recorded, and it was made from this file (the same resolved path, or a moved file that +//! is byte for byte what it was made from). A copy of another track of that name is not this file's +//! to replace, and anything else there is someone's file; both are left alone. + +use std::path::Path; + +use sc_core::Result; + +use super::fsx::{exists, hash_file, hex, io_err}; +use super::journal::TxnKind; +use super::meta::FileId; +use super::preflight::real_path; +use super::sidecar::{self, FileHash, SidecarDoc, hash_of_path, sidecar_path}; + +/// What is at the path a copy would take. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CopyAt { + /// Nothing. + Nothing, + /// A copy SoundCheck wrote of this file, unchanged since. + Own, + /// A copy SoundCheck wrote of another file, unchanged since. + OtherTrack, + /// Something else: no sidecar, one that does not match, or the source itself. + Foreign, +} + +/// What is at `target`, the path a copy of `source` would take. +/// +/// # Errors +/// [`sc_core::Error::Io`] when `target` cannot be read to tell. +pub fn copy_at(target: &Path, source: &Path) -> Result { + Ok(proven_copy(target, source)?.0) +} + +/// [`copy_at`], with the identity of an own copy as it was before its bytes were read. +pub(crate) fn proven_copy(target: &Path, source: &Path) -> Result<(CopyAt, Option)> { + let foreign = (CopyAt::Foreign, None); + if !exists(target) { + return Ok((CopyAt::Nothing, None)); + } + let meta = std::fs::symlink_metadata(target).map_err(|e| io_err(target, e))?; + if !meta.is_file() { + return Ok(foreign); + } + let identity = FileId::of(&meta); + // An export into the source's own folder must never take the source for its earlier copy, + // however it is reached: `source` may be a link to `target`. + if let Ok(source_meta) = std::fs::metadata(source) { + let source_id = FileId::of(&source_meta); + if identity.dev == source_id.dev && identity.ino == source_id.ino { + return Ok(foreign); + } + } + let Ok(doc) = sidecar::read(&sidecar_path(target)) else { + return Ok(foreign); + }; + let Some(output) = doc + .output + .as_ref() + .filter(|_| doc.mode == TxnKind::ToFolder) + else { + return Ok(foreign); + }; + let (bytes, hash) = hash_file(target)?; + #[cfg(test)] + tests::after_hash(target); + // Read first and compared after: a change while it was read is not proven by what was read. + if bytes != output.bytes || hex(&hash) != output.blake3 || FileId::read(target)? != identity { + return Ok(foreign); + } + Ok(if made_from(&doc, source)? { + (CopyAt::Own, Some(identity)) + } else { + (CopyAt::OtherTrack, None) + }) +} + +/// Whether the copy `doc` describes was made from `source`: the path it was made from, or a moved +/// file that is still byte for byte the one it was made from. A sidecar without the path's hash +/// goes by the bytes alone, which at worst refuses to replace a copy of an edited file. +fn made_from(doc: &SidecarDoc, source: &Path) -> Result { + let resolved = real_path(source)?; + if doc.source_path_hash.as_deref() == Some(hash_of_path(&resolved).as_str()) { + return Ok(true); + } + let Some(FileHash { blake3, bytes }) = &doc.original else { + return Ok(false); + }; + let (source_bytes, source_hash) = hash_file(&resolved)?; + Ok(source_bytes == *bytes && hex(&source_hash) == *blake3) +} + +#[cfg(test)] +mod tests; diff --git a/crates/sc-io/src/txn/copy_at/tests.rs b/crates/sc-io/src/txn/copy_at/tests.rs new file mode 100644 index 0000000..71c060b --- /dev/null +++ b/crates/sc-io/src/txn/copy_at/tests.rs @@ -0,0 +1,228 @@ +//! Unit tests of `crates/sc-io/src/txn/copy_at.rs`. +use std::path::{Path, PathBuf}; + +use super::*; +use crate::txn::fsx::{hash_file, hex}; +use crate::txn::journal::TxnKind; +use crate::txn::sidecar::{FileHash, SidecarDoc, hash_of_path}; + +thread_local! { + /// Runs right after a copy's bytes are hashed. + static AFTER_HASH: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +pub(super) fn after_hash(target: &Path) { + if let Some(hook) = AFTER_HASH.get() { + hook(target); + } +} + +/// What a sidecar next to `file` says it was made from. +#[derive(Default)] +struct MadeFrom { + /// The resolved path, hashed. + path: Option, + /// The bytes of the original, hashed. + original: Option, +} + +/// A sidecar next to `file` recording `kind`, with the file as it is now as its output. +fn sidecar_of(file: &Path, kind: TxnKind, from: &MadeFrom) { + let hashed = |p: &Path| { + let (bytes, hash) = hash_file(p).expect("hash"); + FileHash { + blake3: hex(&hash), + bytes, + } + }; + let mut doc: SidecarDoc = serde_json::from_value(serde_json::json!({ + "schema": 2, + "app": "SoundCheck", + "version": "0", + "file": "a.wav", + "transaction": "1-1-1", + "processed_at": "2026-11-05T00:00:00Z", + "mode": kind, + "original": null, + "output": hashed(file), + "request": {"gain_db": 0.0, "trim_frames": 0, "bits": null, "loudness": null, "tag_edits": []}, + "render": { + "frames_in": 1, "frames_out": 1, "sample_rate_hz": 44100, "channels": 2, "bits_out": 16, + "exact": true, "dithered": false, "samples_saturated": 0, "pcm_blake3": "00", + "blocks": {"carried": 0, "patched": 0, "edited": 0, "replaced": 0, "dropped": 0}, + "tags_added": false, "tags_not_added": null, "stale_loudness_tags": [] + }, + "backup": null, + "metadata": {"mtime_kept": false, "notes": []}, + })) + .expect("a sidecar"); + doc.original = from.original.as_deref().map(hashed); + doc.source_path_hash = from.path.as_deref().map(hash_of_path); + std::fs::write(sidecar_path(file), serde_json::to_vec(&doc).expect("json")) + .expect("sidecar written"); +} + +struct Case { + _dir: tempfile::TempDir, + source: PathBuf, + target: PathBuf, +} + +fn case() -> Case { + let dir = tempfile::tempdir().expect("temp"); + let base = dir.path().canonicalize().expect("resolves"); + let source = base.join("source.wav"); + let target = base.join("a.wav"); + std::fs::write(&source, b"source").expect("source"); + std::fs::write(&target, b"copy").expect("copy"); + Case { + _dir: dir, + source, + target, + } +} + +/// Made from `c.source` by its path. +fn by_path(c: &Case) -> MadeFrom { + MadeFrom { + path: Some(c.source.clone()), + original: None, + } +} + +#[test] +fn nothing_there() { + let c = case(); + std::fs::remove_file(&c.target).expect("removed"); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Nothing); +} + +#[test] +fn a_copy_made_from_this_path_is_ours() { + let c = case(); + sidecar_of(&c.target, TxnKind::ToFolder, &by_path(&c)); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Own); +} + +#[test] +fn a_file_moved_unchanged_is_known_by_its_bytes() { + let c = case(); + let moved = c.source.with_file_name("moved.wav"); + std::fs::copy(&c.source, &moved).expect("moved"); + let from = MadeFrom { + path: Some(c.source.clone()), + original: Some(c.source.clone()), + }; + sidecar_of(&c.target, TxnKind::ToFolder, &from); + assert_eq!(copy_at(&c.target, &moved).unwrap(), CopyAt::Own); +} + +#[test] +fn a_sidecar_from_before_the_path_goes_by_the_bytes_alone() { + let c = case(); + let from = MadeFrom { + path: None, + original: Some(c.source.clone()), + }; + sidecar_of(&c.target, TxnKind::ToFolder, &from); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Own); + // At worst it refuses: an edited source no longer proves it. + std::fs::write(&c.source, b"edited").expect("edited"); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::OtherTrack); +} + +#[test] +fn a_copy_of_an_edited_file_at_its_path_is_still_its_own() { + let c = case(); + sidecar_of(&c.target, TxnKind::ToFolder, &by_path(&c)); + std::fs::write(&c.source, b"edited").expect("edited"); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Own); +} + +#[test] +fn a_copy_of_another_track_of_that_name_is_not_ours_to_replace() { + let c = case(); + let other = c.source.with_file_name("other.wav"); + std::fs::write(&other, b"other track").expect("other"); + let from = MadeFrom { + path: Some(other.clone()), + original: Some(other), + }; + sidecar_of(&c.target, TxnKind::ToFolder, &from); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::OtherTrack); +} + +#[test] +fn a_file_with_no_sidecar_is_foreign() { + let c = case(); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Foreign); +} + +#[test] +fn a_copy_changed_since_is_foreign() { + let c = case(); + sidecar_of(&c.target, TxnKind::ToFolder, &by_path(&c)); + std::fs::write(&c.target, b"edit").expect("edited, same length"); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Foreign); +} + +#[test] +fn a_file_changed_in_place_is_not_a_copy() { + let c = case(); + sidecar_of(&c.target, TxnKind::InPlace, &by_path(&c)); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Foreign); +} + +#[test] +fn the_source_itself_is_never_its_own_copy() { + let c = case(); + let from = MadeFrom { + path: Some(c.source.clone()), + original: Some(c.source.clone()), + }; + sidecar_of(&c.source, TxnKind::ToFolder, &from); + assert_eq!(copy_at(&c.source, &c.source).unwrap(), CopyAt::Foreign); +} + +#[test] +fn a_link_to_a_proven_copy_is_never_taken_for_its_earlier_copy() { + let c = case(); + let link = c.source.with_file_name("link.wav"); + std::os::unix::fs::symlink(&c.target, &link).expect("link"); + let from = MadeFrom { + path: Some(c.target.clone()), + original: Some(c.target.clone()), + }; + sidecar_of(&c.target, TxnKind::ToFolder, &from); + assert_eq!(copy_at(&c.target, &link).unwrap(), CopyAt::Foreign); +} + +#[test] +fn an_unreadable_sidecar_is_foreign() { + let c = case(); + std::fs::write(sidecar_path(&c.target), b"{").expect("written"); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Foreign); +} + +#[test] +fn a_copy_that_changes_while_it_is_read_is_not_proven_by_what_was_read() { + let c = case(); + sidecar_of(&c.target, TxnKind::ToFolder, &by_path(&c)); + assert_eq!(copy_at(&c.target, &c.source).unwrap(), CopyAt::Own); + AFTER_HASH.set(Some(|target| { + std::fs::write(target, b"copy, longer now").expect("edited"); + })); + let found = copy_at(&c.target, &c.source).unwrap(); + AFTER_HASH.set(None); + assert_eq!(found, CopyAt::Foreign); +} + +#[test] +fn the_identity_of_a_proven_copy_is_the_one_before_its_bytes_were_read() { + let c = case(); + sidecar_of(&c.target, TxnKind::ToFolder, &by_path(&c)); + let before = FileId::read(&c.target).unwrap(); + let (found, identity) = proven_copy(&c.target, &c.source).unwrap(); + assert_eq!(found, CopyAt::Own); + assert_eq!(identity, Some(before)); +} diff --git a/crates/sc-io/src/txn/meta.rs b/crates/sc-io/src/txn/meta.rs index daba1f3..14f2553 100644 --- a/crates/sc-io/src/txn/meta.rs +++ b/crates/sc-io/src/txn/meta.rs @@ -55,6 +55,21 @@ pub(crate) struct FileId { pub ctime: (i64, i64), } +/// A file's identity for telling later whether it is still that file: its device, inode, length and +/// modification time as opaque text, of the file itself when `path` is a symbolic link. Unlike +/// [`FileId`] it leaves out the change time, which the system moves when it merely reads or labels +/// a file. +/// +/// # Errors +/// [`sc_core::Error::Io`] when the file cannot be read. +pub fn file_identity(path: &Path) -> Result { + let id = FileId::of(&std::fs::metadata(path).map_err(|e| io_err(path, e))?); + Ok(format!( + "{}:{}:{}:{}.{}", + id.dev, id.ino, id.len, id.mtime.0, id.mtime.1 + )) +} + impl FileId { /// The identity in `meta`. pub fn of(meta: &Metadata) -> Self { diff --git a/crates/sc-io/src/txn/mod.rs b/crates/sc-io/src/txn/mod.rs index 439c796..07c33b0 100644 --- a/crates/sc-io/src/txn/mod.rs +++ b/crates/sc-io/src/txn/mod.rs @@ -66,6 +66,7 @@ //! `SC_TEST_CRASH_AFTER_STEP` (see [`CRASH_ENV`]). mod apply; +mod copy_at; pub(crate) mod crash; mod finish; mod forget; @@ -87,13 +88,16 @@ use sc_core::{Error, RenderRequest, Result}; use crate::render::RenderReport; +pub use copy_at::{CopyAt, copy_at}; pub use crash::CRASH_ENV; pub use forget::{Forgotten, forget}; 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, is_in_resolved_backup_root, - is_under_backup_root, resolve_backup_root, resolve_file, tag_family, + 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, }; pub use recover::{Pending, Recovered, RecoveryReport, recover}; pub use sidecar::{SIDECAR_SUFFIX, record_grid_check, sidecar_path}; @@ -142,6 +146,10 @@ pub struct TxnOptions { /// ([`sc_core::Error::FileChanged`], [`sc_core::ChangeCause::SincePlanned`]). `None` checks /// nothing. pub expect_original_blake3: Option<[u8; 32]>, + /// A copy to a folder may replace an earlier copy of that name that SoundCheck wrote and its + /// sidecar still proves ([`copy_at`]). Anything else there is refused either way + /// ([`sc_core::Error::AlreadyExists`]). + pub replace_own_copy: bool, } impl TxnOptions { @@ -154,6 +162,7 @@ impl TxnOptions { sidecar: true, export: None, expect_original_blake3: None, + replace_own_copy: false, } } } diff --git a/crates/sc-io/src/txn/preflight.rs b/crates/sc-io/src/txn/preflight.rs index 8b38671..5b60b39 100644 --- a/crates/sc-io/src/txn/preflight.rs +++ b/crates/sc-io/src/txn/preflight.rs @@ -114,6 +114,49 @@ pub(crate) fn name_in_dir(dir: &Path, given: &std::ffi::OsStr, ino: u64) -> OsSt found.unwrap_or_else(|| given.to_os_string()) } +/// Whether two names differing only in case are one file in the folder `dir` (which need not exist +/// yet): read from the nearest folder that does, by asking for it with its name's case swapped. +/// When that cannot tell, it says yes: two files then keep from sharing a name, never overwrite. +#[must_use] +pub fn folder_folds_case(dir: &Path) -> bool { + let Ok((base, _)) = nearest_existing(dir) else { + return true; + }; + let Ok(device) = std::fs::metadata(&base).map(|m| m.dev()) else { + return true; + }; + for folder in base.ancestors() { + let Some(name) = folder.file_name().and_then(|n| n.to_str()) else { + return true; + }; + let swapped: String = name + .chars() + .map(|c| { + if c.is_uppercase() { + c.to_ascii_lowercase() + } else { + c.to_ascii_uppercase() + } + }) + .collect(); + if swapped == name { + continue; + } + let Ok(meta) = std::fs::metadata(folder) else { + return true; + }; + // Another volume higher up says nothing about this one. + if meta.dev() != device { + return true; + } + return match std::fs::metadata(folder.with_file_name(swapped)) { + Ok(other) => other.dev() == meta.dev() && other.ino() == meta.ino(), + Err(e) => e.kind() != std::io::ErrorKind::NotFound, + }; + } + true +} + /// The nearest existing folder at or above `path` (resolved), and the part below it. pub(crate) fn nearest_existing(path: &Path) -> Result<(PathBuf, PathBuf)> { let absolute = if path.is_absolute() { diff --git a/crates/sc-io/src/txn/sidecar.rs b/crates/sc-io/src/txn/sidecar.rs index b13eec3..432dbff 100644 --- a/crates/sc-io/src/txn/sidecar.rs +++ b/crates/sc-io/src/txn/sidecar.rs @@ -195,6 +195,19 @@ pub struct SidecarDoc { /// schema 1. #[serde(default, skip_serializing_if = "Option::is_none")] pub export: Option, + /// A hash of the resolved path of the file a copy was made from, to tell it from a copy of + /// another track of the same name. A hash, not the path: sidecars travel with shared music + /// folders, and a path would give away a home folder. Absent in place and in older sidecars. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_path_hash: Option, +} + +/// The hash a sidecar records of the resolved path of a copy's source. +#[must_use] +pub fn hash_of_path(resolved: &Path) -> String { + blake3::hash(resolved.as_os_str().as_encoded_bytes()) + .to_hex() + .to_string() } /// The sidecar path of `file`: `.soundcheck.json` next to it. @@ -243,6 +256,7 @@ pub(crate) fn render_text(entry: &Entry, notes: &[String]) -> Result { notes: notes.to_vec(), }, export: record.export.clone(), + source_path_hash: (entry.kind == TxnKind::ToFolder).then(|| hash_of_path(&entry.source)), }; doc_text(&doc) } diff --git a/crates/sc-io/tests/crash.rs b/crates/sc-io/tests/crash.rs index 4923fb7..d68ddc0 100644 --- a/crates/sc-io/tests/crash.rs +++ b/crates/sc-io/tests/crash.rs @@ -2,7 +2,8 @@ //! is run with `SC_TEST_CRASH_AFTER_STEP` naming a crash point, so it aborts there: every //! journaled state and the points between them (inside the render, inside the backup copy, //! between the backup's journaled name and its rename, between the rename and its journal -//! line), for in-place changes of a WAV and a FLAC, copies to a folder, and undos. Recovery +//! line), for in-place changes of a WAV and a FLAC, copies to a folder, a copy replacing an earlier +//! one, and undos. Recovery //! must then leave the file before the transaction or the verified file after it (never //! anything else, and after it exactly when the rename happened); no temp, placeholder or lock //! file; the journal entry recovered; for an in-place change the backup present (equal to the @@ -193,6 +194,70 @@ fn every_crash_point_of_a_copy_to_a_folder_recovers() { } } +#[test] +fn every_crash_point_of_replacing_a_copy_recovers() { + for (point, reached) in TO_FOLDER { + let what = format!("replacing a copy at {point}"); + let lib = Library::new(); + let path = lib.add("a.wav", &wav(3000, 26)); + let original = blake3_of(&path); + let out = lib.dir.path().join("out"); + // The earlier copy, finished; the program's `replace` mode writes another gain over it. + let opts = TxnOptions { + replace_own_copy: true, + ..TxnOptions::new(lib.backups.clone()) + }; + let req = RenderRequest { + gain_db: -3.0, + ..RenderRequest::default() + }; + txn::apply_to_folder( + &path, + &out, + &req, + &opts, + &std::sync::atomic::AtomicBool::new(false), + ) + .expect("the first copy"); + let written = out.join("a.wav"); + let earlier = blake3_of(&written); + + crash_at(&[&path, &lib.backups, &out], "replace", point); + let entries = journal_entries(&lib.backups).expect("journal"); + assert_eq!(entries.len(), 2, "{what}"); + let r = txn::recover(&lib.backups).expect("recovery"); + assert_eq!(r.recovered.len(), 1, "{what}: {r:?}"); + assert_eq!(blake3_of(&path), original, "{what}: the source"); + let entry = journal_entries(&lib.backups) + .expect("journal") + .into_iter() + .next_back() + .expect("the second change"); + assert!(entry.state.is_final(), "{what}"); + let _ = reached; + if renamed(point) { + assert_eq!(r.recovered[0].outcome, Outcome::Completed, "{what}"); + assert_eq!( + Some(hex(&blake3_of(&written))), + entry.output_blake3, + "{what}" + ); + assert_ne!(blake3_of(&written), earlier, "{what}: the new copy"); + } else { + assert_eq!(r.recovered[0].outcome, Outcome::RolledBack, "{what}"); + assert_eq!(blake3_of(&written), earlier, "{what}: the earlier copy"); + } + // Either way the sidecar proves what is there, and the next export can replace it. + assert_eq!( + txn::copy_at(&written, &path).expect("read"), + txn::CopyAt::Own, + "{what}" + ); + assert_no_temps(lib.dir.path()); + assert!(backups_in(&lib.backups).is_empty(), "{what}: no backup"); + } +} + #[test] fn every_crash_point_of_an_undo_recovers() { for (point, reached) in UNDO { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 107a3b6..e0534b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -157,7 +157,7 @@ RENDER (streamed) declared by ID3v2.3/2.4 plus iTunes' own, and labels with `:`, refused); `undo_file`; `recover_at_start(root) -> RecoveryStatus` (never fails: an unreadable journal is `failed`), which the desktop shell runs on a background thread at start behind a `RecoveryGate` guard, keeping the result for the `recovery_status` command and logging it, and which `sc-cli process`/`apply`/`undo` run before writing; `collect_audio_files` never lists a file in the backup root (resolved once per call); - copy-to-folder mode: same steps, no backup, never replaces a file. Crash matrix behind the test-only `crash-test` feature + copy-to-folder mode: same steps, no backup, replaces only a copy SoundCheck wrote there when asked to and its sidecar proves it. Crash matrix behind the test-only `crash-test` feature batch artefacts (`sc_engine::export::artefacts`): one BatchRow per file in the order given (Written from the plan, the TxnReport and ProcessDone.grid; XmlOnly/Skipped from EngineEvent::ExportSkipped{outcome, duration, codec, grid}; Failed; Exported from a sidecar whose output hash still matches; Analysed), each with an XmlGrid{Grid{ExportedGrid, sample_rate} | NeedsReview | Absent} in the file's samples @@ -186,9 +186,10 @@ RENDER (streamed) - Selected track (`sc_engine::track`): decoded on its own thread into memory as 16-bit integers, in chunks of 65,536 frames that the waveform and the player read while later chunks still decode; each chunk keeps the folded min/max of every 1,024 frames, so an overview never walks the samples. Files over 20 minutes are held as mono (a one-hour mix takes 318 MB, not 635 MB), float files with overs are stored scaled down by their sample peak, and dropping the track stops its decoding. Waveform requests (`read_peaks{file_id, samples_per_bin, first_bin, bins} -> ipc::Response`) take a power of two from 8 frames per bin and at most 16,384 bins (64 KB), both channels folded into one lane, with bins not decoded yet (or not decodable) marked; samples are stored from -32,767 up so the mark never occurs in audio. A six-minute file decodes in 0.1 s (WAV) to 0.35 s (FLAC) on an M1 and 3,000 bins take under 0.1 ms (`benches/track.rs`). - 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), `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`): `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 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 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 2 KB of JSON whatever a file holds: 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. +- 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 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. - `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 00abeac..05bfe0c 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -19,9 +19,9 @@ SoundCheck levels a DJ library to one loudness and makes new tracks start exactl 1. **Analyse** WAV, AIFF, FLAC, MP3 (M4A/AAC/ALAC, Ogg/Opus analyse-only): loudness (I, max-M, max-S, **S-P95**, S-top30, LRA, true peak, PLR, timeline), beats/downbeats/BPM (0.01) with a three-state confidence and reason chips, **meter with grouping** (4/4, 3/4, 6/8, 9/8 aksak 2+2+2+3, 5/8, 7/8, 10/8), static-vs-drifts verdict with residual statistics, spec (rate/depth/channels/codec, DJ-safe flag), tags and cover art (read). 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). Switching files with Serato data to Library mode automatically (remembered per folder) arrives with the export panel. -5. **Export**: in place with backup (default) or to a folder; 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. +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). +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/docs/release-checklist.md b/docs/release-checklist.md index 900ff40..4693c9c 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -9,7 +9,7 @@ Run before tagging any `v0.x.0` or `-rc` (a patch release needs only the Files a ## Grid: the 20-track rekordbox protocol Owner-owned set (outside the repo), labelled once in rekordbox: BPM and first-downbeat time. Mix: house, techno, DnB, hip-hop; >= 5 MP3; >= 3 with Serato tags. - [ ] `sc-cli analyze --json` on the set: BPM within +/-0.02 of the label on >= 18/20 after at most one x2 / /2. -- [ ] Prepare-mode export of the lossless tracks with `sc-cli process`; record each output's `grid check:` line (the `grid_check` column of `grid-report.csv`) with the release notes. This is not a gate and no outcome blocks a release: the check is SoundCheck's detector reading its own output, and gain alone can move its bar 1 by a beat. The rekordbox criterion below is the gate. +- [ ] Prepare-mode export of the lossless tracks with `sc-cli process --out `; record each output's `grid check:` line (the `grid_check` column of `grid-report.csv`) with the release notes. This is not a gate and no outcome blocks a release: the check is SoundCheck's detector reading its own output, and gain alone can move its bar 1 by a beat. The rekordbox criterion below is the gate. - [ ] rekordbox 7, Track Analysis Mode Normal with the owner's BPM range (70-180), fresh import of the exported files (not XML): beat 1 is the first grid entry, within 15 ms of the start, same phase, BPM equal to the written value on >= 18/20. Record every miss with the file, rekordbox's BPM and first-beat time. Repeat one file with Cloud Analysis off. - [ ] XML path: import the batch XML (tracks, not only the playlist); confirm `Inizio`/`Bpm` match; enable Analysis Lock; export to USB; read `PQTZ` with pyrekordbox; record whether the grid survived. - [ ] Serato DJ Pro (Set Beatgrid/BPM on) and Traktor (Automatic range) on 5 tracks each: BPM equal, bar 1 on the downbeat; record cue drift on the Serato-tagged files (must be 0). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a08af0f..3a57d8e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,9 +11,9 @@ mod shell; use sc_core::analysis::GridEdit; use sc_core::ipc::{ - AnalyzeRequest, ExportRequest, FileEntry, ForgetResult, IpcError, IpcErrorKind, JobEvent, - JobId, Listen, RecoveryStatus, Replan, RevertResult, RowUpdate, SessionSnapshot, TrackEvent, - TrackOpened, WriteXmlRequest, XmlWritten, + AnalyzeRequest, ExportPreview, ExportRequest, FileEntry, ForgetResult, IpcError, IpcErrorKind, + JobEvent, JobId, Listen, RecoveryStatus, Replan, RevertResult, RowUpdate, SessionSnapshot, + TrackEvent, TrackOpened, WriteXmlRequest, XmlWritten, }; use sc_core::plan::DecideSettings; use sc_core::{DbFs, Lufs, SampleIndex}; @@ -70,6 +70,16 @@ async fn analyze( }) } +/// What exporting these files would do, for the confirm step; nothing is written. +#[tauri::command] +async fn plan_export( + shell: State<'_, Shell>, + req: ExportRequest, +) -> Result { + let shell = shell.inner().clone(); + blocking(move || shell.plan_export(&req)).await +} + /// Starts exporting files; refused while start-up recovery runs. Events arrive on `on_event`, /// the batch's artefacts and then `finished` last. #[tauri::command] @@ -303,6 +313,7 @@ pub fn run() { recovery_status, expand_paths, analyze, + plan_export, export, revert_file, write_rekordbox_xml, diff --git a/src-tauri/src/shell.rs b/src-tauri/src/shell.rs index 1ab2943..407fd24 100644 --- a/src-tauri/src/shell.rs +++ b/src-tauri/src/shell.rs @@ -9,7 +9,8 @@ use std::sync::{Arc, Mutex, PoisonError}; use sc_core::Lufs; use sc_core::analysis::AnalysisSettings; use sc_core::ipc::{ - AnalyzeRequest, FileEntry, IpcError, JobEvent, JobId, RecoveryStatus, Replan, SessionSnapshot, + AnalyzeRequest, FileEntry, IpcError, IpcErrorKind, JobEvent, JobId, RecoveryStatus, Replan, + SessionSnapshot, }; use sc_core::plan::{DecideSettings, check_bpm_range}; use sc_engine::player::Player; @@ -246,12 +247,13 @@ impl Shell { /// Runs a job on its own thread, its events going to `send`, holding `hold`'s files. The job /// leaves the running set and lets go of its files before its last event goes out, so a caller /// that has seen `finished` never finds it still cancellable or its files busy. A job that - /// panics lets go of them as its thread ends. + /// panics, or ends without `finished`, gets the same end from [`JobEnd`], so nothing waits for + /// it for ever. fn spawn_job( &self, job_id: JobId, hold: Hold, - mut send: impl FnMut(JobEvent) + Send + 'static, + send: impl FnMut(JobEvent) + Send + 'static, run: impl FnOnce(&Shell, &CancelToken, &mut dyn FnMut(JobEvent)) + Send + 'static, ) { let cancel = CancelToken::new(); @@ -260,17 +262,14 @@ impl Shell { std::thread::Builder::new() .name(format!("sc-job-{job_id}")) .spawn(move || { - let jobs = shell.clone(); - let mut hold = Some(hold); - let mut forward = |event: JobEvent| { - if matches!(event, JobEvent::Finished { .. }) { - jobs.jobs().remove(&job_id); - hold.take(); - } - send(event); + let mut end = JobEnd { + shell: shell.clone(), + job_id, + hold: Some(hold), + send, + finished: false, }; - run(&shell, &cancel, &mut forward); - shell.jobs().remove(&job_id); + run(&shell, &cancel, &mut |event| end.forward(event)); }) .expect("the OS can start a thread for a job"); } @@ -311,13 +310,23 @@ impl Shell { } /// Everything the session holds, for a window that reloads. Jobs still running are - /// cancelled: their events would go to a page that no longer listens. + /// cancelled: their events would go to a page that no longer listens. They end on their own + /// time, so the snapshot names them: a window reads it again until none is left. Read in this + /// order, a snapshot naming none holds all they did. #[must_use] pub fn restore(&self) -> SessionSnapshot { - for token in self.jobs().values() { - token.cancel(); + let mut running: Vec = { + let jobs = self.jobs(); + for token in jobs.values() { + token.cancel(); + } + jobs.keys().copied().collect() + }; + running.sort_unstable(); + SessionSnapshot { + running_jobs: running, + ..self.session().snapshot() } - self.session().snapshot() } /// The median S-P95 of the analysed rows, for "Calibrate from my library". @@ -327,6 +336,53 @@ impl Shell { } } +/// The end of a job: on `finished`, or if it never gets there, when its thread lets go of this. +struct JobEnd { + shell: Shell, + job_id: JobId, + hold: Option, + send: F, + finished: bool, +} + +impl JobEnd { + fn forward(&mut self, event: JobEvent) { + if matches!(event, JobEvent::Finished { .. }) { + self.finished = true; + self.let_go(); + } + (self.send)(event); + } + + fn let_go(&mut self) { + self.shell.jobs().remove(&self.job_id); + self.hold.take(); + } +} + +impl Drop for JobEnd { + fn drop(&mut self) { + if self.finished { + return; + } + self.let_go(); + tracing::error!(job_id = self.job_id, "a job ended without finishing"); + (self.send)(JobEvent::Aborted { + job_id: self.job_id, + error: IpcError { + kind: IpcErrorKind::Internal, + message: "the job stopped unexpectedly; restart SoundCheck to be sure of its files" + .to_owned(), + file_id: None, + }, + }); + (self.send)(JobEvent::Finished { + job_id: self.job_id, + cancelled: false, + }); + } +} + /// Runs the start-up recovery behind `gate`'s guard: whatever `recover` does, a panic included, /// the gate opens when it ends, so exports never wait forever. fn run_recovery(gate: &Arc, recover: impl FnOnce() -> RecoveryStatus) { diff --git a/src-tauri/src/shell/export.rs b/src-tauri/src/shell/export.rs index f3c306b..b327c43 100644 --- a/src-tauri/src/shell/export.rs +++ b/src-tauri/src/shell/export.rs @@ -1,23 +1,26 @@ -//! The export commands' bodies: starting an export, reverting a file, writing a rekordbox XML of -//! analysed rows and forgetting a change recovery left pending. Every one of them is refused while -//! the start-up recovery runs: the engine would only wait for it, and the user is better told. +//! The export commands' bodies: saying what an export would do, starting one, reverting a file, +//! writing a rekordbox XML of analysed rows and forgetting a change recovery left pending. Every +//! one of them is refused while the start-up recovery runs: the engine would only wait for it, and +//! the user is better told. +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; -use sc_core::Error; -use sc_core::analysis::AnalysisRecord; use sc_core::export::Place as ExportPlace; use sc_core::ipc::{ - ExportArtefacts, ExportRequest, ForgetResult, IpcError, IpcErrorKind, JobEvent, JobId, - RecoveryStatus, RevertResult, WriteXmlRequest, XmlNotListed, XmlWritten, + ExportArtefacts, ExportPreview, ExportPreviewRow, ExportRequest, ExportedWith, ForgetResult, + IpcError, IpcErrorKind, JobEvent, JobId, RecoveryStatus, RevertResult, WriteXmlRequest, + XmlNotListed, XmlWritten, }; +use sc_core::{ChangeCause, Error}; use sc_engine::export::{ - ExportJob, XmlOutRefusal, analysed_row, artefact_conflict, export_artefacts, failed_row, - new_artefacts_folder, not_listed, run_export_job, xml_out_refusal, xml_tracks, + ExportJob, PreviewInput, XmlOutRefusal, analysed_row, artefact_conflict, export_artefacts, + failed_row, new_artefacts_folder, not_listed, preview_row, run_export_job, tracks_listed_in, + xml_out_refusal, xml_tracks, }; use sc_engine::{ - Analyzer, BatchFile, BatchRow, BatchSettings, CancelToken, EditState, EngineEvent, Place, - ProcessSettings, Task, XmlSelect, apply_saved, check_inputs, run_batch, undo_file, + Analyzer, BatchFile, BatchRow, BatchSettings, CancelToken, EngineEvent, Place, ProcessSettings, + RevertedAnalysis, Task, XmlSelect, apply_saved, check_inputs, run_batch, undo_file, write_artefacts, }; use sc_io::rekordbox::is_soundcheck_xml; @@ -68,12 +71,18 @@ impl Shell { }) } - /// The rows `file_ids` name, in that order, each named once however it is spelled on disk. + /// 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. /// /// # Errors /// `invalidArgument` for no file or a row the session does not hold; `listedTwice` for a - /// file named twice; `sameOutputName` for two files one folder would hold under one name. - fn distinct_rows(&self, file_ids: &[u32], place: &Place) -> Result, IpcError> { + /// file named twice. + fn distinct_rows( + &self, + file_ids: &[u32], + place: &Place, + ) -> Result<(Vec, BTreeSet), IpcError> { if file_ids.is_empty() { return Err(Error::InvalidArgument("no files were named".into()).into()); } @@ -88,14 +97,17 @@ impl Shell { session.batch(file_ids) }; let paths: Vec = files.iter().map(|f| f.path.clone()).collect(); - let refused = check_inputs(&paths, place) - .into_iter() - .zip(&files) - .find_map(|(refusal, file)| refusal.map(|e| about(file.file_id, e))); - match refused { - Some(error) => Err(error), - None => Ok(files), + let mut same_name = BTreeSet::new(); + for (refusal, file) in check_inputs(&paths, place).into_iter().zip(&files) { + match refusal { + Some(Error::SameOutputName { .. }) => { + same_name.insert(file.file_id); + } + Some(error) => return Err(about(file.file_id, error)), + None => {} + } } + Ok((files, same_name)) } /// Refuses a folder nothing is written into, whatever the rows do: inside the backups, which @@ -115,40 +127,50 @@ impl Shell { Ok(()) } - /// Starts exporting `req.file_ids`, sending every event to `send`; returns at once with the - /// job's id. A running job is cancelled with [`Shell::cancel`]. The grid view closes if it - /// shows a file about to be replaced. + /// What an export of `req` needs, once everything that refuses it before anything is written + /// has had its say. /// /// # Errors - /// Before anything is written: `recoveryRunning` while the start-up recovery runs; - /// `invalidArgument` for settings out of their limits or a folder that does not fit the - /// place; what [`Self::distinct_rows`] and [`Self::refuse_destination`] refuse; `alreadyExists` - /// when a file SoundCheck did not write holds the name of an artefact in the folder; `busy` - /// when another job or command is working on one of the files. - pub fn export( + /// See [`Self::export`], except `busy`. + fn checked_export( &self, - req: ExportRequest, - send: impl FnMut(JobEvent) + Send + 'static, - ) -> Result { + req: &ExportRequest, + ) -> Result<(ProcessSettings, Vec, u32), IpcError> { self.refuse_while_recovering()?; let out_dir = req .out_dir .as_deref() .map(|dir| absolute(dir, "the export folder")) .transpose()?; + // One read, so the numbers and the revision they are checked by go together. + let (decide, revision) = { + let session = self.session(); + (session.settings(), session.revision()) + }; let process = ProcessSettings { - decide: self.session().settings(), + decide, export: req.settings, out_dir: out_dir.clone(), backup_root: self.backup_root()?, edits: self.inner.edits.clone(), recovery: self.inner.recovery.clone(), + confirmed: req.confirmed.as_ref().map(|c| { + c.files + .iter() + .map(|f| (f.file_id, f.identity.clone())) + .collect() + }), + same_name: BTreeSet::new(), }; process.validate()?; let place = out_dir .as_ref() .map_or(Place::InPlace, |dir| Place::Folder(dir.clone())); - let files = self.distinct_rows(&req.file_ids, &place)?; + let (files, same_name) = self.distinct_rows(&req.file_ids, &place)?; + let process = ProcessSettings { + same_name, + ..process + }; if let Some(dir) = &out_dir { if dir.exists() && !dir.is_dir() { return Err( @@ -162,6 +184,140 @@ impl Shell { return Err(conflict.error.into()); } } + Ok((process, files, revision)) + } + + /// 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. + /// + /// # 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 rows = files + .iter() + .map(|file| self.preview_of(file, &process, req)) + .collect(); + let shown = |dir: &Path| dir.display().to_string(); + let artefacts_folder = if req.settings.xml { + match &process.out_dir { + Some(dir) => Some(shown(dir)), + None => self.inner.roots.exports.as_deref().map(shown), + } + } else { + None + }; + let xml_listed = process + .out_dir + .as_deref() + .filter(|_| req.settings.xml) + .map_or(0, tracks_listed_in); + Ok(ExportPreview { + rows, + revision, + decide: process.decide, + xml_listed: u32::try_from(xml_listed).unwrap_or(u32::MAX), + backup_folder: process + .out_dir + .is_none() + .then(|| shown(&process.backup_root)), + artefacts_folder, + }) + } + + /// What exporting `file` would do, or why that cannot be said. + fn preview_of( + &self, + file: &BatchFile, + process: &ProcessSettings, + req: &ExportRequest, + ) -> ExportPreviewRow { + let file_id = file.file_id; + let failed = |error: Error| ExportPreviewRow { + file_id, + row: None, + error: Some(about(file_id, error)), + identity: None, + replaces_copy: false, + }; + let Some(planning) = self.session().planning_row(file_id) else { + return failed(Error::InvalidArgument(format!( + "{} is not analysed yet", + file.path.display() + ))); + }; + // Read first: a file that changes during the planning is told apart by the job. + let identity = match sc_io::txn::file_identity(&file.path) { + Ok(identity) => identity, + Err(error) => return failed(error), + }; + if planning.identity.as_ref().is_some_and(|i| *i != identity) { + return failed(Error::FileChanged { + path: file.path.clone(), + detail: "since it was analysed; press Analyse to read it again".to_owned(), + cause: ChangeCause::SincePlanned, + }); + } + let input = PreviewInput { + path: &file.path, + record: &planning.record, + edit: planning.edit, + decide: &process.decide, + settings: &req.settings, + out_dir: process.out_dir.as_deref(), + revertible: planning.revertible, + same_name: process.same_name.contains(&file_id), + }; + match preview_row(&input) { + Ok((mut row, replaces_copy)) => { + row.exported_with = Some(ExportedWith::new( + process.decide, + req.settings, + process.out_dir.as_deref(), + )); + ExportPreviewRow { + file_id, + row: Some(row), + error: None, + identity: Some(identity), + replaces_copy, + } + } + Err(error) => failed(error), + } + } + + /// Starts exporting `req.file_ids`, sending every event to `send`; returns at once with the + /// job's id. A running job is cancelled with [`Shell::cancel`]. The grid view closes if it + /// shows a file about to be replaced. + /// + /// # Errors + /// Before anything is written: `recoveryRunning` while the start-up recovery runs; + /// `invalidArgument` for settings out of their limits or a folder that does not fit the + /// place; what [`Self::distinct_rows`] and [`Self::refuse_destination`] refuse; `alreadyExists` + /// when a file SoundCheck did not write holds the name of an artefact in the folder; `busy` + /// when another job or command is working on one of the files. + pub fn export( + &self, + req: ExportRequest, + send: impl FnMut(JobEvent) + Send + 'static, + ) -> Result { + let (process, files, revision) = self.checked_export(&req)?; + if req + .confirmed + .as_ref() + .is_some_and(|c| c.revision != revision) + { + return Err(IpcError { + kind: IpcErrorKind::SettingsChanged, + message: "the loudness settings changed after this plan; nothing was done" + .to_owned(), + file_id: None, + }); + } let kind = if req.settings.place == ExportPlace::InPlace { HoldKind::ExportInPlace } else { @@ -211,7 +367,7 @@ impl Shell { pub(super) fn revert_analysing( &self, file_id: u32, - analyse: impl FnOnce(&Path) -> sc_core::Result<(AnalysisRecord, EditState)>, + analyse: impl FnOnce(&Path) -> sc_core::Result, ) -> Result { self.refuse_while_recovering()?; let root = self.backup_root()?; @@ -253,20 +409,26 @@ impl Shell { } /// The analysis of the file at `path`, with its saved edit applied. - fn analyse_original(&self, path: &Path) -> sc_core::Result<(AnalysisRecord, EditState)> { + fn analyse_original(&self, path: &Path) -> sc_core::Result { let mut analyzer = Analyzer::load( self.analysis_settings(), self.inner.cache.clone(), CancelToken::new(), )?; - let mut record = analyzer.analyze(path)?.record; + let report = analyzer.analyze(path)?; + let mut record = report.record; let edit = self .inner .edits .as_ref() .map(|store| apply_saved(&mut record, store)) .unwrap_or_default(); - Ok((record, edit)) + Ok(RevertedAnalysis { + record, + edit, + identity: report.identity, + bpm_range: report.bpm_range, + }) } /// Writes a rekordbox XML of the files `req.file_ids`, as `sc-cli xml` does: each analysed @@ -306,10 +468,22 @@ impl Shell { self.refuse_destination(dir)?; } } - let files = self.distinct_rows(&req.file_ids, &Place::InPlace)?; + let (files, _) = self.distinct_rows(&req.file_ids, &Place::InPlace)?; let _hold = self.hold(&files, HoldKind::Reading)?; + // A file exported to a folder is listed as that copy, which is what the export made. + let files: Vec = { + let session = self.session(); + files + .into_iter() + .map(|file| match session.exported_copy(file.file_id) { + Some(copy) if copy.is_file() => BatchFile { path: copy, ..file }, + _ => file, + }) + .collect() + }; let rows = self.analysed_rows(&files)?; let artefacts = self.write_xml_rows(&rows, out.as_deref())?; + self.session().set_artefacts(Some(Ok(artefacts.clone()))); let not_listed = not_listed(&rows, XmlSelect::All) .into_iter() .map(|(i, why)| XmlNotListed { diff --git a/src-tauri/src/shell/tests.rs b/src-tauri/src/shell/tests.rs index d6b8ab0..30248c1 100644 --- a/src-tauri/src/shell/tests.rs +++ b/src-tauri/src/shell/tests.rs @@ -1,5 +1,6 @@ //! Unit tests of the private parts of `src-tauri/src/shell.rs`; those of the export commands are -//! in `tests/export.rs`, `tests/commands.rs`, `tests/holds.rs` and, with the grid, `tests/grid.rs`. +//! in `tests/export.rs`, `tests/preview.rs`, `tests/commands.rs`, `tests/holds.rs` and, with the +//! grid, `tests/grid.rs`. use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::Duration; @@ -14,6 +15,7 @@ mod commands; mod export; mod grid; mod holds; +mod preview; mod support; fn tone_wav(dir: &Path, name: &str, amp: f32) { diff --git a/src-tauri/src/shell/tests/commands.rs b/src-tauri/src/shell/tests/commands.rs index 40d9dcc..a6567af 100644 --- a/src-tauri/src/shell/tests/commands.rs +++ b/src-tauri/src/shell/tests/commands.rs @@ -452,6 +452,7 @@ fn events_of_an_unfinished_analysis(wav: &Path) -> [EngineEvent; 3] { artist: None, }, analysis: None, + analysed: None, edit: EditState::default(), edit_carried: false, grid_check: GridCheck::NotChecked { diff --git a/src-tauri/src/shell/tests/export.rs b/src-tauri/src/shell/tests/export.rs index a1219b4..12f56c0 100644 --- a/src-tauri/src/shell/tests/export.rs +++ b/src-tauri/src/shell/tests/export.rs @@ -300,14 +300,50 @@ fn duplicate_paths_are_refused_before_anything_is_written() { std::fs::create_dir_all(&other_dir).unwrap(); let same_name = other_dir.join("a.wav"); tone_wav(&same_name, 0.2, 44_100); - let second = shell.expand(vec![same_name.display().to_string()]); - let pair = [ids[0], second[0].file_id]; + let second = analysed(&shell, &[&same_name]); + let pair = [ids[0], second[0]]; + let other_before = sha256(&same_name); let out = lib.base().join("out"); - let err = shell - .export(request(&pair, in_folder(prepare()), Some(&out)), |_| {}) - .unwrap_err(); - assert_eq!(err.kind, IpcErrorKind::SameOutputName); - assert!(!out.exists(), "nothing was written"); + let (_, events) = export(&shell, request(&pair, in_folder(prepare()), Some(&out))); + // The first in the list is written, and the later one skipped with why. + assert!( + steps(&events, pair[0]).contains(&"exportDone"), + "{events:?}" + ); + let skipped = events.iter().find_map(|e| match e { + JobEvent::ExportSkipped { file_id, row, .. } if *file_id == pair[1] => Some(row.clone()), + _ => None, + }); + let skipped = skipped.expect("skipped"); + assert_eq!( + skipped.action, + ExportAction::Skip { + reason: ExportSkip::CopyOfOtherTrack + } + ); + assert!( + skipped.notes[0].contains("another track"), + "{:?}", + skipped.notes + ); + assert_eq!(sha256(&same_name), other_before, "the source is untouched"); + let preview = shell + .plan_export(&request( + &pair, + in_folder(prepare()), + Some(&lib.base().join("fresh")), + )) + .unwrap(); + assert!(matches!( + preview.rows[1].row.as_ref().unwrap().action, + ExportAction::Skip { + reason: ExportSkip::CopyOfOtherTrack + } + )); + assert!(matches!( + preview.rows[0].row.as_ref().unwrap().action, + ExportAction::Write { .. } + )); } #[test] diff --git a/src-tauri/src/shell/tests/grid.rs b/src-tauri/src/shell/tests/grid.rs index 071d469..4b7f707 100644 --- a/src-tauri/src/shell/tests/grid.rs +++ b/src-tauri/src/shell/tests/grid.rs @@ -288,6 +288,104 @@ fn an_export_reads_the_file_again_so_serato_data_added_since_the_row_was_probed_ ); } +#[test] +fn the_confirm_step_plans_the_cut_the_job_makes() { + if !have_models() { + return; + } + let lib = Lib::new(); + let wav = lib.music().join("click.wav"); + click_wav(&wav); + let shell = lib.shell(1); + let id = analysed_click(&shell, &wav); + confirm(&shell, id); + shell.close_track(); + + let preview = shell.plan_export(&request(&[id], prepare(), None)).unwrap(); + let row = preview.rows[0].row.as_ref().expect("planned"); + assert!(row.positions_shift, "{row:?}"); + assert!(matches!( + row.action, + ExportAction::Write { + cut: Cut::Cut { .. }, + .. + } + )); + + let (_, events) = export(&shell, request(&[id], prepare(), None)); + let planned = events.iter().find_map(|e| match e { + JobEvent::Processing { row, .. } => Some(row.action.clone()), + _ => None, + }); + assert_eq!( + planned.as_ref(), + Some(&row.action), + "the cut, to the sample" + ); +} + +#[test] +fn the_xml_button_lists_an_exported_copy_where_it_is() { + if !have_models() { + return; + } + let lib = Lib::new(); + let wav = lib.music().join("click.wav"); + click_wav(&wav); + let shell = lib.shell(1); + let id = analysed_click(&shell, &wav); + confirm(&shell, id); + shell.close_track(); + let out = lib.base().join("copies"); + let (_, events) = export(&shell, request(&[id], in_folder(prepare()), Some(&out))); + assert!(steps(&events, id).contains(&"exportDone"), "{events:?}"); + + let xml = lib.base().join("rebuilt.xml"); + let written = shell + .write_xml(WriteXmlRequest { + file_ids: vec![id], + out: Some(xml.display().to_string()), + }) + .unwrap(); + + assert_eq!(written.artefacts.listed, 1); + let text = std::fs::read_to_string(&xml).unwrap(); + assert!(text.contains("copies/click.wav"), "{text}"); + assert!(!text.contains("music/click.wav"), "{text}"); +} + +#[test] +fn a_saved_grid_marks_the_export_stale_only_when_it_differs() { + if !have_models() { + return; + } + let lib = Lib::new(); + let wav = lib.music().join("click.wav"); + click_wav(&wav); + let shell = lib.shell(1); + let id = analysed_click(&shell, &wav); + assert!(!confirm(&shell, id).export_stale, "no export yet"); + shell.close_track(); + let out = lib.base().join("copies"); + export(&shell, request(&[id], in_folder(prepare()), Some(&out))); + assert!(!shell.restore().rows[0].export_stale); + + // Confirmed again as it was: nothing new to write. + assert!(!confirm(&shell, id).export_stale); + shell.close_track(); + assert!(!shell.restore().rows[0].export_stale); + + // Another bar 1: the export no longer says what the row says, also after a reload. + shell.open_track(id, |_| {}).unwrap(); + let edit = GridEdit { + anchor: Some(sc_core::SampleIndex(20_000)), + ..GridEdit::default() + }; + assert!(shell.commit(id, &edit, true).unwrap().export_stale); + shell.close_track(); + assert!(shell.restore().rows[0].export_stale); +} + #[test] fn a_grid_view_opens_during_a_copy_to_a_folder_whose_source_does_not_change() { if !have_models() { diff --git a/src-tauri/src/shell/tests/holds.rs b/src-tauri/src/shell/tests/holds.rs index 0434c01..0b58923 100644 --- a/src-tauri/src/shell/tests/holds.rs +++ b/src-tauri/src/shell/tests/holds.rs @@ -303,3 +303,53 @@ fn a_job_that_panics_releases_its_files() { }); assert!(released, "still held after the job's thread ended"); } + +#[test] +fn a_job_that_panics_still_ends_and_lets_go_of_its_files() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let files = shell.session().batch(&ids); + let hold = shell.hold(&files, HoldKind::ExportInPlace).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + shell.spawn_job( + 77, + hold, + move |e| { + let _ = tx.send(e); + }, + |_, _, _| panic!("outside the workers' catch_unwind"), + ); + + let events = events_until_finished(&rx); + assert!( + matches!( + &events[..], + [ + JobEvent::Aborted { job_id: 77, .. }, + JobEvent::Finished { job_id: 77, .. } + ] + ), + "{events:?}" + ); + assert!(shell.restore().running_jobs.is_empty(), "no longer running"); + // Its files are free again. + let (_, events) = export(&shell, request(&ids, prepare(), None)); + assert!(steps(&events, ids[0]).contains(&"exportDone")); +} + +#[test] +fn a_job_that_ends_with_its_own_finished_gets_no_second_one() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let (_, events) = export(&shell, request(&ids, prepare(), None)); + let finished = events + .iter() + .filter(|e| matches!(e, JobEvent::Finished { .. })) + .count(); + assert_eq!(finished, 1); + assert!(!events.iter().any(|e| matches!(e, JobEvent::Aborted { .. }))); +} diff --git a/src-tauri/src/shell/tests/preview.rs b/src-tauri/src/shell/tests/preview.rs new file mode 100644 index 0000000..3f14fdb --- /dev/null +++ b/src-tauri/src/shell/tests/preview.rs @@ -0,0 +1,596 @@ +//! The confirm step's `plan_export` and what a window that reloads reads: synthetic tones, loudness +//! only (no model). + +use std::path::Path; + +use sc_core::export::{ExportSettings, ExportSkip}; +use sc_core::ipc::{ + ConfirmedExport, ConfirmedFile, ExportAction, ExportPreview, ExportRequest, IpcErrorKind, + JobEvent, JobStage, +}; + +use super::support::{ + Lib, analysed, events_until_finished, export, held_at, in_folder, loudness_only, prepare, + request, sha256, steps, +}; + +fn at_processing(e: &JobEvent) -> bool { + matches!(e, JobEvent::Processing { .. }) +} + +fn at_written(e: &JobEvent) -> bool { + matches!(e, JobEvent::Written { .. }) +} + +fn no_xml(settings: ExportSettings) -> ExportSettings { + ExportSettings { + xml: false, + ..settings + } +} + +#[test] +fn plan_export_says_what_the_job_then_does_and_writes_nothing() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let hi_res = lib.tone_at("hi-res.wav", 0.1, 96_000); + let shell = lib.shell(2); + let ids = analysed(&shell, &[&wav, &hi_res]); + let before = (sha256(&wav), sha256(&hi_res)); + + let preview = shell.plan_export(&request(&ids, prepare(), None)).unwrap(); + + assert_eq!( + (sha256(&wav), sha256(&hi_res)), + before, + "nothing was written" + ); + assert!(!lib.exports().exists(), "no folder was made"); + assert!(!lib.backups().exists(), "nothing was journaled"); + assert_eq!( + preview.backup_folder.as_deref(), + Some(lib.backups().display().to_string().as_str()) + ); + assert_eq!( + preview.artefacts_folder.as_deref(), + Some(lib.exports().display().to_string().as_str()) + ); + let row = |i: usize| preview.rows[i].row.as_ref().expect("planned"); + assert_eq!(preview.rows[0].file_id, ids[0]); + assert_eq!(row(0).stage, JobStage::Processing); + assert_eq!(row(1).stage, JobStage::Skipped); + assert!( + matches!( + row(1).action, + ExportAction::Skip { + reason: ExportSkip::NotDjSafeRate { .. } + } + ), + "{:?}", + row(1).action + ); + assert!(row(1).notes[0].contains("96000 Hz"), "{:?}", row(1).notes); + + // The same action, gain and all, as the job then reports. + let (_, events) = export(&shell, request(&ids, prepare(), None)); + let planned = events.iter().find_map(|e| match e { + JobEvent::Processing { row, .. } => Some(row.action.clone()), + _ => None, + }); + let skipped = events.iter().find_map(|e| match e { + JobEvent::ExportSkipped { row, .. } => Some(row.as_ref().clone()), + _ => None, + }); + assert_eq!(planned.as_ref(), Some(&row(0).action)); + assert_eq!( + skipped.as_ref(), + Some(row(1).as_ref()), + "the row, wording included" + ); +} + +#[test] +fn plan_export_names_the_folders_the_settings_use() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let out = lib.base().join("copies"); + + let to_folder = shell + .plan_export(&request(&ids, in_folder(prepare()), Some(&out))) + .unwrap(); + assert_eq!(to_folder.backup_folder, None, "a copy backs nothing up"); + assert_eq!( + to_folder.artefacts_folder.as_deref(), + Some(out.display().to_string().as_str()) + ); + assert!(!out.exists(), "not made by asking"); + + let xml_off = shell + .plan_export(&request(&ids, no_xml(prepare()), None)) + .unwrap(); + assert_eq!(xml_off.artefacts_folder, None); +} + +#[test] +fn plan_export_refuses_what_the_export_refuses() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + + // A folder inside the backups. + let refused = shell + .plan_export(&request( + &ids, + in_folder(prepare()), + Some(&lib.backups().join("x")), + )) + .unwrap_err(); + assert_eq!(refused.kind, IpcErrorKind::InvalidArgument, "{refused:?}"); + // A file named twice. + let refused = shell + .plan_export(&request(&[ids[0], ids[0]], prepare(), None)) + .unwrap_err(); + assert_eq!(refused.kind, IpcErrorKind::ListedTwice, "{refused:?}"); + + // During recovery, on the same refusal as the commands around it. + let recovering = lib.shell_recovering(1); + let ids = analysed(&recovering, &[&wav]); + let refused = recovering + .plan_export(&request(&ids, prepare(), None)) + .unwrap_err(); + assert_eq!(refused.kind, IpcErrorKind::RecoveryRunning, "{refused:?}"); +} + +#[test] +fn plan_export_is_refused_while_the_file_is_being_exported() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + + let held = held_at(&shell, request(&ids, prepare(), None), at_processing); + let refused = shell + .plan_export(&request(&ids, prepare(), None)) + .unwrap_err(); + assert_eq!(refused.kind, IpcErrorKind::Busy, "{refused:?}"); + held.finish(); + + // The hold it takes is its own: nothing stays busy after it answers. + shell.plan_export(&request(&ids, prepare(), None)).unwrap(); + let (_, events) = export(&shell, request(&ids, prepare(), None)); + assert!(steps(&events, ids[0]).contains(&"exportDone")); +} + +#[test] +fn a_row_not_analysed_yet_says_so_on_its_row() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let id = shell.expand(vec![wav.display().to_string()])[0].file_id; + + let preview = shell.plan_export(&request(&[id], prepare(), None)).unwrap(); + + assert_eq!(preview.rows[0].row, None); + let error = preview.rows[0].error.as_ref().expect("an error"); + assert_eq!(error.file_id, Some(id)); + assert!(error.message.contains("not analysed"), "{error:?}"); +} + +#[test] +fn a_reload_during_an_export_is_told_the_job_runs_and_then_finds_its_artefacts() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let out = lib.base().join("copies"); + let req = request(&ids, in_folder(prepare()), Some(&out)); + + // Past the point where a cancel can stop the file: it ends as written, and the batch has + // artefacts the old page's channel would have reported. + let held = held_at(&shell, req, at_written); + let during = shell.restore(); + assert_eq!(during.running_jobs.len(), 1, "named while it winds down"); + assert_eq!( + during.rows[0].export.as_ref().map(|r| r.stage), + Some(JobStage::Writing), + "the row in flight" + ); + assert_eq!(during.artefacts, None, "not written yet"); + + let events = held.finish(); + + // The page that reloaded reads again: no job left, and what the old page was told is here too. + let after = shell.restore(); + assert_eq!(after.running_jobs, Vec::::new()); + let told = events.iter().find_map(|e| match e { + JobEvent::Artefacts { artefacts, .. } => Some(artefacts.clone()), + _ => None, + }); + assert!(told.is_some(), "{events:?}"); + assert_eq!(after.artefacts, told); + assert_eq!(after.artefacts_failed, None); +} + +#[test] +fn the_next_batch_or_clearing_the_list_drops_the_artefacts() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let out = lib.base().join("copies"); + + export(&shell, request(&ids, in_folder(prepare()), Some(&out))); + assert!(shell.restore().artefacts.is_some()); + // The next batch has none, with its XML off. + export( + &shell, + request( + &ids, + no_xml(in_folder(prepare())), + Some(&lib.base().join("more")), + ), + ); + assert_eq!(shell.restore().artefacts, None); + + export( + &shell, + request(&ids, in_folder(prepare()), Some(&lib.base().join("again"))), + ); + assert!(shell.restore().artefacts.is_some()); + shell.clear(); + assert_eq!(shell.restore().artefacts, None); +} + +/// The request sent back after `preview`: what the user confirmed. +fn confirmed_of(mut req: ExportRequest, preview: &ExportPreview) -> ExportRequest { + req.confirmed = Some(ConfirmedExport { + revision: preview.revision, + files: preview + .rows + .iter() + .map(|r| ConfirmedFile { + file_id: r.file_id, + identity: r.identity.clone().expect("planned"), + }) + .collect(), + }); + req +} + +/// Another tone of a different level, of another length so the file is told apart at once. +fn rewrite(wav: &Path) { + super::support::tone_wav(wav, 0.5, 44_100); + let mut bytes = std::fs::read(wav).unwrap(); + bytes.extend_from_slice(&[0; 2]); + std::fs::write(wav, &bytes).unwrap(); +} + +#[test] +fn a_file_changed_after_confirm_fails_its_row_and_is_not_written() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let other = lib.tone("b.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav, &other]); + let req = request(&ids, prepare(), None); + let preview = shell.plan_export(&req).unwrap(); + let req = confirmed_of(req, &preview); + + rewrite(&wav); + let rewritten = sha256(&wav); + let (_, events) = export(&shell, req); + + assert_eq!(sha256(&wav), rewritten, "left as it is"); + let refused = events.iter().find_map(|e| match e { + JobEvent::Failed { file_id, error, .. } if *file_id == ids[0] => Some(error), + _ => None, + }); + let refused = refused.expect("failed"); + assert_eq!(refused.kind, IpcErrorKind::FileChanged, "{refused:?}"); + assert!( + refused.message.contains("since you confirmed"), + "{refused:?}" + ); + // The other file of the batch goes on. + assert!(steps(&events, ids[1]).contains(&"exportDone")); +} + +#[test] +fn a_file_unchanged_since_confirm_is_written() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let req = request(&ids, prepare(), None); + let preview = shell.plan_export(&req).unwrap(); + let (_, events) = export(&shell, confirmed_of(req, &preview)); + assert!(steps(&events, ids[0]).contains(&"exportDone"), "{events:?}"); +} + +#[test] +fn the_preview_notices_a_file_that_changed_since_its_analysis() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + rewrite(&wav); + let preview = shell.plan_export(&request(&ids, prepare(), None)).unwrap(); + assert_eq!(preview.rows[0].row, None); + let error = preview.rows[0].error.as_ref().expect("an error"); + assert_eq!(error.kind, IpcErrorKind::FileChanged); + assert!(error.message.contains("since it was analysed"), "{error:?}"); +} + +#[test] +fn the_job_refuses_settings_that_changed_after_the_plan() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let before = sha256(&wav); + let req = request(&ids, prepare(), None); + let preview = shell.plan_export(&req).unwrap(); + assert!((preview.decide.target.0 + 11.0).abs() < 1e-9); + + let mut decide = preview.decide; + decide.target = sc_core::Lufs(-8.0); + shell.set_decide_settings(decide).unwrap(); + + let refused = shell + .export(confirmed_of(req, &preview), |_| {}) + .unwrap_err(); + assert_eq!(refused.kind, IpcErrorKind::SettingsChanged, "{refused:?}"); + assert_eq!(sha256(&wav), before, "nothing was written"); +} + +#[test] +fn the_preview_says_which_copies_it_replaces_and_which_files_it_leaves() { + let lib = Lib::new(); + let (a, b, c) = ( + lib.tone("a.wav", 0.1), + lib.tone("b.wav", 0.1), + lib.tone("c.wav", 0.1), + ); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&a, &b, &c]); + let out = lib.base().join("copies"); + let (_, events) = export(&shell, request(&ids[..1], in_folder(prepare()), Some(&out))); + assert!(steps(&events, ids[0]).contains(&"exportDone")); + std::fs::write(out.join("b.wav"), b"someone else's").unwrap(); + + let preview = shell + .plan_export(&request(&ids, in_folder(prepare()), Some(&out))) + .unwrap(); + + assert!(preview.rows[0].replaces_copy, "an earlier copy"); + assert!(!preview.rows[1].replaces_copy && !preview.rows[2].replaces_copy); + let action = |i: usize| preview.rows[i].row.as_ref().unwrap().action.clone(); + assert!(matches!(action(0), ExportAction::Write { .. })); + assert_eq!( + action(1), + ExportAction::Skip { + reason: ExportSkip::OtherFileInFolder + } + ); + assert!(matches!(action(2), ExportAction::Write { .. })); + assert_eq!(std::fs::read(out.join("b.wav")).unwrap(), b"someone else's"); +} + +#[test] +fn a_row_says_what_it_was_exported_with() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let out = lib.base().join("copies"); + export(&shell, request(&ids, in_folder(prepare()), Some(&out))); + let snapshot = shell.restore(); + let row = snapshot.rows[0].export.as_ref().expect("exported"); + let with = row.exported_with.as_ref().expect("recorded"); + assert!((with.decide.target.0 + 11.0).abs() < 1e-9); + assert_eq!(with.settings.place, sc_core::export::Place::Folder); + assert_eq!( + with.out_dir.as_deref(), + Some(out.display().to_string().as_str()) + ); +} + +#[test] +fn the_snapshot_holds_what_became_of_the_latest_batch() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let hi_res = lib.tone_at("hi-res.wav", 0.1, 96_000); + let shell = lib.shell(2); + let ids = analysed(&shell, &[&wav, &hi_res]); + assert_eq!(shell.restore().last_export, None, "only an export counts"); + + let out = lib.base().join("copies"); + let (job, _) = export(&shell, request(&ids, in_folder(prepare()), Some(&out))); + + let summary = shell.restore().last_export.expect("kept"); + assert_eq!(summary.job_id, job); + assert_eq!( + (summary.written, summary.skipped, summary.failed), + (1, 1, 0) + ); + assert!(!summary.cancelled); + shell.clear(); + assert_eq!(shell.restore().last_export, None); +} + +#[test] +fn a_cancelled_file_is_not_counted_as_failed() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let held = held_at(&shell, request(&ids, prepare(), None), at_processing); + let _ = shell.restore(); + held.finish(); + let summary = shell.restore().last_export.expect("kept"); + assert_eq!((summary.written, summary.failed), (0, 0)); + assert!(summary.cancelled); +} + +#[test] +fn an_artefact_of_ours_that_cannot_be_read_refuses_the_plan_and_the_job() { + let lib = Lib::new(); + let a = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&a]); + let out = lib.base().join("copies"); + export(&shell, request(&ids, in_folder(prepare()), Some(&out))); + let report = out.join(sc_io::report::REPORT_FILE_NAME); + let mut bytes = std::fs::read(&report).unwrap(); + bytes.extend_from_slice(b"a,short,row\r\n"); + std::fs::write(&report, &bytes).unwrap(); + + let req = request(&ids, in_folder(prepare()), Some(&out)); + for refused in [ + shell.plan_export(&req).unwrap_err(), + shell.export(req.clone(), |_| {}).unwrap_err(), + ] { + assert_eq!(refused.kind, IpcErrorKind::Corrupt, "{refused:?}"); + assert!(refused.message.contains("grid-report.csv"), "{refused:?}"); + assert!( + refused.message.contains("choose another folder"), + "{refused:?}" + ); + } + assert_eq!(std::fs::read(&report).unwrap(), bytes, "left as it is"); +} + +#[test] +fn an_artefact_that_turns_unreadable_during_the_job_is_failed_not_written_over() { + let lib = Lib::new(); + let a = lib.tone("a.wav", 0.1); + let b = lib.tone("b.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&a, &b]); + let out = lib.base().join("copies"); + export(&shell, request(&ids[..1], in_folder(prepare()), Some(&out))); + let report = out.join(sc_io::report::REPORT_FILE_NAME); + + let held = held_at( + &shell, + request(&ids[1..], in_folder(prepare()), Some(&out)), + at_written, + ); + let mut bytes = std::fs::read(&report).unwrap(); + bytes.extend_from_slice(b"a,short,row\r\n"); + std::fs::write(&report, &bytes).unwrap(); + let events = held.finish(); + + assert!( + events + .iter() + .any(|e| matches!(e, JobEvent::ArtefactsFailed { .. })), + "{events:?}" + ); + assert_eq!(std::fs::read(&report).unwrap(), bytes, "left as it is"); + assert!( + steps(&events, ids[1]).contains(&"exportDone"), + "the file is written all the same" + ); +} + +#[test] +fn an_unchanged_link_exports_and_a_changed_target_is_refused() { + let lib = Lib::new(); + let real = lib.tone("real.wav", 0.1); + let link = lib.music().join("link.wav"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&link]); + let out = lib.base().join("copies"); + + let req = request(&ids, in_folder(prepare()), Some(&out)); + let preview = shell.plan_export(&req).unwrap(); + assert!(preview.rows[0].row.is_some(), "{:?}", preview.rows[0]); + let (_, events) = export(&shell, confirmed_of(req.clone(), &preview)); + assert!(steps(&events, ids[0]).contains(&"exportDone"), "{events:?}"); + assert!(out.join("link.wav").is_file()); + + // The file the link points at changes: the link itself does not. + let preview = shell.plan_export(&req).unwrap(); + rewrite(&real); + let before = sha256(&out.join("link.wav")); + let (_, events) = export(&shell, confirmed_of(req, &preview)); + let failed = events.iter().find_map(|e| match e { + JobEvent::Failed { error, .. } => Some(error.clone()), + _ => None, + }); + assert_eq!(failed.expect("refused").kind, IpcErrorKind::FileChanged); + assert_eq!(sha256(&out.join("link.wav")), before, "left as it is"); +} + +#[test] +fn a_link_to_its_own_copy_never_counts_as_replacing_it() { + let lib = Lib::new(); + let real = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&real]); + let out = lib.base().join("copies"); + export(&shell, request(&ids, in_folder(prepare()), Some(&out))); + let copy = out.join("a.wav"); + let before = sha256(©); + + let links = lib.base().join("links"); + std::fs::create_dir_all(&links).unwrap(); + let link = links.join("a.wav"); + std::os::unix::fs::symlink(©, &link).unwrap(); + let ids = analysed(&shell, &[&link]); + let req = request(&ids, in_folder(prepare()), Some(&out)); + + let preview = shell.plan_export(&req).unwrap(); + assert!(!preview.rows[0].replaces_copy, "{:?}", preview.rows[0]); + assert_eq!( + preview.rows[0].row.as_ref().unwrap().action, + ExportAction::Skip { + reason: ExportSkip::OtherFileInFolder + } + ); + let (_, events) = export(&shell, req); + assert!( + steps(&events, ids[0]).contains(&"exportSkipped"), + "{events:?}" + ); + assert_eq!(sha256(©), before, "left as it is"); +} + +#[test] +fn a_file_changed_after_confirm_exports_once_it_is_analysed_again() { + let lib = Lib::new(); + let wav = lib.tone("a.wav", 0.1); + let shell = lib.shell(1); + let ids = analysed(&shell, &[&wav]); + let req = request(&ids, prepare(), None); + let preview = shell.plan_export(&req).unwrap(); + rewrite(&wav); + let (_, events) = export(&shell, confirmed_of(req.clone(), &preview)); + assert!(steps(&events, ids[0]).contains(&"failed"), "{events:?}"); + // Still changed for the plan, until it is read again. + assert!(shell.plan_export(&req).unwrap().rows[0].error.is_some()); + + let (tx, rx) = std::sync::mpsc::channel(); + shell + .start( + sc_core::ipc::AnalyzeRequest { + file_ids: ids.clone(), + analysis: loudness_only(), + }, + move |e| { + let _ = tx.send(e); + }, + ) + .unwrap(); + events_until_finished(&rx); + + let preview = shell.plan_export(&req).unwrap(); + assert!(preview.rows[0].error.is_none(), "{:?}", preview.rows[0]); + let (_, events) = export(&shell, confirmed_of(req, &preview)); + assert!(steps(&events, ids[0]).contains(&"exportDone"), "{events:?}"); +} diff --git a/src-tauri/src/shell/tests/support.rs b/src-tauri/src/shell/tests/support.rs index 2bf32d4..50665c4 100644 --- a/src-tauri/src/shell/tests/support.rs +++ b/src-tauri/src/shell/tests/support.rs @@ -171,6 +171,7 @@ pub(super) fn request( file_ids: file_ids.to_vec(), settings, out_dir: out_dir.map(|d| d.display().to_string()), + confirmed: None, } } @@ -257,8 +258,12 @@ pub(super) fn analysis_held_at( hold_at(&|send| shell.start(req.clone(), send), stop) } +/// Prepare, in place: the tests choose a folder with [`in_folder`]. pub(super) fn prepare() -> ExportSettings { - ExportSettings::new(BatchMode::Prepare) + ExportSettings { + place: Place::InPlace, + ..ExportSettings::new(BatchMode::Prepare) + } } pub(super) fn in_folder(settings: ExportSettings) -> ExportSettings { diff --git a/src/App.tsx b/src/App.tsx index b2c9368..bd4114a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; import { useSelector } from '@xstate/react'; +import { X } from 'lucide-react'; +import { errorCopy } from '@/lib/ipc'; import { keepFocusOnClick, ownsKey } from '@/lib/focus'; import { listenForDrops } from '@/lib/platform'; import { useLibrary } from '@/state/library'; @@ -18,6 +20,10 @@ import { GridView } from '@/features/grid/GridView'; import { useTrack } from '@/features/grid/store'; import { startMonitorSync } from '@/features/settings/monitorSync'; import { startSettingsSync } from '@/features/settings/sync'; +import { ExportConfirm } from '@/features/export/ExportConfirm'; +import { ExportDetail } from '@/features/export/ExportDetail'; +import { useExportFlow } from '@/features/export/flow'; +import { startRecoveryWatch } from '@/state/recovery'; /** Keys for the review loop: arrows move, N jumps to the next row that needs a look. */ @@ -26,6 +32,8 @@ function useKeys() { const onKey = (e: KeyboardEvent) => { // The grid view handles its own keys while it is open. if (useTrack.getState().fileId !== null) return; + // The confirm step, while open, has the keys to itself. + if (useExportFlow.getState().phase !== 'closed') return; const panels = usePanels.getState(); if (e.metaKey && e.key === ',') { // Settings: the drawer in a narrow window, else the rail's first control. @@ -78,6 +86,7 @@ export default function App() { useEffect(() => startSettingsSync(), []); useEffect(() => startMonitorSync(), []); useEffect(() => startTrackListPersistence(), []); + useEffect(() => startRecoveryWatch(), []); return (
{inGrid ? : } @@ -112,9 +121,16 @@ function KeyHints() { function Library() { const hasRows = useLibrary((s) => s.order.length > 0); const aborted = useLibrary((s) => s.aborted); + const abortedKind = useLibrary((s) => s.abortedKind); + const artefactsFailed = useLibrary((s) => s.artefactsFailed); + const exported = useLibrary((s) => (s.lastExport?.written ?? 0) > 0); const notice = useLibrary((s) => s.notice); + const info = useLibrary((s) => s.info); + const setInfo = useLibrary((s) => s.setInfo); const review = useLibrary((s) => s.order.filter((id) => s.rows[id]?.state === 'needsReview').length); const state = useSelector(pipeline(), (s) => s.value); + const exportRunning = + state === 'exporting' || state === 'cancellingExport' || state === 'windingDown'; const [dropping, setDropping] = useState(false); const narrow = useMediaQuery(NARROW); const settingsOpen = usePanels((p) => p.settings); @@ -145,24 +161,44 @@ function Library() { )} {aborted && (
- Analysis could not start: {aborted.message} + {abortedKind === 'export' ? 'Export' : 'Analysis'} could not start: {errorCopy(aborted)} +
+ )} + {artefactsFailed && ( +
+ The rekordbox XML and the grid report could not be written: {errorCopy(artefactsFailed)} + {exported && ' The exported files are fine.'} +
+ )} + {info && ( +
+ {info} +
)} {hasRows ? : } + {hasRows && } {hasRows && (
{!narrow && }
{review > 0 && ( - {review === 1 ? '1 track needs' : `${review} tracks need`} a look before processing + {review === 1 ? '1 track needs' : `${review} tracks need`} a look )} @@ -180,7 +216,12 @@ function Library() { )} -
+
+ {dropping && (
Drop to add diff --git a/src/components/Drawer.tsx b/src/components/Drawer.tsx index a960da7..81327cf 100644 --- a/src/components/Drawer.tsx +++ b/src/components/Drawer.tsx @@ -1,7 +1,5 @@ -import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'; - -const FOCUSABLE = - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; +import { useRef, type ReactNode } from 'react'; +import { useDialogFocus } from './useDialogFocus'; /** * A rail shown over the right side of the window when it does not fit beside the content (a @@ -26,67 +24,7 @@ export function Drawer({ children: ReactNode; }) { const panel = useRef(null); - const latest = useRef({ returnFocus, onClose }); - useEffect(() => { - latest.current = { returnFocus, onClose }; - }); - useEffect(() => { - const before = document.activeElement instanceof HTMLElement ? document.activeElement : null; - const first = focus === 'first' ? panel.current?.querySelector(FOCUSABLE) : null; - (first ?? panel.current)?.focus(); - // Keys and focus outside the panel while it is open: after a focused control inside goes - // away (a card removed, a button relabelled) focus lands on the page, not in here. - const onDocumentKey = (e: globalThis.KeyboardEvent) => { - if (e.key === 'Escape') { - // Before the window's own Esc (back, or clearing the filter) can see it. - e.stopPropagation(); - latest.current.onClose(); - } else if (e.key === 'Tab' && !panel.current?.contains(document.activeElement)) { - e.preventDefault(); - (panel.current?.querySelector(FOCUSABLE) ?? panel.current)?.focus(); - } - }; - const onFocusIn = (e: FocusEvent) => { - if (e.target instanceof Node && !panel.current?.contains(e.target)) panel.current?.focus(); - }; - document.addEventListener('keydown', onDocumentKey); - document.addEventListener('focusin', onFocusIn); - return () => { - document.removeEventListener('keydown', onDocumentKey); - document.removeEventListener('focusin', onFocusIn); - // Nothing focused before (the page itself) counts as gone too. - const kept = before && before !== document.body && before.isConnected; - (kept ? before : (latest.current.returnFocus?.() ?? null))?.focus(); - }; - // Set up once per opening; `focus` only matters then. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - // Not also the window's Esc (which clears the table's filter). - e.stopPropagation(); - onClose(); - } else if (e.key === 'Tab') { - const items = [...(panel.current?.querySelectorAll(FOCUSABLE) ?? [])]; - const first = items[0]; - const last = items[items.length - 1]; - const at = items.indexOf(document.activeElement as HTMLElement); - if (!first || !last) { - e.preventDefault(); - } else if (at < 0) { - // Focus on the panel itself (a click on its background): into its controls. - e.preventDefault(); - (e.shiftKey ? last : first).focus(); - } else if (e.shiftKey && at === 0) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && at === items.length - 1) { - e.preventDefault(); - first.focus(); - } - } - }; + const onKeyDown = useDialogFocus(panel, { onClose, returnFocus, focus }); return (
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..99e38dc --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,46 @@ +import { useRef, useState, type ReactNode } from 'react'; +import { useDialogFocus } from './useDialogFocus'; + +/** A press outside this soon after opening is the end of a double click on what opened it. */ +export const BACKDROP_GRACE_MS = 300; + +/** + * A panel over the window that asks for one decision: keeps focus inside while open and gives it + * back when it closes; `Esc` or a press outside closes it. Never taller than the window: its + * children scroll inside. + */ +export function Modal({ + label, + onClose, + children, +}: { + label: string; + onClose: () => void; + children: ReactNode; +}) { + const panel = useRef(null); + const [opened] = useState(() => performance.now()); + const onKeyDown = useDialogFocus(panel, { onClose, focus: 'panel' }); + return ( +
+ + ); +} diff --git a/src/components/useDialogFocus.ts b/src/components/useDialogFocus.ts new file mode 100644 index 0000000..665d4a6 --- /dev/null +++ b/src/components/useDialogFocus.ts @@ -0,0 +1,86 @@ +import { useEffect, useRef, type KeyboardEvent, type RefObject } from 'react'; + +const FOCUSABLE = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * Focus for a modal panel: it moves in when the panel opens (to its first control, or with + * `focus="panel"` to the panel itself, where Space cannot press a button by habit), stays inside + * while it is open, even when the focused control goes away, and goes back where it was when it + * closes, or to `returnFocus()` when that is gone. `Esc`, wherever focus is, closes it. Returns + * the panel's key handler. + */ +export function useDialogFocus( + panel: RefObject, + { + onClose, + returnFocus, + focus = 'first', + }: { + onClose: () => void; + returnFocus?: () => HTMLElement | null; + focus?: 'first' | 'panel'; + }, +): (e: KeyboardEvent) => void { + const latest = useRef({ returnFocus, onClose }); + useEffect(() => { + latest.current = { returnFocus, onClose }; + }); + useEffect(() => { + const before = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const first = focus === 'first' ? panel.current?.querySelector(FOCUSABLE) : null; + (first ?? panel.current)?.focus(); + // Keys and focus outside the panel while it is open: after a focused control inside goes + // away (a card removed, a button relabelled) focus lands on the page, not in here. + const onDocumentKey = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') { + // Before the window's own Esc (back, or clearing the filter) can see it. + e.stopPropagation(); + latest.current.onClose(); + } else if (e.key === 'Tab' && !panel.current?.contains(document.activeElement)) { + e.preventDefault(); + (panel.current?.querySelector(FOCUSABLE) ?? panel.current)?.focus(); + } + }; + const onFocusIn = (e: FocusEvent) => { + if (e.target instanceof Node && !panel.current?.contains(e.target)) panel.current?.focus(); + }; + document.addEventListener('keydown', onDocumentKey); + document.addEventListener('focusin', onFocusIn); + return () => { + document.removeEventListener('keydown', onDocumentKey); + document.removeEventListener('focusin', onFocusIn); + // Nothing focused before (the page itself) counts as gone too. + const kept = before && before !== document.body && before.isConnected; + (kept ? before : (latest.current.returnFocus?.() ?? null))?.focus(); + }; + // Set up once per opening; `focus` only matters then. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Not also the window's Esc (which clears the table's filter). + e.stopPropagation(); + onClose(); + } else if (e.key === 'Tab') { + const items = [...(panel.current?.querySelectorAll(FOCUSABLE) ?? [])]; + const first = items[0]; + const last = items[items.length - 1]; + const at = items.indexOf(document.activeElement as HTMLElement); + if (!first || !last) { + e.preventDefault(); + } else if (at < 0) { + // Focus on the panel itself (a click on its background): into its controls. + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } else if (e.shiftKey && at === 0) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && at === items.length - 1) { + e.preventDefault(); + first.focus(); + } + } + }; +} diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index a8358be..fbd4001 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -6,6 +6,7 @@ */ import { mockIPC } from '@tauri-apps/api/mocks'; import { gridCommand } from './mockGrid'; +import { exportCommand } from './mockExport'; import type { Channel } from '@tauri-apps/api/core'; import type { Codec, @@ -20,7 +21,7 @@ import type { RowPlan, } from '@/lib/ipc'; -interface Synthetic { +export interface Synthetic { title: string; artist: string; codec: Codec; @@ -37,6 +38,8 @@ interface Synthetic { verdict?: RowGrid['verdict']; reasons?: RowGrid['reasons']; fails?: boolean; + /** Holds Serato cue points. */ + serato?: boolean; } const LIBRARY: Synthetic[] = [ @@ -47,6 +50,8 @@ const LIBRARY: Synthetic[] = [ { title: 'Float Bounce', artist: 'Mock Artist E', codec: 'wav', rate: 44100, bits: 32, kbps: null, float: true, sp95: -8.1, integrated: -9.6, tp: 0.3, bpm: 87, confidence: 'amber', verdict: 'staticWarn', reasons: ['octaveMargin'] }, { title: 'Lossless Import', artist: 'Mock Artist F', codec: 'alac', rate: 44100, bits: 16, kbps: null, sp95: -10.2, integrated: -11.5, tp: -0.2, bpm: 124 }, { title: 'Already There', artist: 'Mock Artist G', codec: 'flac', rate: 48000, bits: 24, kbps: null, sp95: -11.02, integrated: -12.3, tp: -0.6, bpm: 120 }, + // Already cued in Serato, in a folder of its own: the batch switches to Library. + { title: 'Serato Cued', artist: 'Mock Artist M', codec: 'wav', rate: 44100, bits: 16, kbps: null, sp95: -9.2, integrated: -10.4, tp: -0.7, bpm: 122, serato: true }, // Every BPM flag at once (octave in doubt, a meter, worth a listen): the widest BPM cell. { title: 'Six Eight Waltz', artist: 'Mock Artist H', codec: 'flac', rate: 44100, bits: 16, kbps: null, sp95: -9.5, integrated: -11.2, tp: -1.1, bpm: 138.41, meter: '6/8 · 3+3', confidence: 'amber', verdict: 'staticWarn', reasons: ['octaveMargin'] }, { title: 'Broken Download', artist: 'Mock Artist I', codec: 'mp3', rate: 44100, bits: null, kbps: 256, sp95: null, integrated: null, tp: 0, bpm: null, fails: true }, @@ -88,8 +93,8 @@ function fileEntry(s: Synthetic, folder: string, fileId: number): FileEntry { artist: s.artist, album: null, djUnsafe: s.float ? 'float' : null, - serato: false, - seratoTags: [], + serato: s.serato ?? false, + seratoTags: s.serato ? ['markers', 'beatGrid'] : [], seratoUnknown: false, soundcheck: null, soundcheckUnreadable: null, @@ -97,7 +102,7 @@ function fileEntry(s: Synthetic, folder: string, fileId: number): FileEntry { }; } -function analysisFor(s: Synthetic): RowAnalysis { +export function analysisFor(s: Synthetic): RowAnalysis { return { duration: 240, sampleRate: s.rate, @@ -133,7 +138,7 @@ function analysisFor(s: Synthetic): RowAnalysis { } /** The same rules as the engine's decide, enough for a believable table. */ -function planFor(s: Synthetic, a: RowAnalysis): Plan { +export function planFor(s: Synthetic, a: RowAnalysis): Plan { const measured = settings.mode === 'dj' ? a.shortTermP95 : a.integrated; const review: ReviewReason[] = []; const g = a.grid; @@ -200,6 +205,13 @@ function runJob(jobId: number, fileIds: number[], channel: unknown) { const MONITOR_KEY = 'soundcheck-mock-monitor'; export function installMockBackend(): void { + const session = { + decide: () => settings, + analysed, + entries, + revision: () => revision, + entryFor: (s: Synthetic, fileId: number) => fileEntry(s, '/Music/Mock Crate', fileId), + }; mockIPC((cmd, args) => { const a = (args ?? {}) as Record; switch (cmd) { @@ -217,6 +229,7 @@ export function installMockBackend(): void { } case 'cancel_job': { cancelled.add(a.jobId as number); + exportCommand(cmd, a, session); return true; } case 'set_decide_settings': { @@ -232,7 +245,9 @@ export function installMockBackend(): void { case 'calibration_target': return -9.2; case 'restore_session': - return { revision, rows: [] }; + return { revision, rows: [], runningJobs: [], artefacts: null, artefactsFailed: null, lastExport: null }; + case 'recovery_status': + return { state: 'finished', recovered: [], pending: [] }; case 'clear_session': analysed.clear(); entries.clear(); @@ -247,12 +262,18 @@ export function installMockBackend(): void { case 'plugin:store|set': if (a.key === 'monitor') localStorage.setItem(MONITOR_KEY, JSON.stringify(a.value)); return null; - case 'plugin:dialog|open': - return ['/Music/Mock Crate']; + case 'plugin:dialog|open': { + // The folder an export goes to is one path; adding asks for several. + const options = a.options as { multiple?: boolean } | undefined; + return options?.multiple ? ['/Music/Mock Crate'] : '/Users/mock/Music/Exports'; + } + case 'plugin:opener|reveal_item_in_dir': + return null; case 'plugin:dialog|message': return true; default: return ( + exportCommand(cmd, a, session) ?? gridCommand(cmd, a, (fileId) => { const s = entries.get(fileId); const an = analysed.get(fileId); diff --git a/src/dev/mockExport.test.tsx b/src/dev/mockExport.test.tsx new file mode 100644 index 0000000..6800421 --- /dev/null +++ b/src/dev/mockExport.test.tsx @@ -0,0 +1,91 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import App from '@/App'; +import { addPaths, pipeline } from '@/features/pipeline/actions'; +import { useLibrary } from '@/state/library'; +import { useRecovery } from '@/state/recovery'; +import { useSettings } from '@/state/settings'; +import { installMockBackend } from './mockBackend'; + +const row = (name: string) => { + const found = screen.getAllByRole('row').find((r) => r.textContent?.includes(name)); + if (!found) throw new Error(`no row ${name}`); + return found; +}; + +const advance = (ms: number) => act(async () => vi.advanceTimersByTimeAsync(ms)); + +describe('the browser stand-in', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); + useRecovery.setState({ running: false }); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('runs a whole export through the screen: confirm, row states, footer links, revert', async () => { + installMockBackend(); + render(); + await advance(100); + await act(async () => addPaths(['/Users/mock/Music/Crate'])); + await advance(10_000); + expect(pipeline().getSnapshot().value).toBe('idle'); + // Its Serato track switched the batch to Library; the scenario wants the cuts. + await act(async () => { + useSettings.getState().setBatchMode('prepare', ['/Users/mock/Music/Crate']); + useSettings.getState().setPlace('inPlace'); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Export \d+/ })); + await advance(500); + const dialog = screen.getByRole('dialog', { name: 'Confirm export' }); + expect(within(dialog).getByText(/backups in ~\/Music\/SoundCheck Backups/)).toBeInTheDocument(); + expect(within(dialog).getByText(/Positions shift/)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: /^Write \d+ files$/ })); + await advance(0); + expect(pipeline().getSnapshot().value).toBe('exporting'); + expect(screen.queryByRole('dialog')).toBeNull(); + + // The first file walks its stages. + await advance(100); + expect(within(row('Night Drive')).getByText(/^Processing/)).toBeInTheDocument(); + await advance(350); + expect(within(row('Night Drive')).getByText('Writing')).toBeInTheDocument(); + await advance(350); + expect(within(row('Night Drive')).getByText('Checking')).toBeInTheDocument(); + expect(screen.getByText(/^Exporting 0 \/ /)).toBeInTheDocument(); + + await advance(20_000); + expect(pipeline().getSnapshot().value).toBe('idle'); + const night = within(row('Night Drive')); + expect(night.getByText('Done, verified')).toBeInTheDocument(); + expect(night.getByText('Grid checks out')).toBeInTheDocument(); + expect(night.getByText('shift')).toBeInTheDocument(); + expect(within(row('Quiet Classic')).getByText(/^Off by/)).toBeInTheDocument(); + expect(within(row('Big Room')).getByText('Check bar 1')).toBeInTheDocument(); + expect(within(row('Already There')).getByText('Reads differently')).toBeInTheDocument(); + expect(within(row('Radio Edit')).getByText('Grid: XML only (MP3)')).toBeInTheDocument(); + expect(within(row('Lossless Import')).getByText(/analysed only/)).toBeInTheDocument(); + expect(within(row('Breakbeat Tool')).getByText('Failed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show in Finder' })).toBeInTheDocument(); + expect(screen.getByText(/Last export: /)).toBeInTheDocument(); + + // Revert: a spinner on that row, nothing else held up; then the original's row. + const id = Object.values(useLibrary.getState().rows).find((r) => r.entry.info.title === 'Night Drive')?.entry.fileId; + fireEvent.click(within(row('Night Drive')).getByRole('button', { name: 'Revert to the original' })); + await advance(100); + expect(within(row('Night Drive')).getByText('Reverting')).toBeInTheDocument(); + expect(useLibrary.getState().rows[id ?? 0]?.reverting).toBe(true); + await advance(3000); + expect(within(row('Night Drive')).queryByText('Done, verified')).toBeNull(); + expect(useLibrary.getState().rows[id ?? 0]?.export).toBeUndefined(); + }); +}); diff --git a/src/dev/mockExport.ts b/src/dev/mockExport.ts new file mode 100644 index 0000000..22f229b --- /dev/null +++ b/src/dev/mockExport.ts @@ -0,0 +1,329 @@ +/** + * The export commands of the browser stand-in: every Action, Status and grid-check chip the table + * can show, in the order the real job sends them. Reached through `mockBackend.ts` only. + */ +import type { Channel } from '@tauri-apps/api/core'; +import type { + Cut, + DecideSettings, + ExportAction, + ExportPreview, + ExportRequest, + ExportRow, + ExportVerification, + FileEntry, + GridCheck, + JobEvent, + RevertResult, + RowAnalysis, + WriteXmlRequest, + XmlWritten, +} from '@/lib/ipc'; +import { analysisFor, planFor, type Synthetic } from './mockBackend'; + +export interface MockSession { + decide: () => DecideSettings; + analysed: Map; + entries: Map; + revision: () => number; + entryFor: (s: Synthetic, fileId: number) => FileEntry; +} + +const BACKUPS = '/Users/mock/Music/SoundCheck Backups'; +const EXPORTS = '/Users/mock/Music/SoundCheck/exports'; +const STEP_MS = 350; + +const meter = (beatsPerBar: number, grouping: number[]) => ({ beatsPerBar, unit: 'eighth', grouping }) as const; + +/** One grid check per track, so a batch shows them all. */ +const CHECKS: Record = { + 'Night Drive': { result: 'pass', offsetMs: 0.6, bpmDiff: 0.01 }, + 'Quiet Classic': { result: 'offBy', offsetMs: 12.4, bpmDiff: 0 }, + 'Big Room': { result: 'checkBar1', offsetMs: 468.8, bpmDiff: 0, beats: 1 }, + 'Already There': { + result: 'advisory', + disagreement: { kind: 'offBy', offsetMs: -14.2, bpmDiff: 0 }, + }, + 'Serato Cued': { result: 'bpmDiffers', bpmDiff: 0.12, found: 122.12 }, + 'Float Bounce': { result: 'noGridFound' }, + Downtempo: { result: 'meterDiffers', found: meter(9, [2, 2, 2, 3]), exported: meter(10, [3, 2, 2, 3]) }, +}; + +const WHY = { + mp3: 'MP3 (file writes arrive later)', + unsupported: (label: string) => `${label} files are analysed only; SoundCheck does not write them`, + seratoCut: (s: number) => + `cutting ${s.toFixed(2)} s in place would move its Serato cue points, which are stored as positions in the audio`, + gridOnlyFlac: 'grid only on FLAC (writing tags alone to FLAC arrives later)', + withheld: 'its BPM and bar 1 are not written, because the grid needs review (confirm it in the app)', +}; + +interface Planned { + action: ExportAction; + notes: string[]; + withheld: boolean; +} + +function plannedFor(s: Synthetic, req: ExportRequest): Planned { + const { batchMode, gridOnly, place } = req.settings; + const write = (gainDb: number, cut: Cut, notes: string[] = [], withheld = false): Planned => ({ + action: { type: 'write', gainDb, cut }, + notes, + withheld, + }); + if (s.codec === 'mp3') { + return { action: { type: 'xmlOnly', reason: { type: 'mp3OrAac', codec: 'mp3' } }, notes: [WHY.mp3], withheld: false }; + } + if (!['wav', 'aiff', 'flac'].includes(s.codec)) { + return { + action: { type: 'skip', reason: { type: 'unsupported', codec: s.codec } }, + notes: [WHY.unsupported(s.codec.toUpperCase())], + withheld: false, + }; + } + if (gridOnly) { + if (s.codec === 'flac') { + return { action: { type: 'xmlOnly', reason: { type: 'gridOnlyFlac' } }, notes: [WHY.gridOnlyFlac], withheld: false }; + } + return write(0, { type: 'gridOnly' }); + } + const gain = -11 - (s.sp95 ?? -11); + const headroom = Math.max(-0.5 - s.tp, 0); + const gainDb = gain <= 0 ? gain : Math.min(gain, headroom); + if (batchMode === 'library') return write(gainDb, { type: 'library' }); + if (s.confidence && s.confidence !== 'green') { + const bar1 = 22_932; + return write(gainDb, { type: 'needsReview', bar1, bar1S: 0.52 }, [WHY.withheld], true); + } + const seconds = 0.21; + if (s.serato && place === 'inPlace') { + return { + action: { type: 'skip', reason: { type: 'seratoInPlaceCut', cutS: seconds } }, + notes: [WHY.seratoCut(seconds)], + withheld: false, + }; + } + return write(gainDb, { type: 'cut', frames: Math.round(seconds * s.rate), seconds }); +} + +function row(planned: Planned, stage: ExportRow['stage'], req: ExportRequest, decide: DecideSettings): ExportRow { + const place = req.settings.place; + const cut = planned.action.type === 'write' && planned.action.cut.type === 'cut'; + return { + stage, + action: planned.action, + positionsShift: cut, + gridWithheld: planned.withheld, + notices: [], + verification: null, + gridCheck: null, + editCarried: false, + revertible: stage !== 'processing' && stage !== 'skipped' && place === 'inPlace', + exportedWith: { decide, settings: req.settings, outDir: req.outDir }, + notes: planned.notes, + }; +} + +function verification(s: Synthetic): ExportVerification { + return { + blocks: 29, + carried: 26, + patched: 0, + edited: 1, + replaced: 2, + dropped: 0, + tagsAdded: 3, + framesIn: 240 * s.rate, + framesOut: 240 * s.rate, + bitsOut: s.bits ?? 16, + exact: false, + dithered: false, + staleLoudnessTags: [], + }; +} + +function preview(session: MockSession, req: ExportRequest): ExportPreview { + const decide = session.decide(); + return { + rows: req.fileIds.map((fileId) => { + const s = session.entries.get(fileId); + if (!s) { + const error = { kind: 'invalidArgument' as const, message: 'not in the list', fileId }; + return { fileId, row: null, error, identity: null, replacesCopy: false }; + } + const planned = plannedFor(s, req); + const stage = planned.action.type === 'write' ? 'processing' : 'skipped'; + const replacesCopy = stage === 'processing' && req.outDir !== null && written.has(`${req.outDir}/${fileId}`); + return { fileId, row: row(planned, stage, req, decide), error: null, identity: `mock:${fileId}`, replacesCopy }; + }), + revision: session.revision(), + decide, + xmlListed: req.outDir === null ? 0 : [...written].filter((k) => k.startsWith(`${req.outDir}/`)).length, + backupFolder: req.settings.place === 'inPlace' ? BACKUPS : null, + artefactsFolder: req.settings.xml ? (req.outDir ?? EXPORTS) : null, + }; +} + +/** Copies this run wrote, `/`. */ +const written = new Set(); +const cancelled = new Set(); +let nextJob = 1000; + +const wait = (ms: number) => new Promise((go) => setTimeout(go, ms)); + +/** One file, in the order the job sends its events; a cancel stops it only before it is written. */ +async function exportOne(session: MockSession, jobId: number, fileId: number, req: ExportRequest, send: (e: JobEvent) => void) { + const s = session.entries.get(fileId); + if (!s) return; + const place = req.settings.place; + const decide = session.decide(); + const stopped = () => { + const stop = cancelled.has(jobId); + if (stop) send({ type: 'cancelled', jobId, fileId }); + return stop; + }; + if (stopped()) return; + send({ type: 'started', jobId, fileId }); + await wait(STEP_MS); + if (stopped()) return; + const planned = plannedFor(s, req); + if (planned.action.type !== 'write') { + send({ type: 'exportSkipped', jobId, fileId, row: row(planned, 'skipped', req, decide) }); + return; + } + send({ type: 'processing', jobId, fileId, row: row(planned, 'processing', req, decide) }); + await wait(STEP_MS); + if (stopped()) return; + if (s.title === 'Breakbeat Tool') { + const message = 'the written file does not match its plan; the original was kept'; + send({ type: 'failed', jobId, fileId, error: { kind: 'verifyFailed', message, fileId } }); + return; + } + const wrote = { ...row(planned, 'writing', req, decide), verification: verification(s) }; + send({ type: 'written', jobId, fileId, row: wrote }); + if (req.outDir !== null) written.add(`${req.outDir}/${fileId}`); + await wait(STEP_MS); + const gridCheck: GridCheck = + CHECKS[s.title] ?? + (s.meter + ? { result: 'notChecked', reason: { type: 'oddMeter', meter: meter(6, [3, 3]) } } + : { result: 'pass', offsetMs: 1.1, bpmDiff: 0 }); + const inPlace = place === 'inPlace'; + send({ + type: 'exportDone', + jobId, + fileId, + row: { ...wrote, stage: 'done', gridCheck }, + inPlace, + update: inPlace ? updateAfter(session, s, fileId) : null, + }); +} + +async function runExport(session: MockSession, jobId: number, req: ExportRequest, channel: unknown) { + const send = (event: JobEvent) => (channel as Channel).onmessage(event); + const total = req.fileIds.length; + let done = 0; + let listed = 0; + for (const fileId of req.fileIds) { + await exportOne(session, jobId, fileId, req, (event) => { + if (event.type === 'exportDone') listed += 1; + send(event); + }); + done += 1; + send({ type: 'batch', jobId, done, total, etaMs: (total - done) * STEP_MS * 3, realtimeX: 60 }); + } + if (req.settings.xml) { + const folder = req.outDir ?? `${EXPORTS}/2026-11-05 14.05.33`; + send({ + type: 'artefacts', + jobId, + artefacts: { + folder, + xml: `${folder}/soundcheck-rekordbox.xml`, + report: `${folder}/grid-report.csv`, + listed, + }, + }); + } + send({ type: 'finished', jobId, cancelled: cancelled.has(jobId) }); +} + +/** The row after an in-place export: at the target, its bar 1 moved. */ +function updateAfter(session: MockSession, s: Synthetic, fileId: number) { + const moved: Synthetic = { ...s, sp95: -11, tp: Math.min(s.tp, -0.5) }; + const a = analysisFor(moved); + session.analysed.set(fileId, a); + session.entries.set(fileId, moved); + return { fileId, row: a, plan: planFor(moved, a), revision: session.revision(), exportStale: false }; +} + +const originals = new Map(); + +function revert(session: MockSession, fileId: number): Promise { + const s = originals.get(fileId) ?? session.entries.get(fileId); + return new Promise((resolve, reject) => { + if (!s) { + reject(new Error('not in the list')); + return; + } + // About what a 6-minute track takes, shortened: long enough to see the spinner. + setTimeout(() => { + const a = analysisFor(s); + session.analysed.set(fileId, a); + session.entries.set(fileId, s); + resolve({ + entry: session.entryFor(s, fileId), + update: { fileId, row: a, plan: planFor(s, a), revision: session.revision(), exportStale: false }, + backup: `${BACKUPS}/${s.title}`, + earlierChanges: 0, + notes: [], + }); + }, 2500); + }); +} + +function writeXml(session: MockSession, req: WriteXmlRequest): XmlWritten { + const folder = `${EXPORTS}/2026-11-05 14.10.02`; + const listed = req.fileIds.filter((id) => session.entries.get(id)?.confidence === undefined); + return { + artefacts: { + folder, + xml: `${folder}/soundcheck-rekordbox.xml`, + report: `${folder}/grid-report.csv`, + listed: listed.length, + }, + notListed: req.fileIds + .filter((id) => !listed.includes(id)) + .map((fileId) => ({ fileId, why: 'its grid needs review and was not confirmed' })), + }; +} + +/** The answer to an export command; `undefined` for any other command. */ +export function exportCommand(cmd: string, a: Record, session: MockSession): unknown { + switch (cmd) { + case 'plan_export': { + // Long enough to see the step read its numbers. + const answer = preview(session, a.req as ExportRequest); + return new Promise((resolve) => setTimeout(() => resolve(answer), 400)); + } + case 'export': { + const req = a.req as ExportRequest; + for (const id of req.fileIds) { + const s = session.entries.get(id); + if (s && !originals.has(id)) originals.set(id, s); + } + const jobId = nextJob++; + void runExport(session, jobId, req, a.onEvent); + return jobId; + } + case 'cancel_job': + cancelled.add(a.jobId as number); + return undefined; + case 'revert_file': + return revert(session, a.fileId as number); + case 'write_rekordbox_xml': + return writeXml(session, a.req as WriteXmlRequest); + default: + return undefined; + } +} diff --git a/src/dev/mockGrid.ts b/src/dev/mockGrid.ts index c03f3dc..cb84870 100644 --- a/src/dev/mockGrid.ts +++ b/src/dev/mockGrid.ts @@ -425,7 +425,7 @@ export function gridCommand( edited: JSON.stringify(g) !== JSON.stringify(open.analysed), confirmed, }; - const update: RowUpdate = { fileId, row: analysis, plan, revision: row.revision }; + const update: RowUpdate = { fileId, row: analysis, plan, revision: row.revision, exportStale: false }; return update; } case 'player_play': diff --git a/src/features/export/ExportCells.tsx b/src/features/export/ExportCells.tsx new file mode 100644 index 0000000..7639d12 --- /dev/null +++ b/src/features/export/ExportCells.tsx @@ -0,0 +1,131 @@ +import { Loader2, Undo2 } from 'lucide-react'; +import type { ExportRow, GridCheck } from '@/lib/ipc'; +import { cn } from '@/lib/utils'; +import { isLocked, useLibrary, type Row } from '@/state/library'; +import { useRecovery } from '@/state/recovery'; +import { revert, revertBlock } from './actions'; +import { gridCheckChip, type ChipTone } from './gridCheck'; +import { exportActionText, verifiedText } from './text'; +import { exportDifference, type ExportNow } from './work'; + +/** Tokens per tone. `neutral` must never read as a failure: no warning or error colour in it. */ +const CHIP: Record = { + positive: 'text-ok', + neutral: 'text-fg-1', + attention: 'rounded border border-[rgba(251,191,36,.45)] bg-[rgba(251,191,36,.12)] px-1 text-warn', + quiet: 'text-warn/80', + muted: 'text-fg-2', +}; + +/** A written file's grid check on one line; the whole sentence on hover and in the details. */ +export function GridCheckBadge({ check }: { check: GridCheck }) { + const chip = gridCheckChip(check); + return ( + + {chip.label} + + ); +} + +const POSITIONS_SHIFT = + 'Positions shift: this file starts later in its audio, so cue points saved elsewhere sit late by the cut.'; + +/** The file's start was cut, so positions kept outside it no longer line up. */ +export function PositionsShiftBadge() { + return ( + + shift + + ); +} + +/** + * The Action column of a row with an export. `stacked`: too narrow for gain and cut on one line, + * so they take the two lines, and what was verified moves to the row's details. + */ +export function ExportActionCell({ + row, + exportRow, + stacked, + now, +}: { + row: Row; + exportRow: ExportRow; + stacked: boolean; + now?: ExportNow; +}) { + 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; + 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; + const line = 'truncate font-mono text-xs font-medium'; + if (twoChanges && stacked && !different) { + return ( +
+
{text.gain}
+
+ {text.cut} + {shift && } +
+
+ ); + } + return ( +
+
+ + {main} + + {shift && } +
+ {second && ( +
+ {second} +
+ )} +
+ ); +} + +/** Puts the file's original back; only on a row with a change to put back. */ +export function RevertIcon({ id, row, selected }: { id: number; row: Row; selected: boolean }) { + const locked = useLibrary((s) => isLocked(s.locked, id)); + const recovering = useRecovery((r) => r.running); + if (!row.revertible || row.reverting) return null; + const block = revertBlock(row, locked, recovering); + return ( + + ); +} + +/** Shown on a row while its revert runs. */ +export function RevertSpinner() { + return
); @@ -138,6 +141,7 @@ export function Table() { const ids = useLibrary(useShallow(visibleIds)); const selected = useLibrary((s) => s.selected); const mode = useSettings((s) => s.mode); + const now = useExportNow(); const ref = useRef(null); const gridRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); @@ -232,6 +236,7 @@ export function Table() { index={first + k} top={(first + k) * ROW_HEIGHT} layout={layout} + now={now} /> ))} {ids.length === 0 && ( diff --git a/src/features/library/Toolbar.test.tsx b/src/features/library/Toolbar.test.tsx index f4ff57d..ee7cad7 100644 --- a/src/features/library/Toolbar.test.tsx +++ b/src/features/library/Toolbar.test.tsx @@ -1,9 +1,12 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { mockIPC } from '@tauri-apps/api/mocks'; import App from '@/App'; import { useLibrary } from '@/state/library'; import { usePanels } from '@/state/panels'; -import { entry } from '@/test/fixtures'; +import { useRecovery } from '@/state/recovery'; +import { useSettings } from '@/state/settings'; +import { analysed, entry } from '@/test/fixtures'; +import { Toolbar } from './Toolbar'; /** A window narrower than 1000 px. */ function narrowWindow() { @@ -101,4 +104,60 @@ describe('the toolbar in a narrow window', () => { expect(screen.queryByRole('menu')).toBeNull(); expect(screen.getByRole('button', { name: 'Add' })).toHaveFocus(); }); + + it('shows Export in the top bar, since the rail is behind a drawer', () => { + render(); + expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument(); + expect(screen.queryByRole('complementary')).toBeNull(); + }); + + it('that button goes by the same rules as the rail\'s', () => { + act(() => useLibrary.getState().add([entry(1)])); + render(); + const button = screen.getByRole('button', { name: 'Export' }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', expect.stringMatching(/Analyse the tracks first/)); + }); + + it('with a folder all that is missing, it asks for one instead of sitting disabled', async () => { + let asked = false; + mockIPC((cmd) => { + if (cmd === 'plugin:dialog|open') { + asked = true; + return '/Users/dj/Out'; + } + return undefined; + }); + useSettings.setState({ place: 'folder', outDir: null }); + act(() => { + useLibrary.getState().add([entry(1)]); + useLibrary.getState().applyEvent(analysed(1)); + }); + render(); + const button = screen.getByRole('button', { name: 'Export' }); + expect(button).toBeEnabled(); + expect(button).toHaveAttribute('title', 'Choose a folder'); + fireEvent.click(button); + await waitFor(() => expect(asked).toBe(true)); + await waitFor(() => expect(useSettings.getState().outDir).toBe('/Users/dj/Out')); + }); + + it('with anything else in the way, it stays disabled', () => { + useSettings.setState({ place: 'folder', outDir: null }); + useRecovery.setState({ running: true }); + act(() => { + useLibrary.getState().add([entry(1)]); + useLibrary.getState().applyEvent(analysed(1)); + }); + render(); + expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled(); + useRecovery.setState({ running: false }); + }); + + it('shows no second Export beside the rail in a wide window', () => { + vi.unstubAllGlobals(); + useLibrary.getState().add([entry(1)]); + render(); + expect(screen.queryByRole('button', { name: 'Export' })).toBeNull(); + }); }); diff --git a/src/features/library/Toolbar.tsx b/src/features/library/Toolbar.tsx index 39c002d..a369665 100644 --- a/src/features/library/Toolbar.tsx +++ b/src/features/library/Toolbar.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { ChevronDown, Search, SlidersHorizontal, X } from 'lucide-react'; +import { Plus, Search, SlidersHorizontal, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { NARROW, useMediaQuery } from '@/lib/media'; import { chooseFiles, chooseFolders } from '@/lib/platform'; @@ -8,13 +8,14 @@ import { counts, staleIds, useLibrary, type Filter } from '@/state/library'; import { usePanels } from '@/state/panels'; import { presetLabel, useSettings } from '@/state/settings'; import { addPaths, analyseStale } from '@/features/pipeline/actions'; +import { ExportBarButton } from '@/features/export/ExportPanel'; /** The filter chips; `short` is the label in a narrow window. */ const CHIPS: { id: Filter; label: string; short?: string }[] = [ { id: 'all', label: 'All' }, { id: 'review', label: 'Needs review', short: 'Review' }, { id: 'short', label: 'Short' }, - { id: 'skipped', label: 'Skipped' }, + { id: 'skipped', label: 'Skipped', short: 'Skip' }, { id: 'done', label: 'Done' }, ]; @@ -114,8 +115,12 @@ export function Toolbar({ busy }: { busy: boolean }) { onClick={analyseStale} title={stale === 0 ? 'Every track is analysed with the current settings' : undefined} > - Analyse{!busy && stale > 0 ? ` ${stale}` : ''} + {/* In a narrow window the count makes room for Export beside it. */} + Analyse{!busy && stale > 0 && !narrow ? ` ${stale}` : ''} + {narrow && ( + + )} ); @@ -230,11 +235,12 @@ function AddMenu() { type="button" aria-haspopup="menu" aria-expanded={open} + aria-label="Add" + title="Add" onClick={() => setOpen(!open)} - className={cn(button, 'border-line text-fg-0 hover:bg-bg-2')} + className={cn(button, 'border-line px-2 text-fg-0 hover:bg-bg-2')} > - Add -
{detail && ( -
+
{detail}
)} diff --git a/src/features/library/columns.ts b/src/features/library/columns.ts index 6551420..a5a99ef 100644 --- a/src/features/library/columns.ts +++ b/src/features/library/columns.ts @@ -30,8 +30,8 @@ export const WIDE: Layout = { loudness: 188, tp: 56, bpm: 196, - action: 224, - status: 118, + action: 244, + status: 142, nameMin: 180, bar: true, }; @@ -47,9 +47,9 @@ export const COMPACT: Layout = { loudness: 124, tp: 56, bpm: 176, - action: 200, - status: 112, - nameMin: 140, + action: 190, + status: 142, + nameMin: 120, bar: false, }; @@ -65,9 +65,9 @@ export const NARROW: Layout = { loudness: 124, tp: null, bpm: 132, - action: 180, - status: 112, - nameMin: 140, + action: 168, + status: 142, + nameMin: 130, bar: false, }; diff --git a/src/features/library/remember.test.ts b/src/features/library/remember.test.ts index 7e88afa..e6d8666 100644 --- a/src/features/library/remember.test.ts +++ b/src/features/library/remember.test.ts @@ -3,7 +3,8 @@ import { mockIPC } from '@tauri-apps/api/mocks'; import type { FileEntry } from '@/lib/ipc'; import { loadTrackList, saveTrackList } from '@/lib/persist'; import { useLibrary } from '@/state/library'; -import { analysis, entry, plan } from '@/test/fixtures'; +import { analysis, doneRow, entry, plan } from '@/test/fixtures'; +import { pipeline } from '@/features/pipeline/actions'; import { startTrackListPersistence } from './remember'; vi.mock('@/lib/persist', () => ({ @@ -46,7 +47,14 @@ describe('remembering the track list', () => { it('after a window reload takes the rows from the engine instead', async () => { mockIPC((cmd) => { if (cmd === 'restore_session') { - return { revision: 2, rows: [{ entry: entry(9), row: analysis(), plan: plan() }] }; + return { + revision: 2, + rows: [{ entry: entry(9), row: analysis(), plan: plan(), export: null, revertible: false, exportStale: false, analysedRange: null }], + runningJobs: [], + artefacts: null, + artefactsFailed: null, + lastExport: null, + }; } return null; }); @@ -57,6 +65,55 @@ describe('remembering the track list', () => { stop(); }); + it('a job still running at a reload sets the pipeline waiting for it', async () => { + mockIPC((cmd) => { + if (cmd === 'restore_session') { + return { + revision: 2, + rows: [{ entry: entry(9), row: analysis(), plan: plan(), export: null, revertible: false, exportStale: false, analysedRange: null }], + runningJobs: [4], + artefacts: null, + artefactsFailed: null, + lastExport: null, + }; + } + return null; + }); + const stop = startTrackListPersistence(); + await waitFor(() => expect(pipeline().getSnapshot().value).toBe('windingDown')); + expect(useLibrary.getState().locked).toBe('all'); + // The read after it says the job is gone (the mock's next answer), and the pipeline is free. + mockIPC((cmd) => + cmd === 'restore_session' + ? { revision: 2, rows: [], runningJobs: [], artefacts: null, artefactsFailed: null, lastExport: null } + : null, + ); + await waitFor(() => expect(pipeline().getSnapshot().value).toBe('idle'), { timeout: 2000 }); + stop(); + }); + + it('after a plain reload analyses a file written in place whose analysis did not finish', async () => { + const calls: string[] = []; + mockIPC((cmd) => { + calls.push(cmd); + if (cmd === 'restore_session') { + return { + revision: 2, + rows: [{ entry: entry(9), row: null, plan: null, export: doneRow(), revertible: true, exportStale: false, analysedRange: null }], + runningJobs: [], + artefacts: null, + artefactsFailed: null, + lastExport: null, + }; + } + return cmd === 'analyze' ? 6 : null; + }); + const stop = startTrackListPersistence(); + await waitFor(() => expect(calls).toContain('analyze')); + pipeline().send({ type: 'FINISHED' }); + stop(); + }); + it('a cleared list stays empty and a late job end reports nothing', () => { const lib = useLibrary.getState(); lib.add([entry(1), entry(2)]); diff --git a/src/features/library/remember.ts b/src/features/library/remember.ts index 66dfc55..507fcd6 100644 --- a/src/features/library/remember.ts +++ b/src/features/library/remember.ts @@ -1,15 +1,15 @@ import { restoreSession } from '@/lib/ipc'; import { loadTrackList, saveTrackList } from '@/lib/persist'; import { useLibrary } from '@/state/library'; -import { useSettings } from '@/state/settings'; -import { addPaths } from '@/features/pipeline/actions'; +import { addPaths, analyseWritten, pipeline } from '@/features/pipeline/actions'; /** * Brings the track list back when the app starts and keeps it saved afterwards. * * After a window reload the engine still holds the rows, so they come from it. After a restart * the saved file paths are added again; their analyses come from the disk cache, and a file that - * was moved or deleted shows as an error row. Returns a stop function. + * was moved or deleted shows as an error row. A job still winding down when the window reloaded + * cannot be heard from this page, so the pipeline waits it out. Returns a stop function. */ export function startTrackListPersistence(): () => void { let stopped = false; @@ -26,8 +26,10 @@ export function startTrackListPersistence(): () => void { try { const snapshot = await restoreSession(); if (stopped) return; + if (snapshot?.runningJobs?.length) pipeline().send({ type: 'WIND_DOWN' }); if (snapshot?.rows?.length) { - useLibrary.getState().restore(snapshot, useSettings.getState().bpmRange); + useLibrary.getState().restore(snapshot); + if (!snapshot.runningJobs?.length) analyseWritten(); } else { const paths = await loadTrackList(); if (stopped) return; diff --git a/src/features/library/status.ts b/src/features/library/status.ts index c341f14..fbf2b02 100644 --- a/src/features/library/status.ts +++ b/src/features/library/status.ts @@ -1,7 +1,19 @@ import type { Row } from '@/state/library'; +/** Its export finished, whatever its grid still asks: the reason shows under the pill. */ +export function exportedAndRests(row: Row): boolean { + return row.state === 'needsReview' && row.export?.stage === 'done'; +} + +/** Left to the rekordbox XML, which lists it: it carries 4/4 grids only. */ +function listedInXml(row: Row): boolean { + return row.export?.action.type === 'xmlOnly' && row.analysis?.grid?.fourFour !== false; +} + /** The Status column's text. */ export function statusLabel(row: Row): string { + if (row.reverting) return 'Reverting'; + if (exportedAndRests(row)) return row.export?.verification ? 'Done, verified' : 'Done'; switch (row.state) { case 'queued': return 'Queued'; @@ -12,10 +24,23 @@ export function statusLabel(row: Row): string { case 'needsReview': return 'Needs review'; case 'skipped': - return 'Skipped'; + return listedInXml(row) ? 'XML only' : 'Skipped'; case 'error': return 'Error'; case 'cancelled': return 'Cancelled'; + case 'exportQueued': + return 'Queued'; + case 'processing': + return `Processing ${Math.round(row.progress * 100)} %`; + case 'writing': + return 'Writing'; + // Already verified: only the grid check is still running. + case 'checking': + return 'Checking'; + case 'done': + return row.export?.verification ? 'Done, verified' : 'Done'; + case 'failed': + return 'Failed'; } } diff --git a/src/features/pipeline/actions.test.ts b/src/features/pipeline/actions.test.ts new file mode 100644 index 0000000..a03fbc3 --- /dev/null +++ b/src/features/pipeline/actions.test.ts @@ -0,0 +1,241 @@ +import { act } from '@testing-library/react'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { analysed, analysis, doneRow, entry, plan } from '@/test/fixtures'; +import type { JobEvent, SessionSnapshot } from '@/lib/ipc'; +import { useLibrary } from '@/state/library'; +import { useSettings } from '@/state/settings'; + +const initialLibrary = useLibrary.getState(); +const initialSettings = useSettings.getState(); + +/** The pipeline module holds one actor: each test takes a fresh copy of the module. */ +async function fresh() { + vi.resetModules(); + // The store and the machine come back with the module; keep the singletons the test reads. + const lib = await import('@/state/library'); + const actions = await import('./actions'); + return { useLibrary: lib.useLibrary, ...actions }; +} + +const request = { + fileIds: [1], + settings: { + batchMode: 'prepare' as const, + place: 'inPlace' as const, + depth: null, + gridOnly: false, + tbpm: true, + xml: true, + leadMs: 5, + }, + outDir: null, + confirmed: null, +}; + +const snapshot = (overrides: Partial = {}): SessionSnapshot => ({ + revision: 0, + rows: [], + runningJobs: [], + artefacts: null, + artefactsFailed: null, + lastExport: null, + ...overrides, +}); + +beforeEach(() => { + useLibrary.setState(initialLibrary, true); + useSettings.setState(initialSettings, true); +}); + +describe('starting a job', () => { + it('a refused analysis keeps the typed error, not [object Object]', async () => { + const { pipeline, useLibrary: lib } = await fresh(); + mockIPC((cmd) => { + if (cmd === 'analyze') throw { kind: 'busy', message: 'being exported', fileId: 1 }; + return undefined; + }); + lib.getState().add([entry(1)]); + pipeline().send({ type: 'ANALYSE', fileIds: [1] }); + await vi.waitFor(() => expect(lib.getState().aborted).not.toBeNull()); + expect(lib.getState().aborted).toEqual({ kind: 'busy', message: 'being exported', fileId: 1 }); + expect(pipeline().getSnapshot().value).toBe('idle'); + expect(lib.getState().rows[1]?.state).toBe('cancelled'); + }); + + it('a refused export says why and leaves its rows as they were', async () => { + const { pipeline, useLibrary: lib } = await fresh(); + mockIPC((cmd) => { + if (cmd === 'export') throw { kind: 'recoveryRunning', message: 'running', fileId: null }; + return undefined; + }); + lib.getState().add([entry(1)]); + lib.getState().applyEvent(analysed(1)); + pipeline().send({ type: 'EXPORT', job: { request, planned: [] } }); + expect(lib.getState().rows[1]?.state).toBe('exportQueued'); + await vi.waitFor(() => expect(lib.getState().aborted?.kind).toBe('recoveryRunning')); + expect(lib.getState().abortedKind).toBe('export'); + expect(lib.getState().rows[1]?.state).toBe('analysed'); + expect(pipeline().getSnapshot().value).toBe('idle'); + }); + + it('runs an export to its end over the channel', async () => { + const { pipeline, useLibrary: lib } = await fresh(); + let send: (e: JobEvent) => void = () => {}; + mockIPC((cmd, args) => { + if (cmd === 'export') { + const channel = (args as { onEvent: { onmessage: (e: JobEvent) => void } }).onEvent; + send = (e) => channel.onmessage(e); + return 11; + } + return undefined; + }); + lib.getState().add([entry(1)]); + lib.getState().applyEvent(analysed(1)); + pipeline().send({ type: 'EXPORT', job: { request, planned: [] } }); + await vi.waitFor(() => expect(pipeline().getSnapshot().context.jobId).toBe(11)); + send({ type: 'exportDone', jobId: 11, fileId: 1, row: doneRow(), inPlace: false, update: null }); + send({ type: 'finished', jobId: 11, cancelled: false }); + expect(lib.getState().rows[1]?.state).toBe('done'); + expect(pipeline().getSnapshot().value).toBe('idle'); + }); + + it('an in-place row with no update is analysed again after the export', async () => { + const { pipeline, useLibrary: lib } = await fresh(); + const started: string[] = []; + let send: (e: JobEvent) => void = () => {}; + mockIPC((cmd, args) => { + started.push(cmd); + if (cmd === 'export') { + const channel = (args as { onEvent: { onmessage: (e: JobEvent) => void } }).onEvent; + send = (e) => channel.onmessage(e); + return 11; + } + if (cmd === 'analyze') return 12; + return undefined; + }); + lib.getState().add([entry(1)]); + lib.getState().applyEvent(analysed(1)); + pipeline().send({ type: 'EXPORT', job: { request, planned: [] } }); + await vi.waitFor(() => expect(pipeline().getSnapshot().context.jobId).toBe(11)); + send({ type: 'exportDone', jobId: 11, fileId: 1, row: doneRow(), inPlace: true, update: null }); + expect(started).not.toContain('analyze'); + send({ type: 'finished', jobId: 11, cancelled: false }); + await vi.waitFor(() => expect(started).toContain('analyze')); + expect(pipeline().getSnapshot().value).toBe('analysing'); + }); +}); + +describe('a reload during an export', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('reads the session until the job has ended, then finds the links and starts what waited', async () => { + const { pipeline, useLibrary: lib, WIND_DOWN_POLL_MS } = await fresh(); + const answers = [ + snapshot({ + runningJobs: [9], + rows: [{ entry: entry(1), row: null, plan: null, export: null, revertible: false, exportStale: false, analysedRange: null }], + }), + snapshot({ + runningJobs: [], + artefacts: { folder: '/x', xml: '/x/a.xml', report: '/x/a.csv', listed: 1 }, + rows: [{ entry: entry(1), row: analysis(), plan: plan(), export: doneRow(), revertible: true, exportStale: false, analysedRange: null }], + }), + ]; + const reads: number[] = []; + mockIPC((cmd) => { + if (cmd === 'restore_session') { + reads.push(1); + return answers.shift(); + } + return undefined; + }); + pipeline().send({ type: 'WIND_DOWN' }); + expect(pipeline().getSnapshot().value).toBe('windingDown'); + + await act(async () => vi.advanceTimersByTimeAsync(WIND_DOWN_POLL_MS)); + expect(reads).toHaveLength(1); + expect(pipeline().getSnapshot().value).toBe('windingDown'); + expect(lib.getState().locked).toBe('all'); + + await act(async () => vi.advanceTimersByTimeAsync(WIND_DOWN_POLL_MS)); + expect(reads).toHaveLength(2); + expect(pipeline().getSnapshot().value).toBe('idle'); + expect(lib.getState().locked).toBeNull(); + expect(lib.getState().artefacts?.xml).toBe('/x/a.xml'); + expect(lib.getState().rows[1]?.revertible).toBe(true); + }); + + it('analyses a file written in place during the reload, once the job has ended', async () => { + const { pipeline, useLibrary: lib, WIND_DOWN_POLL_MS } = await fresh(); + const calls: string[] = []; + const answers = [ + snapshot({ + rows: [{ entry: entry(1), row: null, plan: null, export: doneRow(), revertible: true, exportStale: false, analysedRange: null }], + }), + ]; + mockIPC((cmd) => { + calls.push(cmd); + if (cmd === 'restore_session') return answers.shift(); + return cmd === 'analyze' ? 3 : undefined; + }); + pipeline().send({ type: 'WIND_DOWN' }); + await act(async () => vi.advanceTimersByTimeAsync(WIND_DOWN_POLL_MS)); + expect(calls).toContain('analyze'); + expect(pipeline().getSnapshot().value).toBe('analysing'); + expect(lib.getState().rows[1]).toMatchObject({ state: 'queued', revertible: true }); + expect(lib.getState().rows[1]?.export?.stage).toBe('done'); + }); + + it('says stopped by the reload only of the export the reload stopped', async () => { + const summary = { jobId: 9, written: 1, xmlOnly: 0, skipped: 0, failed: 0, cancelled: true, seconds: 3 }; + const { pipeline, useLibrary: lib, WIND_DOWN_POLL_MS } = await fresh(); + // A reload during an analysis (job 5), with an older export (job 9) on record. + const answers = [snapshot({ runningJobs: [], lastExport: summary })]; + mockIPC((cmd) => (cmd === 'restore_session' ? answers.shift() : undefined)); + lib.getState().restore(snapshot({ runningJobs: [5], lastExport: summary })); + pipeline().send({ type: 'WIND_DOWN' }); + await act(async () => vi.advanceTimersByTimeAsync(WIND_DOWN_POLL_MS)); + expect(lib.getState().lastExport?.stoppedByReload).toBeFalsy(); + + // The export itself. + const again = [snapshot({ runningJobs: [], lastExport: summary })]; + mockIPC((cmd) => (cmd === 'restore_session' ? again.shift() : undefined)); + lib.getState().restore(snapshot({ runningJobs: [9] })); + pipeline().send({ type: 'WIND_DOWN' }); + await act(async () => vi.advanceTimersByTimeAsync(WIND_DOWN_POLL_MS)); + expect(lib.getState().lastExport?.stoppedByReload).toBe(true); + }); + + it('gives up after a minute and says to restart', async () => { + const { pipeline, useLibrary: lib, WIND_DOWN_LIMIT_MS } = await fresh(); + let reads = 0; + mockIPC((cmd) => { + if (cmd !== 'restore_session') return undefined; + reads += 1; + return snapshot({ runningJobs: [9] }); + }); + pipeline().send({ type: 'WIND_DOWN' }); + await act(async () => vi.advanceTimersByTimeAsync(WIND_DOWN_LIMIT_MS - 1000)); + expect(pipeline().getSnapshot().value).toBe('windingDown'); + expect(lib.getState().notice).toBeNull(); + await act(async () => vi.advanceTimersByTimeAsync(2000)); + expect(pipeline().getSnapshot().value).toBe('idle'); + expect(lib.getState().notice).toMatch(/Restart SoundCheck/); + const at = reads; + await act(async () => vi.advanceTimersByTimeAsync(5000)); + expect(reads).toBe(at); + }); + + it('does not wait for ever when the session cannot be read', async () => { + const { pipeline, useLibrary: lib, WIND_DOWN_POLL_MS } = await fresh(); + mockIPC(() => { + throw { kind: 'internal', message: 'gone', fileId: null }; + }); + pipeline().send({ type: 'WIND_DOWN' }); + await act(async () => vi.advanceTimersByTimeAsync(WIND_DOWN_POLL_MS)); + expect(pipeline().getSnapshot().value).toBe('idle'); + expect(lib.getState().locked).toBeNull(); + }); +}); + diff --git a/src/features/pipeline/actions.ts b/src/features/pipeline/actions.ts index e7003c8..28f5ee1 100644 --- a/src/features/pipeline/actions.ts +++ b/src/features/pipeline/actions.ts @@ -1,12 +1,65 @@ import { createActor, type Actor } from 'xstate'; -import { analyze, calibrationTarget, cancelJob, clearSession, expandPaths } from '@/lib/ipc'; +import { + analyze, + calibrationTarget, + cancelJob, + clearSession, + expandPaths, + exportFiles, + ipcErrorOf, + restoreSession, + type JobEvent, +} from '@/lib/ipc'; import { ask } from '@/lib/platform'; -import { useLibrary, staleIds } from '@/state/library'; +import { staleIds, useLibrary, writtenWithoutAnalysis } from '@/state/library'; import { analysisSettings, useSettings } from '@/state/settings'; import { pipelineMachine } from './machine'; +/** How often a reloaded window reads the session while the job it cannot hear winds down. */ +export const WIND_DOWN_POLL_MS = 500; + +/** How long it waits for that job before saying something is wrong; a cancel takes seconds. */ +export const WIND_DOWN_LIMIT_MS = 60_000; + /** The one pipeline of the app, wired to the engine and the library store. */ function createPipeline(): Actor { + /** A job that never started: what stopped it shows, and its rows end as they were. */ + const refused = (err: unknown) => { + const lib = useLibrary.getState(); + lib.applyEvent({ type: 'aborted', jobId: 0, error: ipcErrorOf(err) }); + lib.applyEvent({ type: 'finished', jobId: 0, cancelled: false }); + actor.send({ type: 'FINISHED' }); + }; + const onExportEvent = (event: JobEvent) => { + useLibrary.getState().applyEvent(event); + // Replaced in place and not analysed: what the row showed is gone, so it is analysed again. + if (event.type === 'exportDone' && event.inPlace && !event.update) { + actor.send({ type: 'ANALYSE', fileIds: [event.fileId] }); + } + if (event.type === 'finished') actor.send({ type: 'FINISHED' }); + }; + const watchSession = async () => { + const limit = Date.now() + WIND_DOWN_LIMIT_MS; + try { + for (;;) { + await new Promise((go) => setTimeout(go, WIND_DOWN_POLL_MS)); + const snapshot = await restoreSession(); + useLibrary.getState().restore(snapshot); + if (snapshot.runningJobs.length === 0) break; + if (Date.now() >= limit) { + useLibrary + .getState() + .setNotice('A job from before the window reloaded has not stopped. Restart SoundCheck to be sure of its files.'); + break; + } + } + } catch { + // The session cannot be read: nothing more can be known about that job. + } + useLibrary.getState().windDownEnded(); + analyseWritten(); + actor.send({ type: 'WOUND_DOWN' }); + }; const actor: Actor = createActor( pipelineMachine.provide({ actions: { @@ -20,21 +73,19 @@ function createPipeline(): Actor { if (event.type === 'finished') actor.send({ type: 'FINISHED' }); }) .then((jobId) => actor.send({ type: 'STARTED', jobId })) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - const lib = useLibrary.getState(); - lib.applyEvent({ - type: 'aborted', - jobId: 0, - error: { kind: 'internal', message, fileId: null }, - }); - lib.applyEvent({ type: 'finished', jobId: 0, cancelled: false }); - actor.send({ type: 'FINISHED' }); - }); + .catch(refused); + }, + startExport: (_, { job }) => { + if (!job) return; + useLibrary.getState().beginExport(job.request.fileIds, job.planned); + exportFiles(job.request, onExportEvent) + .then((jobId) => actor.send({ type: 'STARTED', jobId })) + .catch(refused); }, cancelJob: (_, { jobId }) => { if (jobId !== null) void cancelJob(jobId); }, + watchSession: () => void watchSession(), }, }), ); @@ -50,6 +101,13 @@ export function pipeline(): Actor { return singleton; } +/** Analyses rows written in place whose own analysis did not finish, as a live export does. */ +export function analyseWritten(): void { + const { rows, order } = useLibrary.getState(); + const fileIds = writtenWithoutAnalysis(rows, order); + if (fileIds.length > 0) pipeline().send({ type: 'ANALYSE', fileIds }); +} + /** Adds dropped or chosen paths and starts analysing the new rows. */ export async function addPaths(paths: string[]): Promise { if (paths.length === 0) return; @@ -73,7 +131,7 @@ export async function clearList(): Promise { } /** Stops the running job. */ -export function cancelAnalysis(): void { +export function cancelRunningJob(): void { pipeline().send({ type: 'CANCEL' }); } diff --git a/src/features/pipeline/machine.test.ts b/src/features/pipeline/machine.test.ts index fcb82ac..a19aad9 100644 --- a/src/features/pipeline/machine.test.ts +++ b/src/features/pipeline/machine.test.ts @@ -1,19 +1,48 @@ import { createActor } from 'xstate'; -import { pipelineMachine } from './machine'; +import { pipelineMachine, type PipelineEvent } from './machine'; function run() { const started: number[][] = []; + const exports: number[][] = []; const cancelled: (number | null)[] = []; + let watched = 0; const actor = createActor( pipelineMachine.provide({ actions: { startJob: (_, { fileIds }) => void started.push(fileIds), + startExport: (_, { job }) => void exports.push(job?.request.fileIds ?? []), cancelJob: (_, { jobId }) => void cancelled.push(jobId), + watchSession: () => { + watched += 1; + }, }, }), ); actor.start(); - return { actor, started, cancelled }; + return { actor, started, exports, cancelled, watched: () => watched }; +} + +function exportEvent(fileIds: number[]): PipelineEvent { + return { + type: 'EXPORT', + job: { + request: { + fileIds, + settings: { + batchMode: 'prepare', + place: 'inPlace', + depth: null, + gridOnly: false, + tbpm: true, + xml: true, + leadMs: 5, + }, + outDir: null, + confirmed: null, + }, + planned: [], + }, + }; } describe('pipeline machine', () => { @@ -77,4 +106,94 @@ describe('pipeline machine', () => { expect(actor.getSnapshot().value).toBe('analysing'); expect(started).toEqual([[1], [5, 6]]); }); + + describe('export', () => { + it('runs the export, then goes idle when it finishes', () => { + const { actor, exports, started } = run(); + actor.send(exportEvent([1, 2])); + expect(actor.getSnapshot().value).toBe('exporting'); + expect(exports).toEqual([[1, 2]]); + expect(started).toEqual([]); + actor.send({ type: 'STARTED', jobId: 4 }); + expect(actor.getSnapshot().context.jobId).toBe(4); + actor.send({ type: 'FINISHED' }); + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.jobId).toBeNull(); + }); + + it('ignores an export of nothing', () => { + const { actor, exports } = run(); + actor.send(exportEvent([])); + expect(actor.getSnapshot().value).toBe('idle'); + expect(exports).toEqual([]); + }); + + it('is one job at a time: no export starts during an analysis or another export', () => { + const { actor, exports } = run(); + actor.send({ type: 'ANALYSE', fileIds: [1] }); + actor.send(exportEvent([1])); + expect(actor.getSnapshot().value).toBe('analysing'); + expect(exports).toEqual([]); + actor.send({ type: 'FINISHED' }); + actor.send(exportEvent([1])); + actor.send(exportEvent([2])); + expect(exports).toEqual([[1]]); + }); + + it('files added during an export wait and start as the next analysis', () => { + const { actor, started } = run(); + actor.send(exportEvent([1])); + actor.send({ type: 'ANALYSE', fileIds: [2] }); + actor.send({ type: 'ANALYSE', fileIds: [2, 3] }); + expect(started).toEqual([]); + actor.send({ type: 'FINISHED' }); + expect(actor.getSnapshot().value).toBe('analysing'); + expect(started).toEqual([[2, 3]]); + }); + + it('cancel stops the export and keeps the files waiting, which the export did not start', () => { + const { actor, cancelled, started } = run(); + actor.send(exportEvent([1])); + actor.send({ type: 'STARTED', jobId: 8 }); + actor.send({ type: 'ANALYSE', fileIds: [5] }); + actor.send({ type: 'CANCEL' }); + expect(actor.getSnapshot().value).toBe('cancellingExport'); + expect(cancelled).toEqual([8]); + actor.send({ type: 'FINISHED' }); + expect(started).toEqual([[5]]); + }); + + it('a cancel before the job id arrives cancels the export on arrival', () => { + const { actor, cancelled } = run(); + actor.send(exportEvent([1])); + actor.send({ type: 'CANCEL' }); + actor.send({ type: 'STARTED', jobId: 6 }); + expect(cancelled).toEqual([null, 6]); + actor.send({ type: 'FINISHED' }); + expect(actor.getSnapshot().value).toBe('idle'); + }); + }); + + describe('a reload during a job', () => { + it('waits for the old job before starting anything', () => { + const { actor, started, exports, watched } = run(); + actor.send({ type: 'WIND_DOWN' }); + expect(actor.getSnapshot().value).toBe('windingDown'); + expect(watched()).toBe(1); + actor.send({ type: 'ANALYSE', fileIds: [3] }); + actor.send(exportEvent([3])); + expect(started).toEqual([]); + expect(exports).toEqual([]); + actor.send({ type: 'WOUND_DOWN' }); + expect(actor.getSnapshot().value).toBe('analysing'); + expect(started).toEqual([[3]]); + }); + + it('goes idle when nothing waits', () => { + const { actor } = run(); + actor.send({ type: 'WIND_DOWN' }); + actor.send({ type: 'WOUND_DOWN' }); + expect(actor.getSnapshot().value).toBe('idle'); + }); + }); }); diff --git a/src/features/pipeline/machine.ts b/src/features/pipeline/machine.ts index 57077f4..3c2d464 100644 --- a/src/features/pipeline/machine.ts +++ b/src/features/pipeline/machine.ts @@ -1,23 +1,43 @@ import { assign, setup } from 'xstate'; +import type { ExportPreviewRow, ExportRequest } from '@/lib/ipc'; /** - * The analysis pipeline: at most one job at a time. Files added while a job runs wait in - * `pending` and start as the next job when it finishes. Cancelling drops the files waiting at - * that moment; files added while the cancelled job winds down still run next. + * The job pipeline: at most one job at a time, an analysis or an export. Files added while a job + * runs wait in `pending` and start as the next analysis when it finishes. Cancelling an analysis + * drops the files waiting at that moment; files added while the cancelled job winds down still + * run next. Cancelling an export keeps them, since it was the export that was stopped. * - * The side effects are provided by the caller: `startJob` (start a job for `pending`, then send - * `STARTED` with its id and `FINISHED` when its last event arrives) and `cancelJob`. + * `windingDown` is a window that reloaded while a job ran: the new page cannot hear that job, so it + * starts nothing until the session says the job has ended. + * + * The side effects are provided by the caller: `startJob` and `startExport` (start a job, then send + * `STARTED` with its id and `FINISHED` when its last event arrives), `cancelJob`, and `watchSession` + * (send `WOUND_DOWN` once no job is left). */ export interface PipelineContext { jobId: number | null; pending: number[]; + /** The export to start, and its confirm step's rows. */ + exportJob: ExportJob | null; +} + +export interface ExportJob { + request: ExportRequest; + planned: ExportPreviewRow[]; } export type PipelineEvent = | { type: 'ANALYSE'; fileIds: number[] } + | { type: 'EXPORT'; job: ExportJob } | { type: 'STARTED'; jobId: number } | { type: 'CANCEL' } - | { type: 'FINISHED' }; + | { type: 'FINISHED' } + | { type: 'WIND_DOWN' } + | { type: 'WOUND_DOWN' }; + +function queued(context: PipelineContext, fileIds: number[]): number[] { + return [...context.pending, ...fileIds.filter((id) => !context.pending.includes(id))]; +} export const pipelineMachine = setup({ types: { @@ -26,15 +46,28 @@ export const pipelineMachine = setup({ }, actions: { startJob: (_, _params: { fileIds: number[] }) => {}, + startExport: (_, _params: { job: ExportJob | null }) => {}, cancelJob: (_, _params: { jobId: number | null }) => {}, + watchSession: () => {}, + queueFiles: assign({ + pending: ({ context, event }) => + event.type === 'ANALYSE' ? queued(context, event.fileIds) : context.pending, + }), + jobStarted: assign({ + jobId: ({ context, event }) => (event.type === 'STARTED' ? event.jobId : context.jobId), + }), }, guards: { hasPending: ({ context }) => context.pending.length > 0, }, }).createMachine({ id: 'pipeline', - context: { jobId: null, pending: [] }, + context: { jobId: null, pending: [], exportJob: null }, initial: 'idle', + on: { + // From anywhere: only the first read after a reload can send it, and then nothing runs yet. + WIND_DOWN: { target: '.windingDown' }, + }, states: { idle: { on: { @@ -43,6 +76,11 @@ export const pipelineMachine = setup({ target: 'analysing', actions: assign({ pending: ({ event }) => event.fileIds }), }, + EXPORT: { + guard: ({ event }) => event.job.request.fileIds.length > 0, + target: 'exporting', + actions: assign({ exportJob: ({ event }) => event.job }), + }, }, }, analysing: { @@ -51,15 +89,8 @@ export const pipelineMachine = setup({ assign({ pending: [], jobId: null }), ], on: { - STARTED: { actions: assign({ jobId: ({ event }) => event.jobId }) }, - ANALYSE: { - actions: assign({ - pending: ({ context, event }) => [ - ...context.pending, - ...event.fileIds.filter((id) => !context.pending.includes(id)), - ], - }), - }, + STARTED: { actions: 'jobStarted' }, + ANALYSE: { actions: 'queueFiles' }, CANCEL: { target: 'cancelling' }, FINISHED: [ { guard: 'hasPending', target: 'analysing', reenter: true }, @@ -74,18 +105,11 @@ export const pipelineMachine = setup({ ], on: { // Files added while the old job winds down start as the next job. - ANALYSE: { - actions: assign({ - pending: ({ context, event }) => [ - ...context.pending, - ...event.fileIds.filter((id) => !context.pending.includes(id)), - ], - }), - }, + ANALYSE: { actions: 'queueFiles' }, // The job id can arrive after Cancel was pressed; cancel it then. STARTED: { actions: [ - assign({ jobId: ({ event }) => event.jobId }), + 'jobStarted', { type: 'cancelJob', params: ({ event }) => ({ jobId: event.jobId }) }, ], }, @@ -95,5 +119,46 @@ export const pipelineMachine = setup({ ], }, }, + exporting: { + entry: [ + { + type: 'startExport', + params: ({ context }) => ({ job: context.exportJob }), + }, + assign({ jobId: null, exportJob: null }), + ], + on: { + STARTED: { actions: 'jobStarted' }, + ANALYSE: { actions: 'queueFiles' }, + CANCEL: { target: 'cancellingExport' }, + FINISHED: [ + { guard: 'hasPending', target: 'analysing', actions: assign({ jobId: null }) }, + { target: 'idle', actions: assign({ jobId: null }) }, + ], + }, + }, + cancellingExport: { + entry: { type: 'cancelJob', params: ({ context }) => ({ jobId: context.jobId }) }, + on: { + ANALYSE: { actions: 'queueFiles' }, + STARTED: { + actions: [ + 'jobStarted', + { type: 'cancelJob', params: ({ event }) => ({ jobId: event.jobId }) }, + ], + }, + FINISHED: [ + { guard: 'hasPending', target: 'analysing', actions: assign({ jobId: null }) }, + { target: 'idle', actions: assign({ jobId: null }) }, + ], + }, + }, + windingDown: { + entry: 'watchSession', + on: { + ANALYSE: { actions: 'queueFiles' }, + WOUND_DOWN: [{ guard: 'hasPending', target: 'analysing' }, { target: 'idle' }], + }, + }, }, }); diff --git a/src/features/settings/Rail.test.tsx b/src/features/settings/Rail.test.tsx index 700709e..4c64d2b 100644 --- a/src/features/settings/Rail.test.tsx +++ b/src/features/settings/Rail.test.tsx @@ -2,12 +2,32 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { mockIPC } from '@tauri-apps/api/mocks'; import type { DecideSettings } from '@/lib/ipc'; -import { useSettings } from '@/state/settings'; +import { useLibrary } from '@/state/library'; +import { useRecovery } from '@/state/recovery'; +import { DEFAULT_SETTINGS, sanitize, savedPart, useSettings } from '@/state/settings'; +import { analysed, entry } from '@/test/fixtures'; import { Rail } from './Rail'; +import { pipeline } from '@/features/pipeline/actions'; import { startSettingsSync } from './sync'; const initial = useSettings.getState(); -beforeEach(() => useSettings.setState(initial, true)); +const initialLibrary = useLibrary.getState(); +beforeEach(() => { + useSettings.setState(initial, true); + useLibrary.setState(initialLibrary, true); + useRecovery.setState({ running: false }); +}); + +/** A row in `folder`, analysed. */ +function addRow(id: number, folder: string, serato = false) { + const e = entry(id); + const row = { ...e, path: `${folder}/t${id}.wav`, info: { ...e.info, serato } }; + useLibrary.getState().add([row]); + useLibrary.getState().applyEvent(analysed(id)); +} + +const pressed = (name: string) => screen.getByRole('button', { name, pressed: true }); +const exportButton = () => screen.getByRole('button', { name: /^Export/ }); describe('Rail', () => { it('switching to Streaming replans with integrated loudness at -14 LUFS', async () => { @@ -36,4 +56,230 @@ describe('Rail', () => { await userEvent.type(target, '-9.5{Enter}'); expect(useSettings.getState()).toMatchObject({ target: -9.5, djTarget: -9.5, preset: 'djTarget' }); }); + + describe('Export section', () => { + it('shows the settings with their defaults', () => { + mockIPC(() => []); + render(); + expect(pressed('Prepare')).toBeInTheDocument(); + expect(pressed('To folder')).toBeInTheDocument(); + expect(pressed('Source')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Grid only (no audio change)' })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Write TBPM' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Write rekordbox XML' })).toBeChecked(); + }); + + it('each control lands in the saved settings', async () => { + mockIPC(() => []); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Library' })); + await userEvent.click(screen.getByRole('button', { name: 'In place with backup' })); + await userEvent.click(screen.getByRole('button', { name: '24-bit' })); + await userEvent.click(screen.getByRole('checkbox', { name: 'Write rekordbox XML' })); + // TBPM is per mode: off by default in Library, a track's tag being the DJ app's. + const tbpm = screen.getByRole('checkbox', { name: 'Write TBPM' }); + expect(tbpm).not.toBeChecked(); + await userEvent.click(tbpm); + const saved = savedPart(useSettings.getState()); + expect(saved).toMatchObject({ + batchMode: 'library', + place: 'inPlace', + depth: 24, + xml: false, + tbpm: { prepare: true, library: true }, + }); + // What is saved reads back as it was. + expect(sanitize(JSON.parse(JSON.stringify(saved)) as Record)).toMatchObject({ + batchMode: 'library', + place: 'inPlace', + depth: 24, + xml: false, + }); + }); + + it('saves an export setting without replanning every row', async () => { + const calls: string[] = []; + mockIPC((cmd) => { + calls.push(cmd); + return cmd === 'set_decide_settings' ? { revision: 1, plans: [] } : undefined; + }); + useSettings.setState({ hydrated: false }); + const stop = startSettingsSync(); + await waitFor(() => expect(useSettings.getState().hydrated).toBe(true)); + await waitFor(() => expect(calls).toContain('set_decide_settings')); + calls.length = 0; + useSettings.getState().setMode('streaming'); + await waitFor(() => expect(calls.filter((c) => c === 'set_decide_settings')).toHaveLength(1)); + const saves = () => calls.filter((c) => c === 'plugin:store|set').length; + const before = saves(); + useSettings.getState().setXml(false); + await waitFor(() => expect(saves()).toBeGreaterThan(before)); + // Only the first change, which decides differently. + expect(calls.filter((c) => c === 'set_decide_settings')).toHaveLength(1); + stop(); + }); + + it('asks for a folder for To folder, and shows the one chosen', async () => { + mockIPC((cmd) => (cmd === 'plugin:dialog|open' ? '/Users/dj/Music/Out' : undefined)); + render(); + expect(screen.getByText('No folder chosen')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Choose…' })); + await waitFor(() => expect(useSettings.getState().outDir).toBe('/Users/dj/Music/Out')); + expect(screen.getByText('~/Music/Out')).toBeInTheDocument(); + }); + + it('a cancelled folder picker keeps the folder set', async () => { + mockIPC((cmd) => (cmd === 'plugin:dialog|open' ? null : undefined)); + useSettings.setState({ place: 'folder', outDir: '/kept' }); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Choose…' })); + expect(useSettings.getState().outDir).toBe('/kept'); + }); + + it('grid only takes the depth out of it', async () => { + mockIPC(() => []); + useSettings.setState({ depth: 24 }); + render(); + expect(pressed('24-bit')).toBeEnabled(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Grid only (no audio change)' })); + expect(pressed('Source')).toBeDisabled(); + }); + }); + + describe('Serato notice', () => { + it('a Serato row in Prepare switches the batch to Library and says why', () => { + mockIPC(() => []); + addRow(1, '/Music/Cued', true); + render(); + expect(pressed('Library')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('1 track holds Serato cue points, which a cut would move'); + // The saved mode is not changed: new tracks are Prepare again. + expect(useSettings.getState().batchMode).toBe('prepare'); + }); + + it('no notice without Serato data, or when Library was chosen', () => { + mockIPC(() => []); + addRow(1, '/Music/New'); + const { unmount } = render(); + expect(pressed('Prepare')).toBeInTheDocument(); + expect(screen.queryByRole('status')).toBeNull(); + unmount(); + useSettings.setState({ batchMode: 'library' }); + addRow(2, '/Music/Cued', true); + render(); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('choosing Prepare is remembered for the folder, and only for it', async () => { + mockIPC(() => []); + addRow(1, '/Music/Cued', true); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Use Prepare for this folder' })); + expect(pressed('Prepare')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Prepare kept for this folder'); + expect(useSettings.getState().seratoPrepareFolders).toEqual(['/Music/Cued']); + expect(savedPart(useSettings.getState()).seratoPrepareFolders).toEqual(['/Music/Cued']); + addRow(2, '/Music/Other', true); + await waitFor(() => expect(pressed('Library')).toBeInTheDocument()); + }); + + it('the remembered folder is read back at the next start', () => { + mockIPC(() => []); + useSettings.getState().hydrate(sanitize({ seratoPrepareFolders: ['/Music/Cued'] })); + addRow(1, '/Music/Cued', true); + render(); + expect(pressed('Prepare')).toBeInTheDocument(); + }); + + it('Use Library takes the folder back', async () => { + mockIPC(() => []); + useSettings.setState({ seratoPrepareFolders: ['/Music/Cued'] }); + addRow(1, '/Music/Cued', true); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Use Library' })); + expect(pressed('Library')).toBeInTheDocument(); + expect(useSettings.getState().seratoPrepareFolders).toEqual([]); + }); + }); + + describe('the Export button', () => { + it('counts the rows it exports', () => { + mockIPC(() => []); + addRow(1, '/a'); + addRow(2, '/a'); + useSettings.setState({ outDir: '/Out' }); + render(); + expect(exportButton()).toHaveTextContent('Export 2'); + expect(exportButton()).toBeEnabled(); + }); + + it('is disabled with the reason while recovery runs', () => { + mockIPC(() => []); + addRow(1, '/a'); + useRecovery.setState({ running: true }); + render(); + expect(exportButton()).toBeDisabled(); + expect(screen.getByText(/crash interrupted/)).toBeInTheDocument(); + }); + + it('waits for a folder when one is needed', () => { + mockIPC(() => []); + addRow(1, '/a'); + useSettings.setState({ place: 'folder', outDir: null }); + render(); + expect(exportButton()).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Choose a folder' })).toBeInTheDocument(); + }); + + it('the hint opens the folder picker, and Export is ready after it', async () => { + mockIPC((cmd) => (cmd === 'plugin:dialog|open' ? '/Users/dj/Out' : [])); + addRow(1, '/a'); + useSettings.setState({ place: 'folder', outDir: null }); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Choose a folder' })); + await waitFor(() => expect(useSettings.getState().outDir).toBe('/Users/dj/Out')); + expect(exportButton()).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Choose a folder' })).toBeNull(); + }); + + it('other reasons stay plain text', () => { + mockIPC(() => []); + addRow(1, '/a'); + useSettings.setState({ place: 'folder', outDir: null }); + useRecovery.setState({ running: true }); + render(); + expect(screen.queryByRole('button', { name: 'Choose a folder' })).toBeNull(); + }); + + it('is disabled with the reason while a job runs', () => { + mockIPC((cmd) => (cmd === 'analyze' ? 3 : [])); + addRow(1, '/a'); + useSettings.setState({ outDir: '/Out' }); + pipeline().send({ type: 'ANALYSE', fileIds: [1] }); + render(); + expect(exportButton()).toBeDisabled(); + expect(screen.getByText('A job is running.')).toBeInTheDocument(); + pipeline().send({ type: 'FINISHED' }); + }); + + it('tells a row analysed under another BPM range to be analysed again', () => { + mockIPC(() => []); + addRow(1, '/a'); + useLibrary.getState().queue([1], [60, 120]); + useLibrary.getState().applyEvent(analysed(1)); + useLibrary.getState().applyEvent({ type: 'finished', jobId: 1, cancelled: false }); + useSettings.setState({ outDir: '/Out' }); + render(); + expect(exportButton()).toBeDisabled(); + expect(screen.getByText('1 track needs Analyse for this BPM range.')).toBeInTheDocument(); + }); + + it('with nothing analysed there is nothing to press', () => { + mockIPC(() => []); + useLibrary.getState().add([entry(1)]); + render(); + expect(exportButton()).toBeDisabled(); + expect(DEFAULT_SETTINGS.place).toBe('folder'); + }); + }); }); diff --git a/src/features/settings/Rail.tsx b/src/features/settings/Rail.tsx index e8f4a5a..4e3f1eb 100644 --- a/src/features/settings/Rail.tsx +++ b/src/features/settings/Rail.tsx @@ -1,49 +1,10 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { cn } from '@/lib/utils'; +import { useEffect, useRef, useState } from 'react'; import type { LoudnessMode } from '@/lib/ipc'; import { useLibrary } from '@/state/library'; import { PRESETS, presetLabel, useSettings, type PresetId } from '@/state/settings'; import { calibrateFromLibrary } from '@/features/pipeline/actions'; - -function Section({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - -function Segmented({ - label, - value, - options, - onChange, -}: { - label: string; - value: T; - options: { id: T; label: string }[]; - onChange: (v: T) => void; -}) { - return ( -
- {options.map((o) => ( - - ))} -
- ); -} +import { ExportAction, ExportSection } from '@/features/export/ExportPanel'; +import { Section, Segmented } from './controls'; /** * A number field that commits on Enter or blur, so the table replans once per edit rather than @@ -140,8 +101,8 @@ export function Rail() { } /** - * What the batch is levelled to, and the DJ app's BPM range: in the rail, or in the settings - * drawer of a narrow window. + * What the batch is levelled to, the DJ app's BPM range and how the batch is exported: in the + * rail, or in the settings drawer of a narrow window. */ export function Settings() { const s = useSettings(); @@ -220,10 +181,9 @@ export function Settings() { The tempo octave is chosen inside this range first, as rekordbox does. Changing it marks tracks for Analyse.

+
-

- Originals untouched. Nothing is written in this version. -

+
); } diff --git a/src/features/settings/controls.tsx b/src/features/settings/controls.tsx new file mode 100644 index 0000000..c8246e9 --- /dev/null +++ b/src/features/settings/controls.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function Segmented({ + label, + value, + options, + onChange, + disabled = false, + fit = false, +}: { + label: string; + value: T; + options: { id: T; label: string }[]; + onChange: (v: T) => void; + disabled?: boolean; + /** Each option as wide as its label needs, for labels of very different lengths. */ + fit?: boolean; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +/** A setting that is on or off, on a native checkbox. */ +export function Toggle({ + label, + checked, + onChange, + disabled = false, +}: { + label: string; + checked: boolean; + onChange: (on: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/src/features/settings/sync.ts b/src/features/settings/sync.ts index 55d950c..3f27c4e 100644 --- a/src/features/settings/sync.ts +++ b/src/features/settings/sync.ts @@ -1,30 +1,40 @@ -import { setDecideSettings } from '@/lib/ipc'; +import { errorCopy, ipcErrorOf, setDecideSettings } from '@/lib/ipc'; import { loadSettings, saveSettings } from '@/lib/persist'; import { useLibrary } from '@/state/library'; import { decideSettings, savedPart, useSettings } from '@/state/settings'; +/** The latest change of the decide settings on its way to the engine. */ +let landing: Promise = Promise.resolve(); + +/** Resolves once every change made so far has reached the engine (or been refused by it). */ +export function settingsLanded(): Promise { + return landing; +} + /** * Reads the saved settings, then keeps the engine's decide settings and the saved copy in step - * with the store: every change replans every analysed row and is saved. Returns a stop function. + * with the store: every change is saved, and one that decides differently replans every analysed + * row (the export settings do not). Returns a stop function. */ export function startSettingsSync(): () => void { - let last = ''; + let lastSaved = ''; + let lastDecide = ''; const push = () => { const s = useSettings.getState(); if (!s.hydrated) return; - const key = JSON.stringify(savedPart(s)); - if (key === last) return; - last = key; + const saved = JSON.stringify(savedPart(s)); + if (saved === lastSaved) return; + lastSaved = saved; void saveSettings(savedPart(s)); - void setDecideSettings(decideSettings(s)) + const decide = JSON.stringify(decideSettings(s)); + if (decide === lastDecide) return; + lastDecide = decide; + const sent = setDecideSettings(decideSettings(s)) .then((replan) => useLibrary.getState().applyReplan(replan)) .catch((err: unknown) => { - const message = - typeof err === 'object' && err !== null && 'message' in err - ? String((err as { message: unknown }).message) - : String(err); - useLibrary.getState().setNotice(`The settings could not be applied: ${message}`); + useLibrary.getState().setNotice(`The settings could not be applied: ${errorCopy(ipcErrorOf(err))}`); }); + landing = Promise.all([landing, sent]); }; const unsubscribe = useSettings.subscribe(push); void loadSettings().then((saved) => useSettings.getState().hydrate(saved)); diff --git a/src/lib/focus.ts b/src/lib/focus.ts index 7d46ae9..3b26c03 100644 --- a/src/lib/focus.ts +++ b/src/lib/focus.ts @@ -19,6 +19,15 @@ export function ownsKey(target: EventTarget | null, key: string): boolean { const CONTROL = 'button, a[href], [role="button"], [role="menuitem"]'; +/** + * Commits what is being typed: a text field saves on blur, and a click on a button does not take + * focus from it ({@link keepFocusOnClick}), so anything that reads the settings must call this. + */ +export function commitFocusedField(): void { + const active = document.activeElement; + if (active instanceof HTMLInputElement && active.type !== 'checkbox') active.blur(); +} + /** * For `onMouseDownCapture` at the root: a click on a button does not move focus to it, as in a * native Mac app, so Space and Enter stay the window's shortcuts after a click. Tab still diff --git a/src/lib/format.ts b/src/lib/format.ts index ee4b305..7d613fe 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -81,20 +81,24 @@ export function actionText(plan: Plan): { tone: 'fg' | 'accent' | 'muted'; } { if (plan.skip) { + // Exporting leaves an AAC file as it is, and the rekordbox XML carries its grid. + if (plan.skip.type === 'analyseOnly' && plan.skip.codec === 'aac') { + return { main: 'Grid: XML only (AAC)', detail: 'AAC file writes arrive later', tone: 'muted' }; + } const main = plan.skip.type === 'analyseOnly' ? `Skip: ${CODEC_LABEL[plan.skip.codec]} is analyse-only` : 'Skip: silent'; - const detail = plan.skip.type === 'analyseOnly' ? 'Convert to AIFF arrives with export' : null; + const detail = plan.skip.type === 'analyseOnly' ? 'Convert to AIFF arrives later' : null; return { main, detail, tone: 'muted' }; } const gain = plan.gain; if (!gain || gain.type === 'atTarget') { return { main: 'Already at target', detail: null, tone: 'fg' }; } + // Exporting leaves an MP3 as it is for now, and the rekordbox XML carries its grid. if (gain.type === 'globalGain') { - const main = gain.steps === 0 ? 'Global-gain ±0 dB' : `Global-gain ${signed(gain.gainDb)} dB`; - return { main, detail: `Residual ${signed(gain.residualLu)} LU · MP3 moves in 1.5 dB steps`, tone: 'fg' }; + return { main: 'Grid: XML only (MP3)', detail: 'MP3 file writes arrive later', tone: 'muted' }; } if (gain.shortByLu > 0) { // The peak already sits at (or above) the ceiling: no boost at all is possible. diff --git a/src/lib/ipc/errors.test.ts b/src/lib/ipc/errors.test.ts new file mode 100644 index 0000000..44df426 --- /dev/null +++ b/src/lib/ipc/errors.test.ts @@ -0,0 +1,32 @@ +import { errorCopy, ipcErrorOf, rowErrorCopy } from './errors'; + +describe('rejected commands', () => { + it('keeps the typed error Rust sent, with its kind', () => { + const sent = { kind: 'busy', message: 'being exported', fileId: 3 }; + expect(ipcErrorOf(sent)).toEqual(sent); + }); + + it('never turns anything into [object Object]', () => { + expect(ipcErrorOf({ unexpected: true }).message).toBe('The command failed.'); + expect(ipcErrorOf(new Error('boom'))).toEqual({ kind: 'internal', message: 'boom', fileId: null }); + expect(ipcErrorOf('plain text').message).toBe('plain text'); + expect(ipcErrorOf(undefined).message).not.toContain('[object'); + }); + + it('words the timing refusals so the user can act on them', () => { + const busy = errorCopy({ kind: 'busy', message: 'raw', fileId: null }); + expect(busy).toMatch(/another job/i); + expect(busy).not.toContain('raw'); + const recovery = errorCopy({ kind: 'recoveryRunning', message: 'raw', fileId: null }); + expect(recovery).toMatch(/crash interrupted/); + expect(recovery).not.toContain('raw'); + expect(errorCopy({ kind: 'corrupt', message: 'truncated', fileId: 1 })).toBe('Truncated'); + expect(errorCopy({ kind: 'settingsChanged', message: 'raw', fileId: null })).toMatch(/Press Export again/); + }); + + it('on a file\'s own row, says it of this file rather than its long path', () => { + const error = { kind: 'fileChanged' as const, message: '/music/a.wav changed since you confirmed the export', fileId: 1 }; + expect(rowErrorCopy(error, '/music/a.wav')).toBe('This file changed since you confirmed the export'); + expect(rowErrorCopy(error, '/other.wav')).toBe('/music/a.wav changed since you confirmed the export'); + }); +}); diff --git a/src/lib/ipc/errors.ts b/src/lib/ipc/errors.ts new file mode 100644 index 0000000..896cc13 --- /dev/null +++ b/src/lib/ipc/errors.ts @@ -0,0 +1,42 @@ +import type { IpcError } from './generated/IpcError'; + +function isIpcError(err: unknown): err is IpcError { + return ( + typeof err === 'object' && + err !== null && + typeof (err as IpcError).kind === 'string' && + typeof (err as IpcError).message === 'string' + ); +} + +/** What a rejected command rejected with, as the typed error every Rust command sends. */ +export function ipcErrorOf(err: unknown): IpcError { + if (isIpcError(err)) return { kind: err.kind, message: err.message, fileId: err.fileId ?? null }; + const message = err instanceof Error ? err.message : typeof err === 'string' ? err : 'The command failed.'; + return { kind: 'internal', message, fileId: null }; +} + +/** Text from the engine, which words its reasons to follow a colon, as a sentence of its own. */ +export function sentence(text: string): string { + return text.charAt(0).toLocaleUpperCase() + text.slice(1); +} + +/** The refusals that are about timing rather than the file, in words the user can act on. */ +export function errorCopy(error: IpcError): string { + switch (error.kind) { + case 'busy': + return 'Another job is working on one of these files. Wait for it to finish, then try again.'; + case 'recoveryRunning': + return 'SoundCheck is still checking for changes a crash interrupted. Nothing was changed; try again in a moment.'; + case 'settingsChanged': + return 'The loudness settings changed after this summary, so nothing was written. Press Export again to see the new numbers.'; + default: + return sentence(error.message); + } +} + +/** {@link errorCopy} on a file's own row, where its long path would push out what it says. */ +export function rowErrorCopy(error: IpcError, path: string): string { + const copy = errorCopy(error); + return copy.startsWith(path) ? `This file${copy.slice(path.length)}` : copy; +} diff --git a/src/lib/ipc/export.test.ts b/src/lib/ipc/export.test.ts index 11604d0..028f74a 100644 --- a/src/lib/ipc/export.test.ts +++ b/src/lib/ipc/export.test.ts @@ -22,7 +22,7 @@ describe('export commands', () => { calls.push([cmd, args as Record]); return cmd === 'export' ? 7 : {}; }); - const req = { fileIds: [1, 2], settings, outDir: null }; + const req = { fileIds: [1, 2], settings, outDir: null, confirmed: null }; await expect(exportFiles(req, () => undefined)).resolves.toBe(7); await revertFile(3); await writeRekordboxXml({ fileIds: [1], out: null }); @@ -41,6 +41,6 @@ describe('export commands', () => { mockIPC(() => { throw refusal; }); - await expect(exportFiles({ fileIds: [1], settings, outDir: null }, () => undefined)).rejects.toEqual(refusal); + await expect(exportFiles({ fileIds: [1], settings, outDir: null, confirmed: null }, () => undefined)).rejects.toEqual(refusal); }); }); diff --git a/src/lib/ipc/generated/ConfirmedExport.ts b/src/lib/ipc/generated/ConfirmedExport.ts new file mode 100644 index 0000000..3f132f3 --- /dev/null +++ b/src/lib/ipc/generated/ConfirmedExport.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfirmedFile } from "./ConfirmedFile"; + +/** + * What the user saw in the confirm step, sent back with the export. + */ +export type ConfirmedExport = { +/** + * The settings revision the plan was decided with. + */ +revision: number, +/** + * Each file as it was planned. + */ +files: Array, }; diff --git a/src/lib/ipc/generated/ConfirmedFile.ts b/src/lib/ipc/generated/ConfirmedFile.ts new file mode 100644 index 0000000..cba2a97 --- /dev/null +++ b/src/lib/ipc/generated/ConfirmedFile.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One file as the confirm step planned it. + */ +export type ConfirmedFile = { +/** + * The file. + */ +fileId: number, +/** + * Its identity then (length, modification time, inode): opaque, compared whole. + */ +identity: string, }; diff --git a/src/lib/ipc/generated/ExportPreview.ts b/src/lib/ipc/generated/ExportPreview.ts new file mode 100644 index 0000000..35f5ed5 --- /dev/null +++ b/src/lib/ipc/generated/ExportPreview.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DecideSettings } from "./DecideSettings"; +import type { ExportPreviewRow } from "./ExportPreviewRow"; + +/** + * What `plan_export` found: the confirm step's numbers. + */ +export type ExportPreview = { +/** + * One row per file named, in that order. + */ +rows: Array, +/** + * The settings revision the rows were planned with, to send back ([`ConfirmedExport`]). + */ +revision: number, +/** + * The loudness settings they were planned with. + */ +decide: DecideSettings, +/** + * Tracks the rekordbox XML already in the folder lists, whose files are still there: they stay + * listed. + */ +xmlListed: number, +/** + * Where the originals go, for an export in place. + */ +backupFolder: string | null, +/** + * Where the rekordbox XML and grid report go, with the XML on: the export's folder, or for + * an export in place the folder a new dated folder is made in. + */ +artefactsFolder: string | null, }; diff --git a/src/lib/ipc/generated/ExportPreviewRow.ts b/src/lib/ipc/generated/ExportPreviewRow.ts new file mode 100644 index 0000000..617014a --- /dev/null +++ b/src/lib/ipc/generated/ExportPreviewRow.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExportRow } from "./ExportRow"; +import type { IpcError } from "./IpcError"; + +/** + * What exporting would do with one file, before anything is written. + */ +export type ExportPreviewRow = { +/** + * The file. + */ +fileId: number, +/** + * 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. + */ +row: ExportRow | null, +/** + * Why it could not be planned: not analysed yet, changed since it was analysed, or its start + * could not be read. + */ +error: IpcError | null, +/** + * The file as it was planned, to send back ([`ConfirmedFile`]); `None` without a plan. + */ +identity: string | null, +/** + * The write replaces an earlier copy of it SoundCheck wrote in the folder. + */ +replacesCopy: boolean, }; diff --git a/src/lib/ipc/generated/ExportRequest.ts b/src/lib/ipc/generated/ExportRequest.ts index c44e2ae..5b25dc1 100644 --- a/src/lib/ipc/generated/ExportRequest.ts +++ b/src/lib/ipc/generated/ExportRequest.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfirmedExport } from "./ConfirmedExport"; import type { ExportSettings } from "./ExportSettings"; /** @@ -16,4 +17,10 @@ settings: ExportSettings, /** * The absolute folder copies go to: required by, and only by, a `folder` place. */ -outDir: string | null, }; +outDir: string | null, +/** + * What the user confirmed, from `plan_export`: the job refuses to run on other settings, and + * a file no longer the one confirmed fails its row before anything is written. `None` checks + * nothing; the app always sends it. + */ +confirmed: ConfirmedExport | null, }; diff --git a/src/lib/ipc/generated/ExportRow.ts b/src/lib/ipc/generated/ExportRow.ts index 512d4db..8490a26 100644 --- a/src/lib/ipc/generated/ExportRow.ts +++ b/src/lib/ipc/generated/ExportRow.ts @@ -2,6 +2,7 @@ import type { ExportAction } from "./ExportAction"; import type { ExportNotice } from "./ExportNotice"; import type { ExportVerification } from "./ExportVerification"; +import type { ExportedWith } from "./ExportedWith"; import type { GridCheck } from "./GridCheck"; import type { JobStage } from "./JobStage"; @@ -49,6 +50,10 @@ editCarried: boolean, * a folder leaves the original as it was: nothing to revert. */ revertible: boolean, +/** + * The settings it was made with; absent where the row was not made by a job or a preview. + */ +exportedWith: ExportedWith | null, /** * What did not work after the write (the file stays written) and what the render could not * do, in order; bounded to [`MAX_ROW_NOTES_JSON_BYTES`], so the grid report holds the rest. diff --git a/src/lib/ipc/generated/ExportSkip.ts b/src/lib/ipc/generated/ExportSkip.ts index f76eeb8..2c1456a 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": "xmlNoGrid", +codec: Codec, } | { "type": "silent" } | { "type": "noGrid" } | { "type": "gridNeedsReview" } | { "type": "otherFileInFolder" } | { "type": "copyOfOtherTrack" } | { "type": "xmlNoGrid", /** * The codec. */ diff --git a/src/lib/ipc/generated/ExportSummary.ts b/src/lib/ipc/generated/ExportSummary.ts new file mode 100644 index 0000000..83d02a7 --- /dev/null +++ b/src/lib/ipc/generated/ExportSummary.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What became of the latest batch, kept for a window that reloads. + */ +export type ExportSummary = { +/** + * The job it was. + */ +jobId: number, +/** + * Files written. + */ +written: number, +/** + * Files only the rekordbox XML carries. + */ +xmlOnly: number, +/** + * Files skipped. + */ +skipped: number, +/** + * Files failed. + */ +failed: number, +/** + * It was cancelled. + */ +cancelled: boolean, +/** + * How long it took. + */ +seconds: number, }; diff --git a/src/lib/ipc/generated/ExportedWith.ts b/src/lib/ipc/generated/ExportedWith.ts new file mode 100644 index 0000000..daaf9f5 --- /dev/null +++ b/src/lib/ipc/generated/ExportedWith.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DecideSettings } from "./DecideSettings"; +import type { ExportSettings } from "./ExportSettings"; + +/** + * What a row's export was made with, so the row can say when the settings have moved on. + */ +export type ExportedWith = { +/** + * The loudness settings. + */ +decide: DecideSettings, +/** + * The export settings. + */ +settings: ExportSettings, +/** + * The folder, cut to [`MAX_EXPORTED_FOLDER_JSON_BYTES`] of JSON (a folder longer than that + * only reads as a different one). + */ +outDir: string | null, }; diff --git a/src/lib/ipc/generated/IpcErrorKind.ts b/src/lib/ipc/generated/IpcErrorKind.ts index a4103dd..af625fd 100644 --- a/src/lib/ipc/generated/IpcErrorKind.ts +++ b/src/lib/ipc/generated/IpcErrorKind.ts @@ -3,4 +3,4 @@ /** * Error classes as the UI sees them. */ -export type IpcErrorKind = "unsupportedFormat" | "unsupportedChannels" | "corrupt" | "io" | "cancelled" | "notDjSafe" | "drmProtected" | "wouldClip" | "modelUnavailable" | "invalidArgument" | "internal" | "rekordboxUsbExport" | "inPlaceRefused" | "noSpace" | "verifyFailed" | "fileChanged" | "nothingToUndo" | "alreadyExists" | "listedTwice" | "sameOutputName" | "recoveryRunning" | "busy"; +export type IpcErrorKind = "unsupportedFormat" | "unsupportedChannels" | "corrupt" | "io" | "cancelled" | "notDjSafe" | "drmProtected" | "wouldClip" | "modelUnavailable" | "invalidArgument" | "internal" | "rekordboxUsbExport" | "inPlaceRefused" | "noSpace" | "verifyFailed" | "fileChanged" | "nothingToUndo" | "alreadyExists" | "listedTwice" | "sameOutputName" | "recoveryRunning" | "busy" | "settingsChanged"; diff --git a/src/lib/ipc/generated/RowUpdate.ts b/src/lib/ipc/generated/RowUpdate.ts index b7b85cb..e76a7a4 100644 --- a/src/lib/ipc/generated/RowUpdate.ts +++ b/src/lib/ipc/generated/RowUpdate.ts @@ -21,4 +21,8 @@ plan: Plan, /** * The settings revision the plan was decided with. */ -revision: number, }; +revision: number, +/** + * A grid different from what the file's export wrote has been saved since it. + */ +exportStale: boolean, }; diff --git a/src/lib/ipc/generated/SessionRow.ts b/src/lib/ipc/generated/SessionRow.ts index 37dc4f3..011eda7 100644 --- a/src/lib/ipc/generated/SessionRow.ts +++ b/src/lib/ipc/generated/SessionRow.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Bpm } from "./Bpm"; import type { ExportRow } from "./ExportRow"; import type { FileEntry } from "./FileEntry"; import type { Plan } from "./Plan"; @@ -28,4 +29,13 @@ export: ExportRow | null, * A change of it is in effect that `revert_file` can put back: also true with no `export` * after a revert that left an earlier change standing. */ -revertible: boolean, }; +revertible: boolean, +/** + * A grid different from what its export wrote was saved since: exporting it again would write + * something new. + */ +exportStale: boolean, +/** + * The BPM range its analysis was made with, when analysed. + */ +analysedRange: [Bpm, Bpm] | null, }; diff --git a/src/lib/ipc/generated/SessionSnapshot.ts b/src/lib/ipc/generated/SessionSnapshot.ts index 58615c1..1381038 100644 --- a/src/lib/ipc/generated/SessionSnapshot.ts +++ b/src/lib/ipc/generated/SessionSnapshot.ts @@ -1,4 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExportArtefacts } from "./ExportArtefacts"; +import type { ExportSummary } from "./ExportSummary"; +import type { IpcError } from "./IpcError"; import type { SessionRow } from "./SessionRow"; /** @@ -12,4 +15,21 @@ revision: number, /** * The rows. */ -rows: Array, }; +rows: Array, +/** + * Jobs still winding down when this was read: they were cancelled, but until they end their + * files are busy and their rows can still change. Read again until this is empty. + */ +runningJobs: Array, +/** + * Where the latest batch's rekordbox XML and grid report are, until the next batch. + */ +artefacts: ExportArtefacts | null, +/** + * Why the latest batch's rekordbox XML and grid report could not be written. + */ +artefactsFailed: IpcError | null, +/** + * What became of the latest batch. + */ +lastExport: ExportSummary | null, }; diff --git a/src/lib/ipc/index.ts b/src/lib/ipc/index.ts index 4d902f3..bb9abd1 100644 --- a/src/lib/ipc/index.ts +++ b/src/lib/ipc/index.ts @@ -1,6 +1,7 @@ import { Channel, invoke } from '@tauri-apps/api/core'; import type { AnalyzeRequest } from './generated/AnalyzeRequest'; import type { DecideSettings } from './generated/DecideSettings'; +import type { ExportPreview } from './generated/ExportPreview'; import type { ExportRequest } from './generated/ExportRequest'; import type { FileEntry } from './generated/FileEntry'; import type { ForgetResult } from './generated/ForgetResult'; @@ -20,6 +21,8 @@ import type { XmlWritten } from './generated/XmlWritten'; export type { AnalyzeRequest } from './generated/AnalyzeRequest'; export type { AnalysisSettings } from './generated/AnalysisSettings'; export type { Codec } from './generated/Codec'; +export type { ConfirmedExport } from './generated/ConfirmedExport'; +export type { ConfirmedFile } from './generated/ConfirmedFile'; export type { BatchMode } from './generated/BatchMode'; export type { Confidence } from './generated/Confidence'; export type { Cut } from './generated/Cut'; @@ -28,9 +31,13 @@ export type { DjUnsafe } from './generated/DjUnsafe'; export type { ExportAction } from './generated/ExportAction'; export type { ExportArtefacts } from './generated/ExportArtefacts'; export type { ExportNotice } from './generated/ExportNotice'; +export type { ExportPreview } from './generated/ExportPreview'; +export type { ExportPreviewRow } from './generated/ExportPreviewRow'; export type { ExportRequest } from './generated/ExportRequest'; export type { ExportRow } from './generated/ExportRow'; export type { ExportSettings } from './generated/ExportSettings'; +export type { ExportSummary } from './generated/ExportSummary'; +export type { ExportedWith } from './generated/ExportedWith'; export type { ExportSkip } from './generated/ExportSkip'; export type { ExportVerification } from './generated/ExportVerification'; export type { FileEntry } from './generated/FileEntry'; @@ -58,6 +65,7 @@ export type { RowGrid } from './generated/RowGrid'; export type { Replan } from './generated/Replan'; export type { RevertResult } from './generated/RevertResult'; export type { RowPlan } from './generated/RowPlan'; +export type { RowUpdate } from './generated/RowUpdate'; export type { SessionRow } from './generated/SessionRow'; export type { SessionSnapshot } from './generated/SessionSnapshot'; export type { SkipReason } from './generated/SkipReason'; @@ -86,6 +94,14 @@ export function analyze(req: AnalyzeRequest, onEvent: (event: JobEvent) => void) return invoke('analyze', { req, onEvent: channel }); } +/** + * What exporting these files would do with each, planned as the job plans them. Nothing is + * written. Rejects as `exportFiles` does. + */ +export function planExport(req: ExportRequest): Promise { + return invoke('plan_export', { req }); +} + /** * Starts exporting; resolves to the job id at once while events arrive on `onEvent`, `finished` * last. Rejects with `recoveryRunning` while the start-up recovery runs, with `busy` while another @@ -136,7 +152,10 @@ export function clearSession(): Promise { return invoke('clear_session'); } -/** Everything the Rust session holds, for a window that reloads; running jobs are cancelled. */ +/** + * Everything the Rust session holds, for a window that reloads; running jobs are cancelled. They + * end on their own time: `runningJobs` names them until they have. + */ export function restoreSession(): Promise { return invoke('restore_session'); } @@ -154,4 +173,5 @@ export function recoveryStatus(): Promise { return invoke('recovery_status'); } +export * from './errors'; export * from './grid'; diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 161079d..51c076f 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -1,5 +1,6 @@ import { confirm, open } from '@tauri-apps/plugin-dialog'; import { getCurrentWebview } from '@tauri-apps/api/webview'; +import { revealItemInDir } from '@tauri-apps/plugin-opener'; /** Asks for folders; resolves to the chosen paths (none when cancelled). */ export async function chooseFolders(): Promise { @@ -22,6 +23,17 @@ export async function chooseFiles(): Promise { return picked ?? []; } +/** Asks for the one folder copies go to; resolves to null when cancelled. */ +export async function chooseExportFolder(): Promise { + const picked = await open({ directory: true, multiple: false, title: 'Export to folder' }); + return typeof picked === 'string' ? picked : null; +} + +/** Shows the file or folder in the Finder, selected. */ +export function revealInFinder(path: string): Promise { + return revealItemInDir(path); +} + /** A yes/no question in a native sheet. */ export function ask(message: string, title: string, okLabel: string): Promise { return confirm(message, { title, okLabel, cancelLabel: 'Cancel' }); diff --git a/src/state/exportRows.test.ts b/src/state/exportRows.test.ts new file mode 100644 index 0000000..4aa50b8 --- /dev/null +++ b/src/state/exportRows.test.ts @@ -0,0 +1,556 @@ +import type { ExportRow, IpcError, 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'; + +const initial = useLibrary.getState(); +const lib = () => useLibrary.getState(); +const row = (id: number) => { + const r = lib().rows[id]; + if (!r) throw new Error(`no row ${id}`); + return r; +}; + +const update = (fileId: number): RowUpdate => ({ + fileId, + row: analysis({ shortTermP95: -11 }), + plan: plan({ measured: -11, gain: { type: 'atTarget' } }), + revision: 0, + exportStale: false, +}); + +/** Row 1 (and any others) analysed, and an export of them running. */ +function exporting(...ids: number[]) { + lib().add(ids.map((id) => entry(id))); + for (const id of ids) lib().applyEvent(analysed(id)); + lib().beginExport(ids, []); +} + +beforeEach(() => useLibrary.setState(initial, true)); + +describe('rows during an export', () => { + it('walks Processing, Writing, Checking and Done, verified', () => { + exporting(1); + expect(row(1).state).toBe('exportQueued'); + lib().applyEvent({ type: 'started', jobId: 3, fileId: 1 }); + expect(row(1).state).toBe('processing'); + expect(lib().job).toMatchObject({ kind: 'export', jobId: 3 }); + lib().applyEvent({ type: 'progress', jobId: 3, fileId: 1, fraction: 0.5 }); + expect(row(1)).toMatchObject({ state: 'processing', progress: 0.5 }); + lib().applyEvent({ type: 'processing', jobId: 3, fileId: 1, row: exportRow() }); + expect(row(1).state).toBe('writing'); + const written = exportRow({ stage: 'writing', verification: verification(), revertible: true }); + lib().applyEvent({ type: 'written', jobId: 3, fileId: 1, row: written }); + expect(row(1)).toMatchObject({ state: 'checking', revertible: true }); + lib().applyEvent({ + type: 'exportDone', + jobId: 3, + fileId: 1, + row: doneRow(), + inPlace: true, + update: update(1), + }); + expect(row(1).state).toBe('done'); + expect(row(1).export?.gridCheck?.result).toBe('pass'); + }); + + it('shows what the confirm step planned while a row waits', () => { + lib().add([entry(1)]); + lib().applyEvent(analysed(1)); + const planned = exportRow({ action: { type: 'write', gainDb: -1, cut: { type: 'library' } } }); + lib().beginExport([1], [previewRow(1, planned)]); + expect(row(1).export).toEqual(planned); + }); + + it('locks exactly the files of the batch until it finishes', () => { + exporting(1, 2); + lib().add([entry(3)]); + expect(isLocked(lib().locked, 1)).toBe(true); + expect(isLocked(lib().locked, 2)).toBe(true); + expect(isLocked(lib().locked, 3)).toBe(false); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + expect(isLocked(lib().locked, 1)).toBe(false); + }); + + it('a skipped file says so, with the words in its row', () => { + exporting(1); + const skipped = exportRow({ + stage: 'skipped', + action: { type: 'skip', reason: { type: 'silent' } }, + positionsShift: false, + notes: ['it has no audio above the -70 LUFS gate'], + }); + lib().applyEvent({ type: 'exportSkipped', jobId: 3, fileId: 1, row: skipped }); + expect(row(1).state).toBe('skipped'); + expect(row(1).export?.notes).toEqual(['it has no audio above the -70 LUFS gate']); + expect(row(1).analysis).toBeDefined(); + }); + + it('a failure keeps the row\'s numbers and the export standing before it', () => { + const standing = doneRow(); + lib().add([entry(1)]); + lib().applyEvent(analysed(1)); + lib().beginExport([1], []); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: standing, inPlace: false, update: null }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + + lib().beginExport([1], [previewRow(1, exportRow())]); + const error: IpcError = { kind: 'verifyFailed', message: 'no match', fileId: 1 }; + lib().applyEvent({ type: 'processing', jobId: 4, fileId: 1, row: exportRow() }); + lib().applyEvent({ type: 'failed', jobId: 4, fileId: 1, error }); + expect(row(1)).toMatchObject({ state: 'failed', exportError: error, export: standing }); + expect(row(1).analysis).toBeDefined(); + // A failed export's row is not an analysis to redo. + expect(staleIds(lib(), [70, 180])).toEqual([]); + }); + + it('cancel puts a row back as it was, and rows never reached too', () => { + exporting(1, 2); + lib().applyEvent({ type: 'cancelled', jobId: 3, fileId: 1 }); + expect(row(1).state).toBe('analysed'); + expect(row(1).export).toBeUndefined(); + expect(row(2).state).toBe('exportQueued'); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: true }); + expect(row(2).state).toBe('analysed'); + expect(lib().lastExport).toMatchObject({ written: 0, cancelled: true }); + expect(staleIds(lib(), [70, 180])).toEqual([]); + }); + + it('counts what became of the batch and the Done chip', () => { + exporting(1, 2, 3); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: true, update: update(1) }); + lib().applyEvent({ + type: 'exportSkipped', + jobId: 3, + fileId: 2, + row: exportRow({ stage: 'skipped', action: { type: 'xmlOnly', reason: { type: 'gridOnlyFlac' } } }), + }); + lib().applyEvent({ type: 'failed', jobId: 3, fileId: 3, error: { kind: 'io', message: 'x', fileId: 3 } }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + expect(lib().lastExport).toMatchObject({ written: 1, xmlOnly: 1, skipped: 0, failed: 1, cancelled: false }); + expect(lib().lastRun).toBe('export'); + expect(counts(lib()).done).toBe(1); + lib().setFilter('done'); + expect(visibleIds(lib())).toEqual([1]); + }); + + it('counts a row in one tab only, so the tabs add up', () => { + exporting(1, 2, 3, 4); + const review = plan({ status: 'needsReview', review: [{ type: 'drifts' }] }); + const ask = { ...update(1), plan: review }; + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: true, update: ask }); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 2, row: doneRow(), inPlace: true, update: update(2) }); + lib().applyEvent({ + type: 'exportSkipped', + jobId: 3, + fileId: 3, + row: exportRow({ stage: 'skipped', action: { type: 'skip', reason: { type: 'silent' } } }), + }); + lib().applyEvent({ type: 'cancelled', jobId: 3, fileId: 4 }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: true }); + const c = counts(lib()); + // The row exported fine that still asks for a look is in Needs review, not also in Done. + expect(c).toMatchObject({ all: 4, review: 1, done: 1, skipped: 1 }); + expect(row(1).state).toBe('needsReview'); + lib().setFilter('done'); + expect(visibleIds(lib())).toEqual([2]); + lib().setFilter('review'); + expect(visibleIds(lib())).toEqual([1]); + }); + + it('keeps Done through a replan, and shows Needs review when the grid still asks for it', () => { + exporting(1); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: true, update: update(1) }); + lib().applyReplan({ revision: 5, plans: [{ fileId: 1, plan: plan() }] }); + expect(row(1).state).toBe('done'); + lib().applyReplan({ + revision: 6, + plans: [{ fileId: 1, plan: plan({ status: 'needsReview', review: [{ type: 'drifts' }] }) }], + }); + expect(row(1).state).toBe('needsReview'); + }); + + it('a replan leaves a row in flight in its stage', () => { + exporting(1); + lib().applyEvent({ type: 'processing', jobId: 3, fileId: 1, row: exportRow() }); + lib().applyReplan({ revision: 5, plans: [{ fileId: 1, plan: plan() }] }); + expect(row(1).state).toBe('writing'); + }); + + it('a job refused before it started leaves its rows as they were and says why', () => { + exporting(1); + const error: IpcError = { kind: 'busy', message: 'being analysed', fileId: 1 }; + lib().applyEvent({ type: 'aborted', jobId: 0, error }); + lib().applyEvent({ type: 'finished', jobId: 0, cancelled: false }); + expect(lib().aborted).toEqual(error); + expect(lib().abortedKind).toBe('export'); + expect(row(1).state).toBe('analysed'); + expect(lib().locked).toBeNull(); + }); +}); + +describe('a row outside the running batch', () => { + it('keeps its saved grid and its own analysis', () => { + exporting(1); + lib().add([entry(2)]); + lib().applyEvent(analysed(2)); + expect(row(2).state).toBe('analysed'); + lib().applyRowUpdate({ ...update(2), row: analysis({ edited: true }) }); + expect(row(2).analysis?.edited).toBe(true); + }); +}); + +describe('the footer counts of an export', () => { + it('count this job, not an earlier export a failed or cancelled row falls back on', () => { + exporting(1, 2, 3); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: false, update: null }); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 2, row: doneRow(), inPlace: false, update: null }); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 3, row: doneRow(), inPlace: false, update: null }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + // Exported again: one fails, one is cancelled, one is written. + lib().beginExport([1, 2, 3], []); + lib().applyEvent({ type: 'failed', jobId: 4, fileId: 1, error: { kind: 'io', message: 'x', fileId: 1 } }); + lib().applyEvent({ type: 'cancelled', jobId: 4, fileId: 2 }); + lib().applyEvent({ type: 'exportDone', jobId: 4, fileId: 3, row: doneRow(), inPlace: false, update: null }); + lib().applyEvent({ type: 'finished', jobId: 4, cancelled: true }); + expect(row(1).export?.stage).toBe('done'); + expect(row(2).export?.stage).toBe('done'); + expect(lib().lastExport).toMatchObject({ written: 1, failed: 1, skipped: 0, xmlOnly: 0, cancelled: true }); + }); +}); + +describe('a file that changed', () => { + const changed: IpcError = { kind: 'fileChanged', message: '/a changed since you confirmed', fileId: 1 }; + + it('after confirm: the row fails and Analyse lights up, then a new analysis clears it', () => { + exporting(1); + expect(staleIds(lib(), [70, 180])).toEqual([]); + lib().applyEvent({ type: 'failed', jobId: 3, fileId: 1, error: changed }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + expect(row(1)).toMatchObject({ state: 'failed', changedOnDisk: true }); + expect(staleIds(lib(), [70, 180])).toEqual([1]); + + lib().queue([1], [70, 180]); + expect(row(1)).toMatchObject({ state: 'queued', exportError: undefined }); + lib().applyEvent(analysed(1)); + lib().applyEvent({ type: 'finished', jobId: 5, cancelled: false }); + expect(row(1)).toMatchObject({ state: 'analysed', changedOnDisk: false }); + expect(staleIds(lib(), [70, 180])).toEqual([]); + }); + + it('another failure does not ask for an analysis', () => { + exporting(1); + lib().applyEvent({ type: 'failed', jobId: 3, fileId: 1, error: { kind: 'verifyFailed', message: 'x', fileId: 1 } }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + expect(row(1).changedOnDisk).toBeFalsy(); + expect(staleIds(lib(), [70, 180])).toEqual([]); + }); + + it('with an export standing, its new analysis makes that export 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]); + expect(staleIds(lib(), [70, 180])).toEqual([1]); + lib().queue([1], [70, 180]); + lib().applyEvent(analysed(1)); + expect(row(1).exportStale).toBe(true); + expect(row(1).changedOnDisk).toBe(false); + }); +}); + +describe('exportDone by case', () => { + it('in place with an update: the row is the written file', () => { + exporting(1); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: true, update: update(1) }); + expect(row(1).analysis?.shortTermP95).toBe(-11); + expect(row(1).plan?.gain).toEqual({ type: 'atTarget' }); + }); + + it('in place without an update: the numbers are gone and the row waits for an analysis', () => { + exporting(1); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: true, update: null }); + expect(row(1)).toMatchObject({ state: 'queued', analysis: undefined, plan: undefined }); + expect(row(1).export?.stage).toBe('done'); + expect(row(1).revertible).toBe(true); + // Its next analysis, after the export, lands on a row that keeps its export: Done again. + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + expect(row(1).state).toBe('queued'); + lib().queue([1], [70, 180]); + lib().applyEvent(analysed(1)); + expect(row(1).state).toBe('done'); + expect(row(1).export?.stage).toBe('done'); + }); + + it('a copy in a folder without an update keeps the row', () => { + exporting(1); + const before = row(1).analysis; + lib().applyEvent({ + type: 'exportDone', + jobId: 3, + fileId: 1, + row: doneRow({ revertible: false }), + inPlace: false, + update: null, + }); + expect(row(1).analysis).toBe(before); + expect(row(1)).toMatchObject({ state: 'done', revertible: false }); + }); + + it('in place, the row now tells the written depth', () => { + exporting(1); + lib().applyEvent({ + type: 'exportDone', + jobId: 3, + fileId: 1, + row: doneRow({ verification: verification({ bitsOut: 24 }) }), + inPlace: true, + update: update(1), + }); + expect(row(1).entry.info.bitsPerSample).toBe(24); + }); +}); + +describe('artefacts', () => { + it('keeps the links, and a failure instead of them', () => { + exporting(1); + const a = artefacts(); + lib().applyEvent({ type: 'artefacts', jobId: 3, artefacts: a }); + expect(lib().artefacts).toEqual(a); + const error: IpcError = { kind: 'io', message: 'disk full', fileId: null }; + lib().applyEvent({ type: 'artefactsFailed', jobId: 3, error }); + expect(lib()).toMatchObject({ artefacts: null, artefactsFailed: error }); + }); + + it('a new export drops the last one\'s links', () => { + exporting(1); + lib().applyEvent({ type: 'artefacts', jobId: 3, artefacts: artefacts() }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + lib().beginExport([1], []); + expect(lib().artefacts).toBeNull(); + }); + + it('notes why the XML button left rows out', () => { + lib().add([entry(1), entry(2)]); + lib().applyXml({ + artefacts: artefacts({ listed: 1 }), + notListed: [{ fileId: 2, why: 'no grid' }], + }); + expect(lib().xmlLeftOut).toBe(1); + expect(row(2).xmlNotListed).toBe('no grid'); + expect(row(1).xmlNotListed).toBeUndefined(); + }); +}); + +describe('the rekordbox XML button', () => { + it('takes every analysed row, exported or not', () => { + lib().add([entry(1), entry(2), entry(3)]); + lib().applyEvent(analysed(1)); + lib().applyEvent(analysed(2)); + lib().beginExport([1], []); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: true, update: update(1) }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + expect(xmlRowIds(lib())).toEqual([1, 2]); + }); +}); + +describe('a saved grid', () => { + it('marks the row as the engine says, and only then', () => { + exporting(1); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: true, update: update(1) }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + lib().applyRowUpdate(update(1)); + expect(row(1).exportStale).toBe(false); + lib().applyRowUpdate({ ...update(1), exportStale: true }); + expect(row(1).exportStale).toBe(true); + expect(row(1).state).toBe('done'); + }); + + it('a reload keeps it', () => { + lib().restore( + { + revision: 1, + rows: [{ entry: entry(1), row: analysis(), plan: plan(), export: doneRow(), revertible: true, exportStale: true, analysedRange: null }], + runningJobs: [], + artefacts: null, + artefactsFailed: null, + lastExport: null, + }); + expect(row(1).exportStale).toBe(true); + }); +}); + +describe('revert', () => { + const result = (overrides: Partial = {}): RevertResult => ({ + entry: entry(1, 'Reverted'), + update: update(1), + backup: '/backups/a', + earlierChanges: 0, + notes: [], + ...overrides, + }); + + function done() { + exporting(1); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: doneRow(), inPlace: true, update: update(1) }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + } + + it('brings the original back: no export, nothing left to revert', () => { + done(); + lib().setReverting(1); + expect(row(1).reverting).toBe(true); + lib().applyRevert(1, result()); + expect(row(1)).toMatchObject({ reverting: false, revertible: false, state: 'analysed' }); + expect(row(1).export).toBeUndefined(); + expect(row(1).entry.info.title).toBe('Reverted'); + }); + + it('an earlier change still standing stays revertible', () => { + done(); + lib().applyRevert(1, result({ earlierChanges: 2 })); + expect(row(1).revertible).toBe(true); + }); + + it('with no update: the row is cleared and waits for an analysis, with why', () => { + done(); + lib().applyRevert(1, result({ update: null, notes: ['could not be analysed'] })); + expect(row(1)).toMatchObject({ + state: 'queued', + analysis: undefined, + plan: undefined, + revertNotes: ['could not be analysed'], + }); + expect(staleIds(lib(), [70, 180])).toEqual([1]); + }); + + it('a refusal stops the spinner and leaves the export standing', () => { + done(); + lib().setReverting(1); + const error: IpcError = { kind: 'fileChanged', message: 'edited since', fileId: 1 }; + lib().revertFailed(1, error); + expect(row(1)).toMatchObject({ reverting: false, revertError: error, state: 'done' }); + expect(row(1).export?.stage).toBe('done'); + }); + + it('a row reverting is not picked up by Analyse', () => { + lib().add([entry(1)]); + lib().setReverting(1); + expect(staleIds(lib(), [70, 180])).toEqual([]); + }); +}); + +describe('a reload during an export', () => { + const sessionRow = (id: number, exp: ExportRow | null, revertible = false): SessionRow => ({ + entry: entry(id), + row: analysis(), + plan: plan(), + export: exp, + revertible, + exportStale: false, + analysedRange: [70, 180], + }); + const session = (overrides: Partial = {}): SessionSnapshot => ({ + revision: 4, + rows: [ + sessionRow(1, exportRow({ stage: 'writing', verification: verification() }), true), + sessionRow(2, exportRow()), + sessionRow(3, doneRow(), true), + sessionRow(4, null), + ], + runningJobs: [9], + artefacts: null, + artefactsFailed: null, + lastExport: null, + ...overrides, + }); + + it('shows the rows in flight in their stage, and every row locked', () => { + lib().restore(session()); + expect(row(1).state).toBe('checking'); + expect(row(2).state).toBe('writing'); + expect(row(3).state).toBe('done'); + expect(row(4).state).toBe('analysed'); + expect(isLocked(lib().locked, 4)).toBe(true); + }); + + it('keeps Revert after a reload', () => { + lib().restore(session({ runningJobs: [] })); + expect(row(3).revertible).toBe(true); + expect(row(3).export?.verification).toBeDefined(); + expect(isLocked(lib().locked, 3)).toBe(false); + }); + + it('a read with no job left ends the rows in flight and brings the artefact links back', () => { + lib().restore(session()); + lib().select(3); + const a = artefacts(); + lib().restore( + session({ + runningJobs: [], + artefacts: a, + lastExport: { jobId: 9, written: 2, xmlOnly: 0, skipped: 0, failed: 0, cancelled: true, seconds: 3 }, + rows: [sessionRow(1, doneRow(), true), sessionRow(2, null), sessionRow(3, doneRow(), true), sessionRow(4, null)], + })); + lib().windDownEnded(); + expect(row(1).state).toBe('done'); + expect(row(2).state).toBe('analysed'); + expect(lib().selected).toBe(3); + expect(lib().artefacts).toEqual(a); + expect(lib().locked).toBeNull(); + expect(lib().lastExport).toMatchObject({ written: 2, stoppedByReload: true, seconds: 3 }); + }); + + it('a row left in flight with no job running shows no stage it is not in', () => { + lib().restore(session({ runningJobs: [] })); + expect(row(1).state).toBe('analysed'); + expect(row(1).export).toBeUndefined(); + }); + + it('keeps the BPM range each row was analysed with', () => { + lib().restore( + session({ + runningJobs: [], + rows: [{ ...sessionRow(1, null), analysedRange: [60, 120] }, { ...sessionRow(2, null), analysedRange: null }], + }), + ); + expect(row(1).analysedRange).toEqual([60, 120]); + expect(row(2).analysedRange).toBeUndefined(); + // So it counts as analysed under another range, where the settings say 70-180. + expect(staleIds(lib(), [70, 180])).toEqual([1]); + }); + + it('reads the last batch from the session, not from every row with an export', () => { + // Rows 1 and 3 were exported by earlier batches; the last batch wrote one file. + const summary = { jobId: 12, written: 1, xmlOnly: 0, skipped: 2, failed: 0, cancelled: false, seconds: 7 }; + lib().restore(session({ runningJobs: [], lastExport: summary })); + expect(lib().lastExport).toEqual(summary); + lib().restore(session({ runningJobs: [], lastExport: null })); + expect(lib().lastExport).toBeNull(); + expect(lib().lastRun).toBeNull(); + }); + + it('a done row the session holds no analysis for waits for a new one, keeping its export', () => { + lib().restore( + session({ + runningJobs: [], + rows: [{ ...sessionRow(1, doneRow(), true), row: null, plan: null }], + })); + expect(row(1)).toMatchObject({ state: 'queued', revertible: true }); + expect(row(1).export?.stage).toBe('done'); + expect(staleIds(lib(), [70, 180])).toEqual([1]); + }); + + it('brings back the artefacts failure too', () => { + const error: IpcError = { kind: 'io', message: 'disk full', fileId: null }; + lib().restore(session({ runningJobs: [], artefactsFailed: error })); + expect(lib().artefactsFailed).toEqual(error); + }); + + it('a track added while the job winds down stays queued through the next read', () => { + lib().restore(session()); + lib().add([entry(5)]); + const rows = [...session().rows, { ...sessionRow(5, null), row: null, plan: null }]; + lib().restore(session({ rows })); + expect(row(5).state).toBe('queued'); + }); +}); diff --git a/src/state/exportRows.ts b/src/state/exportRows.ts new file mode 100644 index 0000000..a88f194 --- /dev/null +++ b/src/state/exportRows.ts @@ -0,0 +1,251 @@ +import type { + ExportPreviewRow, + ExportRow, + JobEvent, + Plan, + RevertResult, + SessionRow, +} from '@/lib/ipc'; +import type { ExportTally, JobKind, JobProgress, Row, RowState } from './libraryTypes'; + +/** + * What the export events do to a row. A row's `export` is what its Action column shows: the + * confirm step's plan while it waits, then each step of the job. A failed or cancelled export + * leaves the one before it standing, as the engine's session does. + */ + +const EXPORT_FLOW: ReadonlySet = new Set([ + 'exportQueued', + 'processing', + 'writing', + 'checking', +]); + +/** A row an export is working on or about to. */ +export function isExporting(state: RowState): boolean { + return EXPORT_FLOW.has(state); +} + +function stateOfPlan(plan: Plan | undefined, fallback: RowState): RowState { + switch (plan?.status) { + case 'needsReview': + return 'needsReview'; + case 'skipped': + return 'skipped'; + case undefined: + return fallback; + default: + return 'analysed'; + } +} + +/** + * Where a row with no job on it stands: its export, unless its grid still asks for a look, which + * an export does not settle. + */ +export function restingState(plan: Plan | undefined, standing: ExportRow | undefined): RowState { + if (standing?.stage === 'skipped') return 'skipped'; + if (standing?.stage === 'done' && plan?.status !== 'needsReview') return 'done'; + return stateOfPlan(plan, 'cancelled'); +} + +/** The rows of a new export: waiting, showing what the confirm step said will happen. */ +export function exportQueued(row: Row, planned: ExportPreviewRow | undefined): Partial { + return { + state: 'exportQueued', + progress: 0, + exportBefore: row.export, + export: planned?.row ?? row.export, + exportError: undefined, + revertError: undefined, + revertNotes: undefined, + xmlNotListed: undefined, + }; +} + +/** A row whose export ended without changing the file. */ +export function exportEnded(row: Row): Partial { + return { + state: restingState(row.plan, row.exportBefore), + progress: 0, + export: row.exportBefore, + exportBefore: undefined, + }; +} + +/** What `event` does to a row of a running export; `null` for events that are not about it. */ +export function exportPatch(row: Row, event: JobEvent): Partial | null { + switch (event.type) { + case 'started': + return { state: 'processing', progress: 0 }; + case 'progress': + return { state: 'processing', progress: event.fraction }; + // The plan is known: the write starts now. + case 'processing': + return { state: 'writing', export: event.row, revertible: event.row.revertible }; + // The file is in place and verified; only the grid check runs from here on. + case 'written': + return { state: 'checking', export: event.row, revertible: event.row.revertible }; + case 'exportSkipped': + return { + state: 'skipped', + progress: 0, + export: event.row, + exportBefore: undefined, + revertible: event.row.revertible, + }; + case 'exportDone': + return { ...doneOnDisk(row, event), export: event.row, exportBefore: undefined, exportStale: false }; + case 'failed': + return { + ...exportEnded(row), + state: 'failed', + exportError: event.error, + ...(event.error.kind === 'fileChanged' ? { changedOnDisk: true } : {}), + }; + case 'cancelled': + return exportEnded(row); + default: + return null; + } +} + +/** + * A finished file, by what became of the file. A copy in a folder: the source did not change and + * the row stays. In place with an update: the row now is the written file. In place without one: + * its analysis did not finish and what the row shows describes audio that is gone, so it is cleared + * and analysed again. + */ +function doneOnDisk(row: Row, event: Extract): Partial { + const common = { progress: 1, revertible: event.row.revertible }; + if (!event.inPlace) return { ...common, state: restingState(row.plan, event.row) }; + const written = event.row.verification; + const entry = written + ? { + ...row.entry, + info: { ...row.entry.info, bitsPerSample: written.bitsOut, float: false, djUnsafe: null }, + } + : row.entry; + if (!event.update) { + return { + ...common, + entry, + state: 'queued', + progress: 0, + analysis: undefined, + plan: undefined, + planRevision: undefined, + analysedRange: undefined, + }; + } + return { + ...common, + entry, + state: restingState(event.update.plan, event.row), + analysis: event.update.row, + plan: event.update.plan, + planRevision: event.update.revision, + }; +} + +/** A row as the session holds it, after the window reloaded; `live`: a job is still running. */ +export function rowFromSession(r: SessionRow, revision: number, live: boolean): Row { + const analysed = r.row && r.plan ? r : null; + const standing = r.export ?? undefined; + const row: Row = { + entry: r.entry, + state: 'cancelled', + progress: analysed ? 1 : 0, + revertible: r.revertible, + exportStale: r.exportStale, + ...(standing ? { export: standing } : {}), + ...(analysed + ? { + analysis: r.row ?? undefined, + plan: r.plan ?? undefined, + planRevision: revision, + ...(r.analysedRange ? { analysedRange: r.analysedRange } : {}), + } + : {}), + }; + const inFlight = standing?.stage === 'processing' || standing?.stage === 'writing'; + if (inFlight && live) return { ...row, state: standing.stage === 'writing' ? 'checking' : 'writing' }; + if (inFlight) return { ...row, state: restingState(row.plan, undefined), export: undefined }; + if (analysed) return { ...row, state: restingState(row.plan, standing) }; + // Written in place and its analysis gone with what it described: it waits for a new one. + return standing?.stage === 'done' ? { ...row, state: 'queued' } : row; +} + +/** Rows written in place whose own analysis did not finish: they wait for a new one. */ +export function writtenWithoutAnalysis(rows: Record, order: number[]): number[] { + return order.filter((id) => { + const row = rows[id]; + return row?.state === 'queued' && row.export?.stage === 'done' && !row.analysis; + }); +} + +/** A row whose original is back. Without an update it could not be analysed: what it shows is gone. */ +export function revertedPatch(row: Row, { entry, update, earlierChanges, notes }: RevertResult): Partial { + const undone: Partial = { + entry, + reverting: false, + revertible: earlierChanges > 0, + revertNotes: notes, + export: undefined, + exportBefore: undefined, + exportError: undefined, + exportStale: false, + }; + if (!update) { + return { + ...undone, + state: 'queued', + progress: 0, + analysis: undefined, + plan: undefined, + planRevision: undefined, + analysedRange: undefined, + }; + } + // A newer replan already decided this row's plan. + const newer = row.plan !== undefined && (row.planRevision ?? -1) > update.revision; + const plan = newer && row.plan ? row.plan : update.plan; + return { + ...undone, + state: restingState(plan, undefined), + progress: 1, + analysis: update.row, + plan, + planRevision: newer ? row.planRevision : update.revision, + }; +} + +export function newJob(kind: JobKind, fileIds: number[]): JobProgress { + return { + kind, + jobId: null, + fileIds, + done: 0, + total: fileIds.length, + etaMs: null, + realtimeX: null, + startedAt: Date.now(), + tally: { written: 0, xmlOnly: 0, skipped: 0, failed: 0 }, + }; +} + +/** Counts what `event` did to the batch: its own events, not what its rows now show. */ +export function tallied(tally: ExportTally, event: JobEvent): ExportTally { + switch (event.type) { + case 'exportDone': + return { ...tally, written: tally.written + 1 }; + case 'exportSkipped': + return event.row.action.type === 'xmlOnly' + ? { ...tally, xmlOnly: tally.xmlOnly + 1 } + : { ...tally, skipped: tally.skipped + 1 }; + case 'failed': + return { ...tally, failed: tally.failed + 1 }; + default: + return tally; + } +} diff --git a/src/state/library.test.ts b/src/state/library.test.ts index 897a8d5..0a3c783 100644 --- a/src/state/library.test.ts +++ b/src/state/library.test.ts @@ -163,12 +163,16 @@ describe('library store', () => { plan: plan({ status: 'needsReview', review: [{ type: 'drifts' }] }), export: null, revertible: false, + exportStale: false, + analysedRange: [70, 180], }, - { entry: entry(8), row: null, plan: null, export: null, revertible: false }, + { entry: entry(8), row: null, plan: null, export: null, revertible: false, exportStale: false, analysedRange: null }, ], - }, - [70, 180], - ); + runningJobs: [], + artefacts: null, + artefactsFailed: null, + lastExport: null, + }); const s = useLibrary.getState(); expect(s.order).toEqual([7, 8]); expect(s.rows[7]).toMatchObject({ state: 'needsReview', planRevision: 4 }); diff --git a/src/state/library.ts b/src/state/library.ts index 6c99827..f22efe0 100644 --- a/src/state/library.ts +++ b/src/state/library.ts @@ -1,165 +1,22 @@ import { create } from 'zustand'; -import type { - FileEntry, - IpcError, - JobEvent, - Plan, - Replan, - RowAnalysis, - RowUpdate, - SessionSnapshot, -} from '@/lib/ipc'; +import { + exportEnded, + exportPatch, + exportQueued, + isExporting, + newJob, + restingState, + revertedPatch, + rowFromSession, + tallied, + writtenWithoutAnalysis, +} from './exportRows'; +import { visibleIds } from './librarySelectors'; +import type { LibraryState, Row } from './libraryTypes'; -/** Where a row stands, as the Status column shows it. */ -export type RowState = - | 'queued' - | 'analysing' - | 'analysed' - | 'needsReview' - | 'skipped' - | 'error' - | 'cancelled'; - -export interface Row { - entry: FileEntry; - state: RowState; - /** Analysis progress while analysing, 0..1. */ - progress: number; - analysis?: RowAnalysis; - plan?: Plan; - /** The settings revision `plan` was decided with; a newer plan always wins. */ - planRevision?: number; - error?: IpcError; - /** The BPM range the row was last analysed with, to know when it is stale. */ - analysedRange?: [number, number]; -} - -export type Filter = 'all' | 'review' | 'short' | 'skipped' | 'done'; - -export interface JobProgress { - jobId: number | null; - /** The rows this job was started with. */ - fileIds: number[]; - done: number; - total: number; - etaMs: number | null; - realtimeX: number | null; - startedAt: number; -} - -export interface LastBatch { - analysed: number; - review: number; - failed: number; - cancelled: boolean; - seconds: number; -} - -interface LibraryState { - rows: Record; - order: number[]; - filter: Filter; - query: string; - selected: number | null; - job: JobProgress | null; - lastBatch: LastBatch | null; - /** Why the last job could not start, until the next one does. */ - aborted: IpcError | null; - /** A problem to show above the table (for example, settings the engine refused). */ - notice: string | null; - add(entries: FileEntry[]): void; - /** Marks rows queued for a job about to start with `range`. */ - queue(fileIds: number[], range: [number, number]): void; - applyEvent(event: JobEvent): void; - /** A row whose grid edit was saved: its analysis and plan, as an analysed event would. */ - applyRowUpdate(update: RowUpdate): void; - applyReplan(replan: Replan): void; - /** Rebuilds the table from the engine's session after the window reloaded. */ - restore(snapshot: SessionSnapshot, range: [number, number]): void; - setNotice(notice: string | null): void; - /** Empties the list (the engine forgets it too, through `clearList`). */ - clear(): void; - setFilter(filter: Filter): void; - setQuery(query: string): void; - select(fileId: number | null): void; - moveSelection(delta: number): void; - /** Selects the next visible Needs review row after the selection, wrapping; returns it. */ - selectNextReview(): number | null; -} - -function stateOf(plan: Plan): RowState { - switch (plan.status) { - case 'needsReview': - return 'needsReview'; - case 'skipped': - return 'skipped'; - default: - return 'analysed'; - } -} - -/** Whether a row is short of the target (the Short chip). */ -export function isShort(row: Row): boolean { - const g = row.plan?.gain; - return row.analysis !== undefined && g?.type === 'gain' && g.shortByLu > 0; -} - -function matchesFilter(row: Row, filter: Filter): boolean { - switch (filter) { - case 'all': - return true; - case 'review': - return row.state === 'needsReview'; - case 'short': - return isShort(row); - case 'skipped': - return row.state === 'skipped'; - case 'done': - return false; - } -} - -function matchesQuery(row: Row, query: string): boolean { - if (!query) return true; - const q = query.toLocaleLowerCase(); - const { info, path } = row.entry; - return [info.title, info.artist, info.album, path.split('/').pop()].some((s) => - s?.toLocaleLowerCase().includes(q), - ); -} - -/** Row ids the table shows, in order. */ -export function visibleIds(s: Pick): number[] { - return s.order.filter((id) => { - const row = s.rows[id]; - return row !== undefined && matchesFilter(row, s.filter) && matchesQuery(row, s.query); - }); -} - -/** The counts the filter chips show. */ -export function counts(s: Pick): Record { - const c: Record = { all: 0, review: 0, short: 0, skipped: 0, done: 0 }; - for (const id of s.order) { - const row = s.rows[id]; - if (!row) continue; - c.all += 1; - if (row.state === 'needsReview') c.review += 1; - if (row.state === 'skipped') c.skipped += 1; - if (isShort(row)) c.short += 1; - } - return c; -} - -/** Rows the Analyse button would (re)run: failed, cancelled, queued, or analysed with another BPM range. */ -export function staleIds(s: Pick, range: [number, number]): number[] { - return s.order.filter((id) => { - const row = s.rows[id]; - if (!row) return false; - if (row.state === 'error' || row.state === 'cancelled' || row.state === 'queued') return true; - const r = row.analysedRange; - return r !== undefined && (r[0] !== range[0] || r[1] !== range[1]); - }); -} +export { writtenWithoutAnalysis }; +export * from './librarySelectors'; +export type * from './libraryTypes'; /** Applies `patch` to each listed row in one copy of the map. */ function patchRows( @@ -190,8 +47,18 @@ export const useLibrary = create()((set, get) => ({ selected: null, job: null, lastBatch: null, + lastExport: null, + lastRun: null, + artefacts: null, + artefactsFailed: null, + xmlLeftOut: 0, + xmlWriting: false, + locked: null, + windingJobs: [], aborted: null, + abortedKind: 'analysis', notice: null, + info: null, add(entries) { const { rows, order, selected } = get(); const next = { ...rows }; @@ -206,20 +73,92 @@ export const useLibrary = create()((set, get) => ({ queue(fileIds, range) { const rows = patchRows( get().rows, - fileIds.map((id) => [id, () => ({ state: 'queued', progress: 0, error: undefined, analysedRange: range })]), + fileIds.map((id) => [ + id, + () => ({ + state: 'queued', + progress: 0, + error: undefined, + exportError: undefined, + analysedRange: range, + }), + ]), ); set({ rows, aborted: null, - job: { - jobId: null, - fileIds, - done: 0, - total: fileIds.length, - etaMs: null, - realtimeX: null, - startedAt: Date.now(), - }, + abortedKind: 'analysis', + job: newJob('analysis', fileIds), + }); + }, + beginExport(fileIds, planned) { + const byId = new Map(planned.map((p) => [p.fileId, p])); + set({ + rows: patchRows( + get().rows, + fileIds.map((id) => [id, (row) => exportQueued(row, byId.get(id))]), + ), + aborted: null, + abortedKind: 'export', + artefacts: null, + artefactsFailed: null, + xmlLeftOut: 0, + lastExport: null, + locked: new Set(fileIds), + job: newJob('export', fileIds), + }); + }, + setReverting(fileId) { + set({ + rows: one(get().rows, fileId, () => ({ + reverting: true, + revertError: undefined, + revertNotes: undefined, + exportError: undefined, + })), + }); + }, + applyRevert(fileId, result) { + set({ + rows: one(get().rows, fileId, (row) => revertedPatch(row, result)), + }); + }, + revertFailed(fileId, error) { + set({ rows: one(get().rows, fileId, () => ({ reverting: false, revertError: error })) }); + }, + setXmlWriting(xmlWriting) { + set({ xmlWriting }); + }, + applyXml({ artefacts, notListed }) { + const why = new Map(notListed.map((n) => [n.fileId, n.why])); + set({ + xmlWriting: false, + artefacts, + artefactsFailed: null, + xmlLeftOut: notListed.length, + rows: patchRows( + get().rows, + get().order.map((id) => [ + id, + (row) => { + const left = why.get(id); + return left === row.xmlNotListed ? null : { xmlNotListed: left }; + }, + ]), + ), + }); + }, + setXmlFailed(error) { + set({ xmlWriting: false, artefacts: null, artefactsFailed: error, xmlLeftOut: 0 }); + }, + windDownEnded() { + const { lastExport, windingJobs } = get(); + // Only the job the reload stopped: an analysis it stopped says nothing about an older export. + const stopped = lastExport?.jobId !== undefined && windingJobs.includes(lastExport.jobId); + set({ + locked: null, + windingJobs: [], + lastExport: lastExport && stopped ? { ...lastExport, stoppedByReload: true } : lastExport, }); }, applyRowUpdate(update) { @@ -231,9 +170,20 @@ export const useLibrary = create()((set, get) => ({ plan: update.plan, revision: update.revision, }); + // 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(); + // A file event of a running export; `job` is null once the list was cleared. A row outside the + // batch (added since, its grid saved meanwhile) goes on as any other. + if (job?.kind === 'export' && 'fileId' in event && job.fileIds.includes(event.fileId)) { + set({ + rows: one(rows, event.fileId, (row) => exportPatch(row, event)), + job: { ...job, jobId: job.jobId ?? event.jobId, tally: tallied(job.tally, event) }, + }); + return; + } switch (event.type) { case 'started': set({ @@ -251,8 +201,11 @@ export const useLibrary = create()((set, get) => ({ const newer = row.plan !== undefined && (row.planRevision ?? -1) > event.revision; const plan = newer && row.plan ? row.plan : event.plan; return { - state: stateOf(plan), + 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 } : {}), + changedOnDisk: false, analysis: event.row, plan, planRevision: newer ? row.planRevision : event.revision, @@ -280,6 +233,12 @@ export const useLibrary = create()((set, get) => ({ }); } break; + case 'artefacts': + set({ artefacts: event.artefacts, artefactsFailed: null, xmlLeftOut: 0 }); + break; + case 'artefactsFailed': + set({ artefacts: null, artefactsFailed: event.error }); + break; case 'aborted': set({ aborted: event.error }); break; @@ -287,6 +246,25 @@ export const useLibrary = create()((set, get) => ({ // A job that ends after the list was cleared leaves nothing to report. if (!job) break; const ids = job.fileIds; + if (job.kind === 'export') { + // Rows it never reached (it was refused or cancelled early) are as they were. + const next = patchRows( + rows, + ids.map((id) => [id, (row) => (isExporting(row.state) ? exportEnded(row) : null)]), + ); + set({ + rows: next, + job: null, + locked: null, + lastRun: 'export', + lastExport: { + ...job.tally, + cancelled: event.cancelled, + seconds: (Date.now() - job.startedAt) / 1000, + }, + }); + break; + } // Rows of this job still waiting (it was aborted or cancelled early) no longer are. const next = patchRows( rows, @@ -299,6 +277,7 @@ export const useLibrary = create()((set, get) => ({ set({ rows: next, job: null, + lastRun: 'analysis', lastBatch: { analysed: mine.filter((r) => r.analysis !== undefined && (r.state === 'analysed' || r.state === 'needsReview' || r.state === 'skipped')).length, review: mine.filter((r) => r.state === 'needsReview').length, @@ -321,40 +300,67 @@ export const useLibrary = create()((set, get) => ({ (row) => { if ((row.planRevision ?? -1) > revision) return null; // Rows still in flight keep their status until their analysis arrives. - return row.analysis - ? { plan, planRevision: revision, state: stateOf(plan) } + return row.analysis && !isExporting(row.state) && row.state !== 'failed' + ? { plan, planRevision: revision, state: restingState(plan, row.export) } : { plan, planRevision: revision }; }, ]), ), }); }, - restore(snapshot, range) { + restore(snapshot) { + const { rows: before, selected } = get(); + const live = snapshot.runningJobs.length > 0; const rows: Record = {}; const order: number[] = []; for (const r of snapshot.rows) { const id = r.entry.fileId; order.push(id); - rows[id] = - r.row && r.plan - ? { - entry: r.entry, - state: stateOf(r.plan), - progress: 1, - analysis: r.row, - plan: r.plan, - planRevision: snapshot.revision, - analysedRange: range, - } - : { entry: r.entry, state: 'cancelled', progress: 0 }; + const row = rowFromSession(r, snapshot.revision, live); + // Added while the job winds down, and waiting for it: still waiting. + rows[id] = before[id]?.state === 'queued' && !row.analysis ? before[id] : row; } - set({ rows, order, selected: order[0] ?? null, job: null }); + set({ + rows, + order, + selected: selected !== null && rows[selected] ? selected : (order[0] ?? null), + job: null, + locked: live ? 'all' : null, + windingJobs: [...new Set([...get().windingJobs, ...snapshot.runningJobs])], + artefacts: snapshot.artefacts, + artefactsFailed: snapshot.artefactsFailed, + lastExport: snapshot.lastExport, + lastRun: snapshot.lastExport ? 'export' : null, + }); + }, + markChanged(fileIds) { + set({ rows: patchRows(get().rows, fileIds.map((id) => [id, () => ({ changedOnDisk: true })])) }); }, setNotice(notice) { set({ notice }); }, + setInfo(info) { + set({ info }); + }, clear() { - set({ rows: {}, order: [], selected: null, job: null, lastBatch: null, aborted: null, filter: 'all', query: '' }); + set({ + rows: {}, + order: [], + selected: null, + job: null, + lastBatch: null, + lastExport: null, + lastRun: null, + artefacts: null, + artefactsFailed: null, + xmlLeftOut: 0, + locked: null, + windingJobs: [], + aborted: null, + info: null, + filter: 'all', + query: '', + }); }, setFilter(filter) { set({ filter }); diff --git a/src/state/librarySelectors.ts b/src/state/librarySelectors.ts new file mode 100644 index 0000000..b5ced38 --- /dev/null +++ b/src/state/librarySelectors.ts @@ -0,0 +1,76 @@ +import type { Filter, LibraryState, Locked, Row } from './libraryTypes'; + +/** Whether a row must not be opened or reverted now, because a running batch holds its file. */ +export function isLocked(locked: Locked, id: number): boolean { + return locked === 'all' || (locked !== null && locked.has(id)); +} + +/** Whether a row is short of the target (the Short chip). */ +export function isShort(row: Row): boolean { + const g = row.plan?.gain; + return row.analysis !== undefined && g?.type === 'gain' && g.shortByLu > 0; +} + +function matchesFilter(row: Row, filter: Filter): boolean { + switch (filter) { + case 'all': + return true; + case 'review': + return row.state === 'needsReview'; + case 'short': + return isShort(row); + case 'skipped': + return row.state === 'skipped'; + case 'done': + return row.state === 'done'; + } +} + +function matchesQuery(row: Row, query: string): boolean { + if (!query) return true; + const q = query.toLocaleLowerCase(); + const { info, path } = row.entry; + return [info.title, info.artist, info.album, path.split('/').pop()].some((s) => + s?.toLocaleLowerCase().includes(q), + ); +} + +/** Row ids the table shows, in order. */ +export function visibleIds(s: Pick): number[] { + return s.order.filter((id) => { + const row = s.rows[id]; + return row !== undefined && matchesFilter(row, s.filter) && matchesQuery(row, s.query); + }); +} + +/** The counts the filter chips show. */ +export function counts(s: Pick): Record { + const c: Record = { all: 0, review: 0, short: 0, skipped: 0, done: 0 }; + for (const id of s.order) { + const row = s.rows[id]; + if (!row) continue; + c.all += 1; + if (row.state === 'needsReview') c.review += 1; + if (row.state === 'skipped') c.skipped += 1; + if (row.state === 'done') c.done += 1; + if (isShort(row)) c.short += 1; + } + return c; +} + +/** Rows the Analyse button would (re)run: failed, cancelled, queued, or analysed with another BPM range. */ +export function staleIds(s: Pick, range: [number, number]): number[] { + return s.order.filter((id) => { + const row = s.rows[id]; + if (!row || row.reverting) return false; + if (row.state === 'error' || row.state === 'cancelled' || row.state === 'queued') return true; + if (row.changedOnDisk) return true; + const r = row.analysedRange; + return r !== undefined && (r[0] !== range[0] || r[1] !== range[1]); + }); +} + +/** Rows the rekordbox XML button lists: every analysed row, an exported one where its export is. */ +export function xmlRowIds(s: Pick): number[] { + return s.order.filter((id) => s.rows[id]?.analysis !== undefined); +} diff --git a/src/state/libraryTypes.ts b/src/state/libraryTypes.ts new file mode 100644 index 0000000..9ed4e88 --- /dev/null +++ b/src/state/libraryTypes.ts @@ -0,0 +1,178 @@ +import type { + ExportArtefacts, + ExportPreviewRow, + ExportRow, + FileEntry, + IpcError, + JobEvent, + Plan, + Replan, + RevertResult, + RowAnalysis, + RowUpdate, + SessionSnapshot, + XmlWritten, +} from '@/lib/ipc'; + +/** Where a row stands, as the Status column shows it. */ +export type RowState = + | 'queued' + | 'analysing' + | 'analysed' + | 'needsReview' + | 'skipped' + | 'error' + | 'cancelled' + | 'exportQueued' + | 'processing' + | 'writing' + | 'checking' + | 'done' + | 'failed'; + +export interface Row { + entry: FileEntry; + state: RowState; + /** Analysis progress while analysing, 0..1. */ + progress: number; + analysis?: RowAnalysis; + plan?: Plan; + /** The settings revision `plan` was decided with; a newer plan always wins. */ + planRevision?: number; + error?: IpcError; + /** The BPM range the row was last analysed with, to know when it is stale. */ + analysedRange?: [number, number]; + /** What its export does or did: the plan while it waits, then each step of the job. */ + export?: ExportRow; + /** The export standing before the running one, which a failure or a cancel leaves in effect. */ + exportBefore?: ExportRow; + /** Why its export failed; the file was not changed. */ + 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. */ + exportStale?: boolean; + /** Revert is running on this row (it takes about 10 s for a 6-minute track). */ + reverting?: boolean; + revertError?: IpcError; + /** What could not be restored or analysed after the last revert. */ + revertNotes?: string[]; + /** Why the rekordbox XML left it out. */ + xmlNotListed?: string; + /** The file changed after it was analysed or confirmed: Analyse brings the row up to date. */ + changedOnDisk?: boolean; +} + +export type Filter = 'all' | 'review' | 'short' | 'skipped' | 'done'; + +export type JobKind = 'analysis' | 'export'; + +/** What became of the files of a running export, from its own events. */ +export interface ExportTally { + written: number; + xmlOnly: number; + skipped: number; + failed: number; +} + +export interface JobProgress { + kind: JobKind; + jobId: number | null; + /** The rows this job was started with. */ + fileIds: number[]; + done: number; + total: number; + etaMs: number | null; + realtimeX: number | null; + startedAt: number; + tally: ExportTally; +} + +export interface LastBatch { + analysed: number; + review: number; + failed: number; + cancelled: boolean; + seconds: number; +} + +export interface LastExport { + /** The job it was, when the engine said. */ + jobId?: number; + written: number; + xmlOnly: number; + skipped: number; + failed: number; + cancelled: boolean; + seconds: number; + /** The window reloaded during it, which stopped it. */ + stoppedByReload?: boolean; +} + +/** Which of the footer's summaries is the newest. */ +export type LastRun = JobKind | null; + +/** Files of a running batch: its holds cover all of them, all or none. `all`: not known which. */ +export type Locked = ReadonlySet | 'all' | null; + +export interface LibraryState { + rows: Record; + order: number[]; + filter: Filter; + query: string; + selected: number | null; + job: JobProgress | null; + lastBatch: LastBatch | null; + lastExport: LastExport | null; + lastRun: LastRun; + /** The latest batch's rekordbox XML and grid report. */ + artefacts: ExportArtefacts | null; + /** Why they could not be written; the files themselves are fine. */ + artefactsFailed: IpcError | null; + /** Tracks the XML button's XML left out. */ + xmlLeftOut: number; + /** The rekordbox XML button's command is running. */ + xmlWriting: boolean; + /** The files of the running export, or of a job a reloaded window can only wait for. */ + locked: Locked; + /** The jobs a reloaded window found running. */ + windingJobs: number[]; + /** Why the last job could not start, until the next one does. */ + aborted: IpcError | null; + abortedKind: JobKind; + /** A problem to show above the table (for example, settings the engine refused). */ + notice: string | null; + /** Something the user should know that is not a problem; dismissed by the user. */ + info: string | null; + add(entries: FileEntry[]): void; + /** Marks rows queued for a job about to start with `range`. */ + queue(fileIds: number[], range: [number, number]): void; + /** Marks rows waiting for an export about to start, with what its confirm step planned. */ + beginExport(fileIds: number[], planned: ExportPreviewRow[]): void; + setReverting(fileId: number): void; + applyRevert(fileId: number, result: RevertResult): void; + revertFailed(fileId: number, error: IpcError): void; + setXmlWriting(writing: boolean): void; + applyXml(written: XmlWritten): void; + setXmlFailed(error: IpcError): void; + /** The job a reloaded window found running has ended; what it did was read with `restore`. */ + windDownEnded(): void; + applyEvent(event: JobEvent): void; + /** A row whose grid edit was saved: its analysis and plan, as an analysed event would. */ + applyRowUpdate(update: RowUpdate): void; + applyReplan(replan: Replan): void; + /** Rebuilds the table from the engine's session after the window reloaded. */ + restore(snapshot: SessionSnapshot): void; + /** These files changed after they were analysed: Analyse brings them up to date. */ + markChanged(fileIds: number[]): void; + setNotice(notice: string | null): void; + setInfo(info: string | null): void; + /** Empties the list (the engine forgets it too, through `clearList`). */ + clear(): void; + setFilter(filter: Filter): void; + setQuery(query: string): void; + select(fileId: number | null): void; + moveSelection(delta: number): void; + /** Selects the next visible Needs review row after the selection, wrapping; returns it. */ + selectNextReview(): number | null; +} diff --git a/src/state/recovery.ts b/src/state/recovery.ts new file mode 100644 index 0000000..2c45d29 --- /dev/null +++ b/src/state/recovery.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; +import { recoveryStatus } from '@/lib/ipc'; + +/** How often the state of the start-up recovery is read while it runs. */ +const POLL_MS = 1000; + +interface RecoveryState { + /** The check for changes a crash interrupted is running: export, Revert and XML wait for it. */ + running: boolean; +} + +export const useRecovery = create()(() => ({ running: false })); + +/** Follows the start-up recovery until it has ended. Returns a stop function. */ +export function startRecoveryWatch(): () => void { + let stopped = false; + let timer: ReturnType | undefined; + const read = () => { + recoveryStatus() + .then((status) => { + if (stopped) return; + const running = status?.state === 'running'; + useRecovery.setState({ running }); + if (running) timer = setTimeout(read, POLL_MS); + }) + .catch(() => { + // Not known: the engine still refuses what it must, with its own words. + if (!stopped) useRecovery.setState({ running: false }); + }); + }; + read(); + return () => { + stopped = true; + clearTimeout(timer); + }; +} diff --git a/src/state/settings.export.test.ts b/src/state/settings.export.test.ts new file mode 100644 index 0000000..57b9a6e --- /dev/null +++ b/src/state/settings.export.test.ts @@ -0,0 +1,94 @@ +import { DEFAULT_SETTINGS, exportSettings, sanitize, savedPart, useSettings } from './settings'; + +const initial = useSettings.getState(); +beforeEach(() => useSettings.setState(initial, true)); + +describe('export settings', () => { + it('default to Prepare, to a folder, source depth, XML on, TBPM in Prepare only', () => { + const s = useSettings.getState(); + expect(exportSettings(s, 'prepare')).toEqual({ + batchMode: 'prepare', + place: 'folder', + depth: null, + gridOnly: false, + tbpm: true, + xml: true, + leadMs: 5, + }); + expect(exportSettings(s, 'library').tbpm).toBe(false); + }); + + it('grid only keeps the source depth, whatever is set', () => { + useSettings.getState().setDepth(24); + expect(exportSettings(useSettings.getState(), 'prepare').depth).toBe(24); + useSettings.getState().setGridOnly(true); + expect(exportSettings(useSettings.getState(), 'prepare')).toMatchObject({ depth: null, gridOnly: true }); + }); + + it('are in the saved part, so they persist', () => { + const s = useSettings.getState(); + s.setBatchMode('library'); + s.setPlace('inPlace'); + s.setOutDir('/Music/Out'); + s.setDepth(16); + s.setGridOnly(true); + s.setTbpm(true, 'library'); + s.setXml(false); + expect(savedPart(useSettings.getState())).toMatchObject({ + batchMode: 'library', + place: 'inPlace', + outDir: '/Music/Out', + depth: 16, + gridOnly: true, + tbpm: { prepare: true, library: true }, + xml: false, + }); + }); + + it('a saved copy round-trips, and a damaged one keeps the defaults', () => { + const s = useSettings.getState(); + s.setBatchMode('library'); + s.setDepth(24); + s.setOutDir('/x'); + const saved = JSON.parse(JSON.stringify(savedPart(useSettings.getState()))) as Record; + expect(sanitize(saved)).toMatchObject({ batchMode: 'library', depth: 24, outDir: '/x' }); + expect( + sanitize({ + batchMode: 'x', + place: 3, + outDir: '', + depth: 20, + gridOnly: 'yes', + tbpm: { prepare: 1 }, + xml: null, + seratoPrepareFolders: 'no', + }), + ).toEqual({}); + // A store from before these settings gets their defaults. + useSettings.getState().hydrate(sanitize({ target: -9 })); + expect(useSettings.getState()).toMatchObject({ batchMode: 'prepare', target: -9, tbpm: DEFAULT_SETTINGS.tbpm }); + }); + + it('source depth saved as null survives; an absent one is not read as a choice', () => { + expect(sanitize({ depth: null })).toEqual({ depth: null }); + expect(sanitize({})).toEqual({}); + }); + + it('remembers the folders chosen for Prepare, and forgets them for Library', () => { + const s = useSettings.getState(); + s.setBatchMode('prepare', ['/a', '/b']); + expect(useSettings.getState().seratoPrepareFolders).toEqual(['/a', '/b']); + s.setBatchMode('prepare', ['/a']); + expect(useSettings.getState().seratoPrepareFolders).toEqual(['/b', '/a']); + s.setBatchMode('library', ['/b']); + expect(useSettings.getState()).toMatchObject({ batchMode: 'library', seratoPrepareFolders: ['/a'] }); + }); + + it('caps the folders it remembers', () => { + const many = Array.from({ length: 300 }, (_, i) => `/f${i}`); + useSettings.getState().setBatchMode('prepare', many); + expect(useSettings.getState().seratoPrepareFolders).toHaveLength(200); + expect(useSettings.getState().seratoPrepareFolders.at(-1)).toBe('/f299'); + expect(sanitize({ seratoPrepareFolders: many }).seratoPrepareFolders).toHaveLength(200); + }); +}); diff --git a/src/state/settings.ts b/src/state/settings.ts index 163d12e..19311dd 100644 --- a/src/state/settings.ts +++ b/src/state/settings.ts @@ -1,5 +1,12 @@ import { create } from 'zustand'; -import type { AnalysisSettings, DecideSettings, LoudnessMode } from '@/lib/ipc'; +import type { + AnalysisSettings, + BatchMode, + DecideSettings, + ExportSettings, + LoudnessMode, + Place, +} from '@/lib/ipc'; /** A loudness preset. `djTarget` reads its target from the persisted DJ target. */ export type PresetId = 'djTarget' | 'clubHot' | 'spotify' | 'apple' | 'custom'; @@ -31,6 +38,15 @@ export const TARGET_RANGE: [number, number] = [-30, -4]; export const CEILING_RANGE: [number, number] = [-6, 0]; export const BPM_LIMITS: [number, number] = [40, 300]; +/** Time a Prepare cut leaves before bar 1, ms (the engine's default, until it is calibrated). */ +export const LEAD_MS = 5; + +/** The export depths the engine writes; `null` keeps the source depth. */ +export const EXPORT_DEPTHS: (number | null)[] = [null, 16, 24]; + +/** How many folders are remembered as "keep Prepare, Serato data or not". */ +export const MAX_REMEMBERED_FOLDERS = 200; + /** The shape of the saved settings; bumped when it changes. */ export const SETTINGS_SCHEMA = 1; @@ -42,6 +58,19 @@ export interface SavedSettings { target: number; ceiling: number; bpmRange: [number, number]; + /** Prepare or Library, as chosen; files with Serato data can switch a batch to Library. */ + batchMode: BatchMode; + place: Place; + /** The folder copies go to, once chosen. */ + outDir: string | null; + /** Output bits per sample; `null` keeps the source depth. */ + depth: number | null; + gridOnly: boolean; + /** Write the tempo tag, per mode: in Library a track's tag is the DJ app's by default. */ + tbpm: Record; + xml: boolean; + /** Folders whose Serato files the user chose to prepare anyway. */ + seratoPrepareFolders: string[]; } export const DEFAULT_SETTINGS: SavedSettings = { @@ -51,6 +80,15 @@ export const DEFAULT_SETTINGS: SavedSettings = { target: DEFAULT_DJ_TARGET, ceiling: -0.5, bpmRange: [70, 180], + batchMode: 'prepare', + // Originals are changed only when asked; the folder is remembered once chosen. + place: 'folder', + outDir: null, + depth: null, + gridOnly: false, + tbpm: { prepare: true, library: false }, + xml: true, + seratoPrepareFolders: [], }; interface SettingsState extends SavedSettings { @@ -62,6 +100,13 @@ interface SettingsState extends SavedSettings { setTarget(lufs: number): void; setCeiling(dbtp: number): void; setBpmRange(range: [number, number]): void; + setBatchMode(mode: BatchMode, seratoFolders?: string[]): void; + setPlace(place: Place): void; + setOutDir(dir: string): void; + setDepth(depth: number | null): void; + setGridOnly(on: boolean): void; + setTbpm(on: boolean, mode: BatchMode): void; + setXml(on: boolean): void; /** Sets the DJ target (from "Calibrate from my library") and selects it. */ calibrate(lufs: number): void; } @@ -103,6 +148,23 @@ export function sanitize(saved: Record): Partial if (num(saved.target, TARGET_RANGE)) out.target = saved.target as number; if (num(saved.ceiling, CEILING_RANGE)) out.ceiling = saved.ceiling as number; if (validRange(saved.bpmRange)) out.bpmRange = saved.bpmRange; + if (saved.batchMode === 'prepare' || saved.batchMode === 'library') out.batchMode = saved.batchMode; + if (saved.place === 'inPlace' || saved.place === 'folder') out.place = saved.place; + if (typeof saved.outDir === 'string' && saved.outDir !== '') out.outDir = saved.outDir; + if (EXPORT_DEPTHS.includes(saved.depth as number | null) && saved.depth !== undefined) { + out.depth = saved.depth as number | null; + } + if (typeof saved.gridOnly === 'boolean') out.gridOnly = saved.gridOnly; + const tbpm = saved.tbpm as Partial> | null | undefined; + if (typeof tbpm?.prepare === 'boolean' && typeof tbpm.library === 'boolean') { + out.tbpm = { prepare: tbpm.prepare, library: tbpm.library }; + } + if (typeof saved.xml === 'boolean') out.xml = saved.xml; + if (Array.isArray(saved.seratoPrepareFolders)) { + out.seratoPrepareFolders = saved.seratoPrepareFolders + .filter((f): f is string => typeof f === 'string') + .slice(-MAX_REMEMBERED_FOLDERS); + } return out; } @@ -135,6 +197,32 @@ export const useSettings = create()((set, get) => ({ setBpmRange(range) { if (validRange(range)) set({ bpmRange: [Math.round(range[0]), Math.round(range[1])] }); }, + setBatchMode(mode, seratoFolders = []) { + const kept = get().seratoPrepareFolders.filter((f) => !seratoFolders.includes(f)); + set({ + batchMode: mode, + seratoPrepareFolders: + mode === 'prepare' ? [...kept, ...seratoFolders].slice(-MAX_REMEMBERED_FOLDERS) : kept, + }); + }, + setPlace(place) { + set({ place }); + }, + setOutDir(outDir) { + set({ outDir }); + }, + setDepth(depth) { + if (EXPORT_DEPTHS.includes(depth)) set({ depth }); + }, + setGridOnly(gridOnly) { + set({ gridOnly }); + }, + setTbpm(on, mode) { + set({ tbpm: { ...get().tbpm, [mode]: on } }); + }, + setXml(xml) { + set({ xml }); + }, calibrate(lufs) { const djTarget = tenth(clamp(lufs, TARGET_RANGE)); set({ mode: 'dj', preset: 'djTarget', djTarget, target: djTarget, ceiling: -0.5 }); @@ -151,6 +239,19 @@ export function analysisSettings(s: SavedSettings): AnalysisSettings { return { bpmRange: s.bpmRange, grid: true, model: 'small' }; } +/** What an export runs with, for a batch in `batchMode`. Grid only keeps the source depth. */ +export function exportSettings(s: SavedSettings, batchMode: BatchMode): ExportSettings { + return { + batchMode, + place: s.place, + depth: s.gridOnly ? null : s.depth, + gridOnly: s.gridOnly, + tbpm: s.tbpm[batchMode], + xml: s.xml, + leadMs: LEAD_MS, + }; +} + /** The saved part of the state. */ export function savedPart(s: SavedSettings): SavedSettings { return { @@ -160,6 +261,14 @@ export function savedPart(s: SavedSettings): SavedSettings { target: s.target, ceiling: s.ceiling, bpmRange: s.bpmRange, + batchMode: s.batchMode, + place: s.place, + outDir: s.outDir, + depth: s.depth, + gridOnly: s.gridOnly, + tbpm: s.tbpm, + xml: s.xml, + seratoPrepareFolders: s.seratoPrepareFolders, }; } diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index c0259e3..30bac09 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -1,4 +1,14 @@ -import type { FileEntry, JobEvent, Plan, RowAnalysis } from '@/lib/ipc'; +import type { + ExportArtefacts, + ExportPreview, + ExportPreviewRow, + ExportRow, + ExportVerification, + FileEntry, + JobEvent, + Plan, + RowAnalysis, +} from '@/lib/ipc'; /** A probed FLAC row. */ export function entry(fileId: number, title = `Track ${fileId}`): FileEntry { @@ -73,3 +83,89 @@ 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 }; } + +export function verification(overrides: Partial = {}): ExportVerification { + return { + blocks: 29, + carried: 26, + patched: 0, + edited: 1, + replaced: 2, + dropped: 0, + tagsAdded: 3, + framesIn: 1000, + framesOut: 1000, + bitsOut: 16, + exact: false, + dithered: false, + staleLoudnessTags: [], + ...overrides, + }; +} + +/** A row the engine sends for a file being written with a -2.3 dB gain and a 0.21 s cut. */ +export function exportRow(overrides: Partial = {}): ExportRow { + return { + stage: 'processing', + action: { type: 'write', gainDb: -2.3, cut: { type: 'cut', frames: 9261, seconds: 0.21 } }, + positionsShift: true, + gridWithheld: false, + notices: [], + verification: null, + gridCheck: null, + editCarried: false, + revertible: false, + exportedWith: null, + notes: [], + ...overrides, + }; +} + +/** A row finished, verified and in place. */ +export function doneRow(overrides: Partial = {}): ExportRow { + return exportRow({ + stage: 'done', + verification: verification(), + gridCheck: { result: 'pass', offsetMs: 0.4, bpmDiff: 0 }, + revertible: true, + ...overrides, + }); +} + +/** A row of the confirm step's plan. */ +export function previewRow( + fileId: number, + row: ExportRow | null, + overrides: Partial = {}, +): ExportPreviewRow { + return { + fileId, + row, + error: row ? null : { kind: 'corrupt', message: 'unreadable', fileId }, + identity: row ? `id-${fileId}` : null, + replacesCopy: false, + ...overrides, + }; +} + +export function exportPreview(rows: ExportPreviewRow[], overrides: Partial = {}): ExportPreview { + return { + rows, + revision: 4, + decide: { mode: 'dj', target: -11, ceiling: -0.5, bpmRange: [70, 180] }, + xmlListed: 0, + backupFolder: '/Users/dj/Music/SoundCheck Backups', + artefactsFolder: '/Users/dj/Music/SoundCheck/exports', + ...overrides, + }; +} + +export function artefacts(overrides: Partial = {}): ExportArtefacts { + return { + folder: '/exports/2026-11-05', + xml: '/exports/2026-11-05/soundcheck-rekordbox.xml', + report: '/exports/2026-11-05/grid-report.csv', + listed: 3, + ...overrides, + }; +}