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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pnpm install
./scripts/verify.sh # fmt, clippy, tests, typecheck, vitest
pnpm tauri dev # run the app
pnpm dev:mock # the UI alone in a browser, with synthetic tracks (open /?demo=1)
# start-up recovery banners: /?demo=recovery, /?demo=recovery-failed, /?demo=recovery-finished
cargo run --release -p sc-cli -- analyze <file> # loudness, BPM, meter and bar 1
cargo run --release -p sc-cli -- plan <files> # what processing would do to each file
cargo run --release -p sc-cli -- plan <files> --batch-mode prepare # ... and what exporting would write
Expand Down
3 changes: 3 additions & 0 deletions crates/sc-core/src/ipc/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,7 @@ pub struct PendingChange {
pub path: String,
/// Why it was left (volume not mounted, busy, cleanup failed).
pub reason: String,
/// It was writing a copy into a folder (`path` is the copy), not changing the file in place:
/// until its rename the copy is not there, or is an empty file holding its name.
pub to_folder: bool,
}
1 change: 1 addition & 0 deletions crates/sc-engine/src/txn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ pub fn recovery_status(report: &RecoveryReport) -> RecoveryStatus {
txn: p.txn.clone(),
path: p.path.display().to_string(),
reason: p.reason.clone(),
to_folder: p.to_folder,
})
.collect(),
}
Expand Down
1 change: 1 addition & 0 deletions crates/sc-engine/src/txn/gate/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ fn updates_at_once_lose_nothing() {
txn: format!("t{n}"),
path: format!("/p{n}"),
reason: "gone".to_owned(),
to_folder: false,
};
let gate = Arc::new(RecoveryGate::new(RecoveryStatus::Finished {
recovered: Vec::new(),
Expand Down
24 changes: 21 additions & 3 deletions crates/sc-io/src/txn/forget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
//! leaving pending (its volume will never be mounted again, its folder was moved away).
//!
//! A `forgotten` line ends it, so recovery skips it from then on. Nothing is deleted: the
//! backup of the original, its temp copy and the transaction's temp file stay where they are,
//! and [`Forgotten::kept`] lists the ones that may still exist so the user can find them. Only
//! backup of the original, its temp copy, the transaction's temp file and the empty placeholder
//! a copy to an exFAT or FAT folder holds its name with stay where they are, and
//! [`Forgotten::kept`] lists the ones that may still exist so the user can find them. Only
//! a transaction that has not ended can be forgotten, and not while it runs.

use std::path::{Path, PathBuf};

use sc_core::{Error, Result};

use super::fsx::exists;
use super::journal::{Journal, Line, State, TxnKind, TxnLock, is_txn_id};
use super::journal::{Entry, Journal, Line, State, TxnKind, TxnLock, is_txn_id};

/// What [`forget`] did.
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -94,6 +95,9 @@ pub fn forget(backup_root: &Path, txn: &str) -> Result<Forgotten> {
kept.push(path);
}
}
if placeholder_may_remain(&entry) && !kept.contains(&entry.path) {
kept.push(entry.path.clone());
}
let mut line = Line::new(txn, State::Forgotten);
line.notes = kept
.iter()
Expand All @@ -111,6 +115,20 @@ pub fn forget(backup_root: &Path, txn: &str) -> Result<Forgotten> {
})
}

/// Whether a copy to a folder may have left the empty placeholder that holds its name on a
/// volume without an exclusive rename (exFAT, FAT32; see `fsx::rename_noreplace`): it had not
/// renamed, and its target is an empty file, or sits in a folder that cannot be reached now. A
/// later export to that folder would find a file of that name that is not SoundCheck's.
fn placeholder_may_remain(e: &Entry) -> bool {
if e.kind != TxnKind::ToFolder || e.reached >= State::Renamed {
return false;
}
if e.path.parent().is_some_and(|d| !d.is_dir()) {
return true;
}
std::fs::symlink_metadata(&e.path).is_ok_and(|m| m.is_file() && m.len() == 0)
}

/// Whether `path` exists, or its folder cannot be reached so it cannot be known.
fn may_exist(path: &Path) -> bool {
exists(path) || path.parent().is_some_and(|d| !d.is_dir())
Expand Down
64 changes: 64 additions & 0 deletions crates/sc-io/src/txn/forget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ fn a_pending_change_is_forgotten_and_recovery_skips_it() {
let s = scene();
let report = recover(&s.root).expect("recovery runs");
assert_eq!(report.pending.len(), 1, "unreachable, so pending");
assert!(!report.pending[0].to_folder, "it changed the file in place");

let f = forget(&s.root, ID).expect("forgotten");
assert_eq!(f.path, s.target);
Expand Down Expand Up @@ -133,3 +134,66 @@ fn an_id_that_is_a_path_touches_nothing() {
assert!(TxnLock::try_acquire(&s.journal, "../../victim").is_err());
assert!(victim.exists());
}
/// A journal in `root` holding a copy into `<dir>/a.wav` that reached `reached`.
fn copy_to(root: &std::path::Path, dir: &std::path::Path, reached: State) -> (Journal, PathBuf) {
let journal = Journal::open(root).expect("journal");
let target = dir.join("a.wav");
let mut planned = Line::new(ID, State::Planned);
planned.kind = Some(TxnKind::ToFolder);
planned.path = Some(target.clone());
planned.source = Some(root.join("a.wav"));
planned.temp = Some(dir.join(".a.wav.soundcheck-tmp-t"));
planned.output_bytes = Some(4096);
journal.append(&planned).expect("planned");
for state in [State::TempWritten, State::Verified, State::Renamed] {
if state <= reached {
journal.append(&Line::new(ID, state)).expect("step");
}
}
(journal, target)
}

#[test]
fn a_copy_to_an_unmounted_stick_lists_the_placeholder_it_may_have_left() {
let dir = tempfile::tempdir().expect("temp dir");
let base = dir.path().canonicalize().expect("resolves");
let root = base.join("backups");
// Whether the empty file is on the stick cannot be known while it is away, so it is listed.
let gone = base.join("Volumes/STICK/Crate");
let (_journal, target) = copy_to(&root, &gone, State::Verified);
let report = recover(&root).expect("recovery runs");
let pending = report.pending.first().expect("unreachable, so pending");
assert!(pending.to_folder, "recovery says it was a copy");
let f = forget(&root, ID).expect("forgotten");
assert!(f.kept.contains(&target), "{:?}", f.kept);
assert_eq!(f.backup, None, "a copy makes no backup");
}

#[test]
fn an_empty_placeholder_is_listed_and_kept_but_a_real_file_is_not() {
let dir = tempfile::tempdir().expect("temp dir");
let base = dir.path().canonicalize().expect("resolves");
let folder = base.join("Crate");
std::fs::create_dir_all(&folder).expect("folder");

let root = base.join("backups");
let (_journal, target) = copy_to(&root, &folder, State::Verified);
std::fs::write(&target, b"").expect("placeholder");
let f = forget(&root, ID).expect("forgotten");
assert!(f.kept.contains(&target), "{:?}", f.kept);
assert!(target.exists(), "nothing deleted");

// A file of that name with something in it is not the change's placeholder.
let root = base.join("backups-2");
let (_journal, target) = copy_to(&root, &folder, State::Verified);
std::fs::write(&target, b"someone else's").expect("other file");
let f = forget(&root, ID).expect("forgotten");
assert!(!f.kept.contains(&target), "{:?}", f.kept);

// Past the rename, the file there is the copy itself, not a placeholder.
let root = base.join("backups-3");
let (_journal, target) = copy_to(&root, &folder, State::Renamed);
std::fs::write(&target, b"").expect("empty");
let f = forget(&root, ID).expect("forgotten");
assert!(!f.kept.contains(&target), "{:?}", f.kept);
}
3 changes: 3 additions & 0 deletions crates/sc-io/src/txn/recover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ pub struct Pending {
pub path: PathBuf,
/// Why it was left (running, folder not reachable, cleanup failed).
pub reason: String,
/// It was writing a copy into a folder, not changing the file in place.
pub to_folder: bool,
}

/// What [`recover`] did.
Expand Down Expand Up @@ -119,6 +121,7 @@ pub fn recover(backup_root: &Path) -> Result<RecoveryReport> {
txn: first.txn.clone(),
path: first.path.clone(),
reason,
to_folder: first.kind == TxnKind::ToFolder,
};
let lock = match TxnLock::try_acquire(&journal, &first.txn) {
Ok(Some(lock)) => lock,
Expand Down
4 changes: 2 additions & 2 deletions docs/PRODUCT.md

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ 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';
import { startRecoveryWatch, useRecovery } from '@/state/recovery';
import { RecoveryBanner } from '@/features/recovery/RecoveryBanner';


/** Keys for the review loop: arrows move, N jumps to the next row that needs a look. */
Expand All @@ -34,6 +35,8 @@ function useKeys() {
if (useTrack.getState().fileId !== null) return;
// The confirm step, while open, has the keys to itself.
if (useExportFlow.getState().phase !== 'closed') return;
// So does the step that asks before forgetting a change.
if (useRecovery.getState().asking) return;
const panels = usePanels.getState();
if (e.metaKey && e.key === ',') {
// Settings: the drawer in a narrow window, else the rail's first control.
Expand Down Expand Up @@ -154,19 +157,20 @@ function Library() {
<Toolbar busy={state !== 'idle'} />
<div className="flex min-h-0 flex-1">
<main className="flex min-w-0 flex-1 flex-col">
<RecoveryBanner />
{notice && (
<div role="alert" className="border-b border-[rgba(251,191,36,.45)] bg-[rgba(251,191,36,.10)] px-4 py-2 text-[12.5px] text-warn">
{notice}
</div>
)}
{aborted && (
<div role="alert" className="border-b border-[rgba(248,113,113,.45)] bg-[rgba(248,113,113,.10)] px-4 py-2 text-[12.5px] text-err">
{abortedKind === 'export' ? 'Export' : 'Analysis'} could not start: {errorCopy(aborted)}
{abortedKind === 'export' ? 'Export' : 'Analysis'} could not start: {errorCopy(aborted, 'banner')}
</div>
)}
{artefactsFailed && (
<div role="alert" className="border-b border-[rgba(251,191,36,.45)] bg-[rgba(251,191,36,.10)] px-4 py-2 text-[12.5px] text-warn">
The rekordbox XML and the grid report could not be written: {errorCopy(artefactsFailed)}
The rekordbox XML and the grid report could not be written: {errorCopy(artefactsFailed, 'banner')}
{exported && ' The exported files are fine.'}
</div>
)}
Expand Down
12 changes: 9 additions & 3 deletions src/dev/mockBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { mockIPC } from '@tauri-apps/api/mocks';
import { gridCommand } from './mockGrid';
import { exportCommand } from './mockExport';
import { mockRecovery, recoveryDemoOf, type RecoveryDemo } from './mockRecovery';
import type { Channel } from '@tauri-apps/api/core';
import type {
Codec,
Expand Down Expand Up @@ -94,7 +95,8 @@ function fileEntry(s: Synthetic, folder: string, fileId: number): FileEntry {
album: null,
djUnsafe: s.float ? 'float' : null,
serato: s.serato ?? false,
seratoTags: s.serato ? ['markers', 'beatGrid'] : [],
// Analysed in Serato: its auto gain too.
seratoTags: s.serato ? ['markers', 'beatGrid', 'autotags'] : [],
seratoUnknown: false,
soundcheck: null,
soundcheckUnreadable: null,
Expand Down Expand Up @@ -204,7 +206,9 @@ function runJob(jobId: number, fileIds: number[], channel: unknown) {
/** Where the mock keeps the monitor volume between reloads. */
const MONITOR_KEY = 'soundcheck-mock-monitor';

export function installMockBackend(): void {
/** `recovery`: which start-up recovery to show (see `mockRecovery.ts`); the page's URL by default. */
export function installMockBackend(recovery: RecoveryDemo = recoveryDemoOf(globalThis.location?.search ?? '')): void {
const recovered = mockRecovery(recovery);
const session = {
decide: () => settings,
analysed,
Expand Down Expand Up @@ -247,7 +251,9 @@ export function installMockBackend(): void {
case 'restore_session':
return { revision, rows: [], runningJobs: [], artefacts: null, artefactsFailed: null, lastExport: null };
case 'recovery_status':
return { state: 'finished', recovered: [], pending: [] };
return recovered.status();
case 'forget_pending':
return recovered.forget(a.txn as string);
case 'clear_session':
analysed.clear();
entries.clear();
Expand Down
12 changes: 9 additions & 3 deletions src/dev/mockExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,13 @@ function row(planned: Planned, stage: ExportRow['stage'], req: ExportRequest, de
};
}

function verification(s: Synthetic): ExportVerification {
/** Loudness tags each track keeps at the old level once its gain changes, as the engine labels them. */
const STALE: Record<string, string[]> = {
'Quiet Classic': ['TXXX:REPLAYGAIN_ALBUM_GAIN', 'TXXX:REPLAYGAIN_ALBUM_PEAK', 'RVA2', 'COMM:iTunNORM'],
'Serato Cued': ['GEOB:Serato Autotags'],
};

function verification(s: Synthetic, gainDb: number): ExportVerification {
return {
blocks: 29,
carried: 26,
Expand All @@ -138,7 +144,7 @@ function verification(s: Synthetic): ExportVerification {
bitsOut: s.bits ?? 16,
exact: false,
dithered: false,
staleLoudnessTags: [],
staleLoudnessTags: gainDb === 0 ? [] : (STALE[s.title] ?? []),
};
}

Expand Down Expand Up @@ -199,7 +205,7 @@ async function exportOne(session: MockSession, jobId: number, fileId: number, re
send({ type: 'failed', jobId, fileId, error: { kind: 'verifyFailed', message, fileId } });
return;
}
const wrote = { ...row(planned, 'writing', req, decide), verification: verification(s) };
const wrote = { ...row(planned, 'writing', req, decide), verification: verification(s, planned.action.gainDb) };
send({ type: 'written', jobId, fileId, row: wrote });
if (req.outDir !== null) written.add(`${req.outDir}/${fileId}`);
await wait(STEP_MS);
Expand Down
Loading
Loading