diff --git a/docs/features/library.md b/docs/features/library.md index eddd74eb..c45ff8ce 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -10,7 +10,7 @@ The library is a per-profile SQLite database (`/profiles//data.db`) ke - **Several genres per track** (#729) — every genre item is read (`get_strings(ItemKey::Genre)`, never `genre()`, which is the first one): lofty already turns a multi-value ID3v2.4 `TCON`, repeated Vorbis `GENRE` comments and several MP4 `©gen` into one item each. [`split_genres`](../../src-tauri/crates/core/src/scanner/canonical.rs) then splits each value on `;`, NUL and `\` — taggers that write one field join genres with `;`, and some write the `\\` a tag editor shows between multiple values into the file as text ("Alternative\\Rock") — trims them, and drops a name the library would file under one already listed (same `canonical_name`), keeping the tag's order. **Not on `/` or `,`**: they occur inside genre names people use ("Hip-Hop/Rap", "Folk, World, & Country"). Each genre is one `track_genre` row. A library scanned before #729 holds one genre per track — the first value, or a "Rock; Jazz" kept whole — and the fast path never reopens a file whose mtime and size still match, so each folder gets **one forced re-read**, marked `profile_setting['scan.genre_backfill_done.']` like the custom-tag pass: the unchanged-file branch compares the file's genres with the stored ones and relinks them when they differ. A stopped scan does not set the marker. A file that fails extraction holds it back, since a lock is usually transient, but for three scans at most (`scan.genre_backfill_attempts.`, as the custom-tag pass counts), so a permanently unreadable file does not re-read the folder forever. The genre row such a scan leaves without tracks stays in `genre` (a smart-playlist rule may name it) and drops out of every listing, which only ever counts genres with an available track. - **Folder cover fallback** — when a track carries no embedded picture, the scanner inspects its parent directory for a sidecar image with one of the canonical stems (`cover`, `folder`, `front`, `albumart`, `album`, `artwork`) and an extension the thumbnail pipeline can decode (`jpg`/`jpeg`, `png`, `webp`, `bmp`, `gif`, `tiff`). The first match — by stem priority, not alphabetical — is hash-addressed into the shared `artwork/` dir like an embedded picture. The provenance is tagged `source = 'folder'` in the `artwork` table so a future cleanup job can distinguish it from `'embedded'`, `'deezer'`, or `'user'` entries. Covers common CD-rip / lossless layouts where the artwork sits beside the audio files. - **Audio quality** — sample rate, bitrate, channel count, bit depth and codec are captured at scan time. Hi-Res badges (≥ 24-bit, ≥ 44.1 kHz) light up automatically on covers and rows. -- **Watch folders** — [`notify 8`](https://crates.io/crates/notify) drives a per-folder filesystem watcher with debounced rescans so files dropped into a watched directory appear without a manual refresh. Deletions flag rows `is_available = 0` rather than purging them, so play history, ratings and playlist memberships survive a reorganisation. +- **Watch folders** — [`notify 8`](https://crates.io/crates/notify) drives a per-folder filesystem watcher with debounced rescans so files dropped into a watched directory appear without a manual refresh. Deletions flag rows `is_available = 0` rather than purging them, so play history, ratings and playlist memberships survive a reorganisation. Three things the [watcher](../../src-tauri/crates/app/src/watcher.rs) does not let fall through: the backend's own "events were lost" signal (an inotify queue overflow, an FSEvents must-scan — `EventKind::Other` with the rescan flag, usually naming no path) asks for the same debounced scan of the folder as any change, since it is exactly what a large copy produces; errors the backend reports are logged, at most once a minute per folder with a count of the ones in between; and a watched folder that cannot be armed — a drive not plugged in at launch, a share that mounts late — is retried every 60 s, then scanned once when it is back, instead of staying unwatched until the next restart. The retry is a placeholder in the same map as the watchers, so removing the folder, turning watching off or switching profile stops it the same way. - **The reserved directory** — `.waveflow/` inside a library folder is WaveFlow's own, and both walkers leave it alone ([`scanner::reserved`](../../src-tauri/crates/core/src/scanner/reserved.rs), issue #695). It has to be said explicitly, for three reasons that each had to be checked rather than assumed: `mp4` is in `AUDIO_EXTENSIONS`, legitimately, since an `.mp4` usually holds AAC; `is_scannable_audio` takes it **on sight**, because only `ogg` / `oga` pay for a header read, so a Canvas clip with no audio stream at all would pass; and the walk had no directory filter whatsoever, so the leading dot would have bought nothing — that is a display convention, not something a file walker obeys. `collect_scannable_files` prunes the branch as `filter_entry` meets it, so the whole subtree is skipped rather than each file in it refused, and the fs watcher drops an event whose **every** path is inside it, so caching a clip does not cost a folder rescan (an event touching one of ours and one of theirs is a library change). The check is rooted, not run over every component of an absolute path: somebody whose music lives under a directory of that name would otherwise have an empty library and nothing to explain it — and for the same reason the walk never prunes its own root. - **Live progress** — `scan_folder_inner` emits a `scan:progress` event (throttled to every 25 files, plus one initial and one final tick) carrying `current` / `total`, the running `added` / `updated` / `skipped` / `errors` tallies, `cancelled`, and `current_dir` — the parent directory of the file just processed, `None` on the initial and final ticks. The moving bar itself belongs to the [task status bar](ui.md#long-running-tasks) since #601, which lists every long operation in one place; [`ScanProgressToast`](../../src/components/common/ScanProgressToast.tsx) (mounted once in `AppLayout`) shows the **outcome**, which is the part a status bar cannot — its row vanishes when the task ends, and “412 added, 3 errors” is what is worth reading. The toast ignores non-terminal events, so a rescan starting underneath cannot blank a summary the user has not finished reading. A scan the user stopped reports as stopped rather than as a completed walk, and `current` then carries the files actually reached rather than the folder total (issue #430, #601). **Listing the folder reports too**: on a network share the walk alone can take minutes, and the task row used to sit at zero with no folder named and a stop button nobody looked at until the list was complete. `collect_scannable_files_with` now hands the row the count of audio files found so far (no total yet — the status bar shows the count alone) and the directory it is in, and checks the stop flag every 32 entries; a stop there returns no list at all, since the scan treats the list as the folder's contents and would sweep every track it had not reached yet. @@ -157,7 +157,7 @@ Tag edits go through the same funnel: [`edit.rs`](../../src-tauri/crates/app/src - **Single-click play** — with `ui.single_click_play` on, the first click of a row plays it; the second click of a double-click (`detail > 1`) and the row's `dblclick` stand down, so a double-click out of habit is one play instead of three. Either way, playing a row clears the selection its own click made (#897): with double-click play the first click selects the row, and the blue selection used to outrank the green "now playing" on that very row. The library, album and playlist tables all do this; playing a selection from the action bar is a separate path and keeps it. - **A-Z navigator** — letter rail on the artists tab, NFD-normalised so accents (É → E, Ñ → N) bucket correctly. - **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. **Ctrl+A** (Cmd+A) selects every local row of the list on screen — the tracks tab, an opened folder, an inventory category, an album, a playlist, once that list has loaded (right after navigating, the previous one is still in state) — ([`useSelectAllShortcut`](../../src/hooks/useSelectAllShortcut.ts)) — and leaves the combo to a focused field or an open dialog. The selection bar shows on the tracks tab, in an opened folder and in an inventory category, and the selection clears when the tab, the folder or the inventory category changes. Each row's number gives way to a **checkbox** ([`RowSelectCheckbox`](../../src/components/common/RowSelectCheckbox.tsx)) on keyboard focus (it is hidden by opacity, so it stays in the tab order) and on every row once a selection has been _asked for_ — a Ctrl or Shift click, or Ctrl+A (`useMultiSelect().checkboxMode`). A plain click only highlights its row, which keeps its number: switching the whole list to boxes on the way to a double-click play is what [#859](https://github.com/InstaZDLL/WaveFlow/issues/859) reported. The box no longer shows on hover, in either click mode ([#867](https://github.com/InstaZDLL/WaveFlow/issues/867)): it sits where the number is, so it caught clicks meant for the row, and it put a checkbox under every pass of the mouse. Each box is named after its row for screen readers; its click and Space never reach the row, which would play it or reset the selection. A row clicked with the mouse never shows a focus ring, whatever key is pressed next — Escape, a digit, or a desktop shortcut such as Super+Q under Hyprland ([#866](https://github.com/InstaZDLL/WaveFlow/issues/866)): [`inputModality`](../../src/lib/inputModality.ts) marks focus that arrives from a pointer, and `app.css` keeps a marked element ringless; focus reached from the keyboard keeps its ring. Server rows have no rowid to select and keep their number. -- **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. The read-only view shows every tag the form edits (track and disc number on two rows — the old "1 / 37" read as track 1 of 37 — and the genre), and **Edit** sits in the header next to Close rather than in the footer a scroll away. In the form, artist, album and genre are [`TagCombobox`](../../src/components/common/TagCombobox.tsx) fields: click or type and a list opens with what the library already holds. Genres are few and loaded whole, then filtered as you type (accent- and case-insensitive); artists and albums can run to thousands, so they come from the backend's `search_artists` / `search_albums` (bounded, prefix-first, the top bar's search) after a 150 ms pause, cached per query for the life of the dialog and shown as returned, since that search matches a normalised form the list would not. For the genre, the list adds the common genres of [`genrePresets.ts`](../../src/lib/genrePresets.ts) (the ID3v1 / Winamp list tag editors propose, trimmed, plus a few recent genres) — anything typed is still taken as is. A pick replaces the genre rather than adding one: the save stores one genre, so completing a `;`-separated list would create a genre named after the whole list. The list is portalled (the metadata section clips with `overflow: hidden`) and focus never leaves the input, so Escape closes the list first and the dialog second. **What a save sends, and what it changes.** Title, numbers and year always; artist, album and genre only when the field differs from what the track holds. The artist field is filled from `get_track_artists`, the credits joined with `"; "` — the only separator `split_artist_name` splits on — not from the row's display `artist_name`, which is joined with `", "`: echoing that back merged a multi-artist track's credits into one artist named "A, B", in the database and in the file, on any save, a title-only one included. Artists, albums and genres are found by a canonical key that ignores case, punctuation and spacing, so "The Wall" typed over "the wall" lands on the same row; the save then **respells** that row (`respell` in `edit.rs`), since nothing else ever renamed it and the fix showed in the file alone. Only on an explicit edit: the scanner leaves names as they were first seen, or two files spelling an artist differently would rename it back and forth on every scan. The save also recomputes the year of the album the track left and the one it joined (`refresh_album_years`, shared with the folder scan), which the header otherwise kept until the next scan of that folder. +- **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. The read-only view shows every tag the form edits (track and disc number on two rows — the old "1 / 37" read as track 1 of 37 — and the genre), and **Edit** sits in the header next to Close rather than in the footer a scroll away. In the form, artist, album and genre are [`TagCombobox`](../../src/components/common/TagCombobox.tsx) fields: click or type and a list opens with what the library already holds. Genres are few and loaded whole, then filtered as you type (accent- and case-insensitive); artists and albums can run to thousands, so they come from the backend's `search_artists` / `search_albums` (bounded, prefix-first, the top bar's search) after a 150 ms pause, cached per query for the life of the dialog and shown as returned, since that search matches a normalised form the list would not. For the genre, the list adds the common genres of [`genrePresets.ts`](../../src/lib/genrePresets.ts) (the ID3v1 / Winamp list tag editors propose, trimmed, plus a few recent genres) — anything typed is still taken as is. A pick replaces the genre rather than adding one: the save stores one genre, so completing a `;`-separated list would create a genre named after the whole list. The list is portalled (the metadata section clips with `overflow: hidden`) and focus never leaves the input, so Escape closes the list first and the dialog second. **What a save sends, and what it changes.** Title, numbers and year always; artist, album and genre only when the field differs from what the track holds. The artist field is filled from `get_track_artists`, the credits joined with `"; "` — the only separator `split_artist_name` splits on — not from the row's display `artist_name`, which is joined with `", "`: echoing that back merged a multi-artist track's credits into one artist named "A, B", in the database and in the file, on any save, a title-only one included. Artists, albums and genres are found by a canonical key that ignores case, punctuation and spacing, so "The Wall" typed over "the wall" lands on the same row; the save then **respells** that row (`respell` in `edit.rs`), since nothing else ever renamed it and the fix showed in the file alone. Only on an explicit edit: the scanner leaves names as they were first seen, or two files spelling an artist differently would rename it back and forth on every scan. The save also recomputes the year of the album the track left and the one it joined (`refresh_album_years`, shared with the folder scan), which the header otherwise kept until the next scan of that folder. **A file changed by another program while the dialog is open is not overwritten.** Since the title, numbers and year always go back, a tag written by another tagger in the meantime would have been silently reverted to the values the dialog showed. The dialog reads the file's stamp (`get_track_file_stamp`: size and modification time, the pair the scanner's fast path trusts, as an opaque string so the nanoseconds survive JSON) each time it loads the track, and hands it back with the save; `update_track_tags` checks it before pausing playback and again just before the write, and refuses with `AppError::FileChangedOnDisk` when the file moved. The save returns the new stamp, so the dialog's own writes — and the rating and cover, which refresh the track — never trip it. The refusal names the cause and offers **Reload from file**, which rescans the track's library folder (`reread_track_folder`, the ordinary scan, so unchanged files cost a stat) and refills the form from the result: reloading from the database alone would show the same stale values with a fresh stamp. A change made _before_ the dialog opened and not yet scanned is outside what the stamp can see; a watched folder catches that one. The batch editor and the tag fetcher send only the fields the user chose, so they carry no stamp. - **POPM ratings** — 5-star with half-steps, round-tripped to the file's tag. Edit surfaces: inline `StarRating` in the library track list, integer-star submenu in the right-click `TrackContextMenu` (any view), full half-star widget in the `TrackPropertiesModal`. The backend command `set_track_rating` writes the POPM frame back to the file (binary `\0` for ID3v2, text `RATING=0-100` for Vorbis / MP4 / APE), updates `track.rating` in the DB, then emits `track:updated` so every open view re-fetches without polling. The write goes through `edit::patch_file` like every other tag edit (#644), so a `.dsf` gets its POPM frame through the DSF writer rather than stopping at the database. **ID3v2 ratings are written as the POPM frame itself**, on the concrete tag, and that is not a stylistic choice: lofty 0.25 changed what a POPM looks like on its generic tag — it arrives as `||` through a whole-star `StarRating` rather than as the raw frame body its own documentation still describes — so a 0-255 value in half-star steps put into the generic tag is dropped on the way back to the file, silently, and a plain number or a raw body produces no frame at all (measured). The scanner reads both shapes for the same reason: reading only the old one is why an MP3's rating came back as nothing after that bump. `.dff` is still refused, with the reason. WaveFlow does not read a rating back out of a DSD file — the DSD extractor lifts title / artist / album / year and no more — so the database stays the copy this app trusts; what the write buys is the rating travelling with the file. Smart playlists expose this as the `rating_min` rule — see [smart-playlists.md](smart-playlists.md#custom-smart-playlists-recursive-boolean-rule-tree). - **Lightbox** — double-click any cover or artist photo to view full-size with keyboard navigation. diff --git a/docs/features/playlists.md b/docs/features/playlists.md index cbf2801e..055bc095 100644 --- a/docs/features/playlists.md +++ b/docs/features/playlists.md @@ -31,7 +31,7 @@ Drag-and-drop is implemented with `@dnd-kit` over a virtualised list (`@tanstack ## M3U import / export -`export_playlist_m3u` writes a UTF-8 `.m3u8` with `#EXTINF:, - ` headers and absolute file paths. The importer accepts both `.m3u` and `.m3u8` (foobar2000, VLC, Rekordbox, hand-written sets) and matches against the active library: +`export_playlist_m3u` writes a UTF-8 `.m3u8` with `#EXTINF:<duration>,<artist> - <title>` headers and absolute file paths, through a partial sibling of its own (created exclusively) renamed over the destination once complete ([`atomic_write`](../../src-tauri/crates/core/src/atomic_write.rs)), so overwriting an existing playlist file that fails halfway leaves the old one intact rather than truncated. The importer accepts both `.m3u` and `.m3u8` (foobar2000, VLC, Rekordbox, hand-written sets) and matches against the active library: 1. **Canonical-path match** — strip Windows `\\?\` prefix, lowercase the drive letter, normalise separators. 2. **Basename fallback** — if path-match fails, try the basename. This recovers playlists exported before a library reorganisation. diff --git a/docs/features/ui.md b/docs/features/ui.md index 91196f8b..3edecf0a 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -602,7 +602,7 @@ Per-profile isolated database (libraries, playlists, settings, play history); sh [`commands/profile_io.rs`](../../src-tauri/crates/app/src/commands/profile_io.rs) packages a profile into a single `.waveflow` (zip) file containing `manifest.json` + `data.db` + the per-profile `artwork/` directory. Settings → Storage and backups → Profile backups exposes both buttons. -- **Export:** the active-profile path runs `PRAGMA wal_checkpoint(TRUNCATE)` first so the bundled DB captures every committed page (otherwise a busy WAL would leave the archive holding a partial snapshot). The CPU-bound zip work runs on `tokio::task::spawn_blocking`. +- **Export:** the active-profile path runs `PRAGMA wal_checkpoint(TRUNCATE)` first so the bundled DB captures every committed page (otherwise a busy WAL would leave the archive holding a partial snapshot). The CPU-bound zip work runs on `tokio::task::spawn_blocking`. The archive is written to a partial file of its own beside the target (`<stem>.<process>-<n>.waveflow.partial`, created exclusively, so two writes to one destination never share it; on Unix it takes the destination's group and mode before a byte is written, and on Windows an existing destination is replaced with `ReplaceFileW`, which keeps its ACL and attributes, so a private archive stays private), flushed to disk and renamed over the destination only once complete ([`atomic_write`](../../src-tauri/crates/core/src/atomic_write.rs)), so an export that fails halfway — full disk, a share that drops — leaves an existing archive at that path as it was instead of truncated, and leaves no archive-shaped file behind when there was none. The automatic backup and the pre-reset safety copy share the same writer. - **Import:** always allocates a fresh profile row — never overwrites — under the archive's profile name, or `Name (2)`, `Name (3)`… when a profile already holds it (#767): `profile.name` is UNIQUE and restoring a backup of a profile that still exists is the ordinary case, so the name is taken by construction. It retries on the violation itself rather than checking first. The command returns the new id **and** that name, the success message shows the name, and Settings refreshes the profile list so the selector it points at actually lists the profile. It then extracts the archive under `profiles/<new_id>/`. Failures roll the row back so a half-imported profile doesn't survive the error. Before the sqlx migrator runs, [`normalise_migration_checksums`](../../src-tauri/crates/app/src/commands/profile_io.rs) rewrites `_sqlx_migrations.checksum` for every version present in both the archive and the local migrator — older builds checked out migration files with CRLF endings (Windows `core.autocrlf=true` + no `.gitattributes` lock) so their stored SHA-384 differs from the same SQL re-hashed today, even though the DDL is identical. A `.gitattributes` at repo root now pins `*.sql` / `*.rs` / `*.ts` / etc. to LF so future archives stay byte-stable. Once normalised, the new pool is opened once so any pending sqlx migrations replay before the user switches to it. An archive whose `_sqlx_migrations` lists a version unknown to the local migrator is rejected — that means the export came from a newer build. - **Feedback and failures:** an archive carries the artwork cache, so a large library takes minutes; the card shows an "Exporting…" / "Importing…" line while the buttons are greyed, since greyed buttons alone read as a hang. Both commands log their error at `warn` (`profile export failed` / `profile import failed`) — the UI can only show one generic sentence, and until #767 the cause was written nowhere. - **Out of scope:** the shared `app.db` (Last.fm key, Discord opt-in, `network.offline_mode`) belongs to the install, not the profile. @@ -615,7 +615,7 @@ Opt-in scheduled mirror of the manual export so the user's playlists / likes / r - **Config** lives in `app_setting` (install-wide, not per-profile): `backup.enabled` (bool, default OFF), `backup.interval_days` (1-90, default 7), `backup.folder` (string; empty = default `<app_data>/waveflow/backups/`), `backup.retention` (1-50, default 5 — per profile), `backup.last_run_at` (epoch ms). - **Loop** is a single tokio task started once at boot ([`spawn_backup_loop`](../../src-tauri/crates/app/src/backup.rs)). When disabled, parks on a `tokio::sync::Notify` (zero cost) until the user toggles. When enabled, computes the next deadline as `last_run_at + interval_days * 86_400_000` and uses `tokio::select!` between a sleep and the same `Notify` so config changes wake it without waiting for the old sleep to expire. -- **Pass** ([`run_one_backup`](../../src-tauri/crates/app/src/backup.rs)) iterates every row in `profile`, calls the shared [`profile_io::write_archive`](../../src-tauri/crates/app/src/commands/profile_io.rs) (pub-crate-ified from the manual-export path so the two stay bit-compatible), and applies retention per profile (`<sanitized-name>-*.waveflow` sorted by mtime, oldest beyond `retention` deleted). The active profile gets a `PRAGMA wal_checkpoint(TRUNCATE)` first; inactive profiles are already cold on disk (the pool ran a checkpoint at switch / shutdown). +- **Pass** ([`run_one_backup`](../../src-tauri/crates/app/src/backup.rs)) iterates every row in `profile`, calls the shared [`profile_io::write_archive`](../../src-tauri/crates/app/src/commands/profile_io.rs) (pub-crate-ified from the manual-export path so the two stay bit-compatible), and applies retention per profile (`<sanitized-name>-*.waveflow` sorted by mtime, oldest beyond `retention` deleted) — **only after that profile's archive was written**. Retention is "keep the newest N", and pruning after a failed write deleted the oldest good backup on every failed run. Unfinished archives are swept after every attempt, failed or not, since a profile that keeps failing is the one that leaves them. An unfinished `*.waveflow.partial` is never counted as a backup; one left by a crash is removed once it is a day old, an age no write in progress reaches since every write touches it. The active profile gets a `PRAGMA wal_checkpoint(TRUNCATE)` first; inactive profiles are already cold on disk (the pool ran a checkpoint at switch / shutdown). - **Failure isolation:** per-profile errors are logged but don't abort the pass — one corrupt profile shouldn't block backups of the healthy ones. - **Commands** in [`commands/backup.rs`](../../src-tauri/crates/app/src/commands/backup.rs): `get_backup_config`, `set_backup_config` (also signals the loop), `run_backup_now`. UI is [`BackupCard`](../../src/components/views/settings/BackupCard.tsx) in Settings → Storage and backups → Profile backups right after the manual export/import. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 57ad782e..1fac2442 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7858,6 +7858,7 @@ dependencies = [ "wasmtime", "wasmtime-wasi", "waveflow-plugin-sdk", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/crates/app/src/backup.rs b/src-tauri/crates/app/src/backup.rs index e1f07f44..51913e9b 100644 --- a/src-tauri/crates/app/src/backup.rs +++ b/src-tauri/crates/app/src/backup.rs @@ -424,7 +424,7 @@ pub async fn run_one_backup( .await .map_err(|e| AppError::Other(format!("backup task join: {e}")))?; - match result { + let written = match result { Ok(()) => { tracing::info!(profile_id, target = %target.display(), "auto backup ok"); created.push(target.to_string_lossy().to_string()); @@ -435,17 +435,29 @@ pub async fn run_one_backup( if attempted_with_cache { bundle_metadata_artwork = false; } + true } Err(err) => { tracing::warn!(profile_id, ?err, "auto backup failed"); failed += 1; + false } - } + }; // Prune old archives for this profile. List `<safe>-*.waveflow` // in the folder, sort by mtime descending, keep the first // `retention` entries. - if let Err(err) = prune_old_backups(&folder, &safe, config.retention as usize).await { + // + // Only after an archive was written. Retention is "keep the + // newest N", and a pass that added nothing has no new archive + // to make room for: pruning anyway deleted the oldest good + // backup on every failed run, so a profile that kept failing + // was slowly left with none. + // + // Unfinished archives left by a crash are swept either way: a + // profile that keeps failing is exactly the one that leaves them. + let keep = written.then_some(config.retention as usize); + if let Err(err) = prune_old_backups(&folder, &safe, keep).await { tracing::warn!(?err, "auto backup retention sweep failed"); } @@ -475,10 +487,44 @@ pub async fn run_one_backup( }) } +/// How old an unfinished archive has to be before retention removes it. +/// Far longer than any backup takes to write, so it is never one in +/// progress — another pass can be running beside this one. +const STALE_PARTIAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// What a file in the backup folder is to the retention sweep of one +/// profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackupFile { + /// A finished archive of this profile: counted, oldest pruned. + Archive, + /// An archive of this profile still being written, or one whose + /// write never finished. Never counted as a backup. + Partial, + /// Anything else, left alone. + Other, +} + +fn classify_backup_file(name: &str, prefix: &str) -> BackupFile { + if !name.starts_with(prefix) { + return BackupFile::Other; + } + if name.ends_with(".waveflow") { + return BackupFile::Archive; + } + match name.strip_suffix(waveflow_core::atomic_write::PARTIAL_SUFFIX) { + Some(archive) if archive.ends_with(".waveflow") => BackupFile::Partial, + _ => BackupFile::Other, + } +} + +/// Sweep the backup folder for one profile: remove its stale unfinished +/// archives, and when `keep` is given, every finished archive past the +/// newest `keep`. async fn prune_old_backups( folder: &std::path::Path, name_prefix: &str, - keep: usize, + keep: Option<usize>, ) -> AppResult<()> { let prefix = format!("{name_prefix}-"); let folder = folder.to_path_buf(); @@ -494,22 +540,40 @@ async fn prune_old_backups( // nothing to prune. Not an error from the user's perspective. Err(_) => return Ok(0), }; + let now = std::time::SystemTime::now(); for entry in read_dir.flatten() { let path = entry.path(); - let Some(stem) = path - .file_name() - .and_then(|s| s.to_str()) - .filter(|s| s.starts_with(&prefix_owned) && s.ends_with(".waveflow")) - else { + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue; }; - let _ = stem; + let kind = classify_backup_file(name, &prefix_owned); + if kind == BackupFile::Other { + continue; + } let mtime = entry .metadata() .and_then(|m| m.modified()) .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + if kind == BackupFile::Partial { + // Never counted as a backup: it is an archive that did + // not finish. One whose writer is gone (a crash, a + // power cut) would otherwise sit in the folder for good, + // at the size of a whole archive. A partial still being + // written is touched by every write, so it never looks + // this old. + let stale = now + .duration_since(mtime) + .is_ok_and(|age| age > STALE_PARTIAL); + if stale && std::fs::remove_file(&path).is_ok() { + tracing::debug!(path = %path.display(), "removed an unfinished backup"); + } + continue; + } entries.push((path, mtime)); } + let Some(keep) = keep else { + return Ok(0); + }; entries.sort_by_key(|e| std::cmp::Reverse(e.1)); // newest first let mut removed = 0; for (path, _) in entries.into_iter().skip(keep) { @@ -662,3 +726,42 @@ pub fn spawn_backup_loop(handle: AppHandle, backup_handle: BackupHandle) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_finished_archive_of_the_profile_is_counted() { + assert_eq!( + classify_backup_file("Main-2026-10-19T10-00-00.waveflow", "Main-"), + BackupFile::Archive + ); + } + + /// An unfinished archive must never count toward retention: kept as + /// the newest, it pushed the oldest good backup out. + #[test] + fn an_unfinished_archive_is_not_a_backup() { + assert_eq!( + classify_backup_file("Main-2026-10-19T10-00-00.4242-1.waveflow.partial", "Main-"), + BackupFile::Partial + ); + } + + #[test] + fn other_files_are_left_alone() { + assert_eq!( + classify_backup_file("Other-2026-10-19T10-00-00.waveflow", "Main-"), + BackupFile::Other + ); + assert_eq!( + classify_backup_file("Main-notes.txt.partial", "Main-"), + BackupFile::Other + ); + assert_eq!( + classify_backup_file("Main-notes.txt", "Main-"), + BackupFile::Other + ); + } +} diff --git a/src-tauri/crates/app/src/commands/edit.rs b/src-tauri/crates/app/src/commands/edit.rs index 746dd3d8..5547fcac 100644 --- a/src-tauri/crates/app/src/commands/edit.rs +++ b/src-tauri/crates/app/src/commands/edit.rs @@ -130,6 +130,87 @@ fn validate_edit(edit: &TrackEdit) -> AppResult<()> { Ok(()) } +/// The file's stamp ([`waveflow_core::tagio::file_stamp`]) as the tag +/// editor opens, for it to hand back with its save. `None` when the file +/// cannot be reached, in which case the save is not checked against +/// anything and fails on its own when it tries to write. +#[tauri::command] +pub async fn get_track_file_stamp( + state: tauri::State<'_, AppState>, + track_id: i64, +) -> AppResult<Option<String>> { + let pool = state.require_profile_pool().await?; + let path: Option<String> = sqlx::query_scalar("SELECT file_path FROM track WHERE id = ?") + .bind(track_id) + .fetch_optional(&*pool) + .await?; + let path = path.ok_or_else(|| AppError::Other(format!("track {track_id} not found")))?; + tokio::task::spawn_blocking(move || { + waveflow_core::tagio::file_stamp(std::path::Path::new(&path)).ok() + }) + .await + .map_err(|e| AppError::Other(format!("file stamp join: {e}"))) +} + +/// Refuse to write over a file that changed since the editor read it. +/// +/// The dialog's form holds the values the library had when it opened, +/// and its save writes the title, the numbers and the year back whether +/// or not the user touched them — so if another tagger rewrote the file +/// meanwhile, saving would undo that program's change without a word. +fn ensure_unchanged(path: &std::path::Path, opened_at: &str) -> AppResult<()> { + if waveflow_core::tagio::file_stamp(path)? == opened_at { + Ok(()) + } else { + Err(AppError::FileChangedOnDisk) + } +} + +/// Re-read the folder holding `track_id`, so the library catches up with +/// a file another program changed — what the tag editor's "reload" +/// does after a save was refused by [`AppError::FileChangedOnDisk`]. +/// +/// The whole folder, through the ordinary scan, rather than a one-file +/// path of its own: the scanner is the only code that turns a file into +/// rows (albums, credits, genres, artwork), and its fast path skips every +/// file whose size and time have not moved, so the cost is a walk of the +/// folder plus the files that did change — the same scan the folder +/// watcher would have run. +#[tauri::command] +pub async fn reread_track_folder( + state: tauri::State<'_, AppState>, + app: AppHandle, + track_id: i64, +) -> AppResult<()> { + let pool = state.require_profile_pool().await?; + let profile_id = state.require_profile_id().await?; + let folder_id: Option<Option<i64>> = + sqlx::query_scalar("SELECT folder_id FROM track WHERE id = ?") + .bind(track_id) + .fetch_optional(&*pool) + .await?; + let folder_id = folder_id + .flatten() + .ok_or_else(|| AppError::Other(format!("track {track_id} is in no library folder")))?; + let artwork_dir = state.paths.profile_artwork_dir(profile_id); + crate::commands::scan::scan_folder_inner(&pool, &artwork_dir, folder_id, Some(&app), false) + .await?; + // Same follow-ups as a manual folder scan: ship the sync ops it + // queued, and let every view refetch. + state.drain.notify(); + let _ = app.emit("track:updated", track_id); + let _ = app.emit("library:rescanned", ()); + Ok(()) +} + +/// Write `edit` into the track's file, then bring the database in line. +/// +/// `file_stamp` is what [`get_track_file_stamp`] returned when the +/// editor opened. When given, the write is refused with +/// [`AppError::FileChangedOnDisk`] if the file no longer matches it. +/// Callers that send only the fields the user changed (the tag fetcher) +/// leave it out. Returns the file's stamp after the write, so the editor +/// can save again without being refused by its own change. #[tauri::command] pub async fn update_track_tags( state: tauri::State<'_, AppState>, @@ -137,7 +218,8 @@ pub async fn update_track_tags( app: AppHandle, track_id: i64, edit: TrackEdit, -) -> AppResult<()> { + file_stamp: Option<String>, +) -> AppResult<Option<String>> { validate_edit(&edit)?; let pool = state.require_profile_pool().await?; @@ -154,6 +236,15 @@ pub async fn update_track_tags( let row = row.ok_or_else(|| AppError::Other(format!("track {track_id} not found")))?; let path = std::path::PathBuf::from(&row.file_path); + // 1b. A save that will be refused should not pause the music first, + // so the stamp is checked before step 2 as well as at the write. + if let Some(opened_at) = file_stamp.clone() { + let path = path.clone(); + tokio::task::spawn_blocking(move || ensure_unchanged(&path, &opened_at)) + .await + .map_err(|e| AppError::Other(format!("file stamp join: {e}")))??; + } + // 2. If the engine is playing this track, pause before opening. // Releases the read handle that would otherwise refuse our write // open on Windows. Resume is the user's call — see module doc. @@ -176,13 +267,22 @@ pub async fn update_track_tags( // On a blocking thread, not here: this reaches the disk, and on the // network share the whole of #590 is about it can take seconds. An // async worker parked on it is one fewer for every other command. + // The stamp is checked again here, as close to the write as it gets: + // the pause above can take its time, and a tagger saving during it + // is exactly the case being guarded. { let path = path.clone(); let edit = edit.clone(); - tokio::task::spawn_blocking(move || write_tags_to_file(&path, &edit)) - .await - .map_err(|e| AppError::Other(format!("tag write join: {e}")))? - .map_err(|e| AppError::Other(format!("tag write failed: {e}")))?; + let file_stamp = file_stamp.clone(); + tokio::task::spawn_blocking(move || -> AppResult<()> { + if let Some(opened_at) = &file_stamp { + ensure_unchanged(&path, opened_at)?; + } + write_tags_to_file(&path, &edit) + .map_err(|e| AppError::Other(format!("tag write failed: {e}"))) + }) + .await + .map_err(|e| AppError::Other(format!("tag write join: {e}")))??; } // 3b. The file has changed on disk; recompute its hash so the @@ -211,7 +311,15 @@ pub async fn update_track_tags( let _ = app.emit("track:updated", track_id); let _ = app.emit("library:rescanned", ()); let _ = app.emit("player:queue-changed", ()); - Ok(()) + + // The file as this write left it, which is what the next save from + // the same dialog has to match. Best-effort: the tags are written + // and the database is in line, so failing the command here would + // report a save that happened as one that did not. + let stamp = tokio::task::spawn_blocking(move || waveflow_core::tagio::file_stamp(&path).ok()) + .await + .unwrap_or(None); + Ok(stamp) } /// Per-track result of a batch update. Surfaced to the frontend so it diff --git a/src-tauri/crates/app/src/commands/maintenance.rs b/src-tauri/crates/app/src/commands/maintenance.rs index dfdb9def..2f8c80f2 100644 --- a/src-tauri/crates/app/src/commands/maintenance.rs +++ b/src-tauri/crates/app/src/commands/maintenance.rs @@ -474,7 +474,6 @@ async fn safety_backup_all_profiles(state: &AppState) -> Option<PathBuf> { "pre-reset-{}-{profile_id}-{stamp}.waveflow", sanitize_filename(&profile_name) )); - let tmp_target = final_target.with_extension("part"); let snapshot_db = final_target.with_extension("dbtmp"); // 1. Snapshot the db with `VACUUM INTO` instead of raw-copying the @@ -511,16 +510,16 @@ async fn safety_backup_all_profiles(state: &AppState) -> Option<PathBuf> { exported_at: Utc::now().to_rfc3339(), }; - // 2. Archive the SNAPSHOT (not the live db) + artwork into a temp - // `.part` sibling, renamed to the final name only on success so - // a failed/interrupted write never leaves a truncated file that - // looks like a valid backup (same dir → atomic rename). + // 2. Archive the SNAPSHOT (not the live db) + artwork. + // `write_archive` goes through a `.partial` sibling renamed to + // the final name only on success, so a failed/interrupted write + // never leaves a truncated file that looks like a valid backup. let write_res = tokio::task::spawn_blocking({ - let tmp_target = tmp_target.clone(); + let final_target = final_target.clone(); let snapshot_db = snapshot_db.clone(); move || { write_archive( - &tmp_target, + &final_target, &profile_dir, &snapshot_db, &artwork_dir, @@ -535,22 +534,13 @@ async fn safety_backup_all_profiles(state: &AppState) -> Option<PathBuf> { let _ = std::fs::remove_file(&snapshot_db); match write_res { - Ok(Ok(())) => { - match std::fs::rename(&tmp_target, &final_target) { - Ok(()) => written += 1, - Err(err) => { - tracing::warn!(?err, profile_id, "pre-reset safety backup: could not finalize archive; discarding partial"); - let _ = std::fs::remove_file(&tmp_target); - } - } - } + Ok(Ok(())) => written += 1, Ok(Err(err)) => { tracing::warn!( ?err, profile_id, "pre-reset safety backup: archive failed for profile" ); - let _ = std::fs::remove_file(&tmp_target); } Err(err) => { tracing::warn!( @@ -558,7 +548,6 @@ async fn safety_backup_all_profiles(state: &AppState) -> Option<PathBuf> { profile_id, "pre-reset safety backup: task join failed for profile" ); - let _ = std::fs::remove_file(&tmp_target); } } } diff --git a/src-tauri/crates/app/src/commands/playlist.rs b/src-tauri/crates/app/src/commands/playlist.rs index 9b9fbc4e..fd115acf 100644 --- a/src-tauri/crates/app/src/commands/playlist.rs +++ b/src-tauri/crates/app/src/commands/playlist.rs @@ -825,14 +825,21 @@ pub async fn export_playlist_m3u( out.push('\n'); } + // Through a partial file renamed into place, so overwriting an + // existing playlist file that fails halfway (a full disk, a share + // that drops) leaves the old one intact rather than truncated. On + // the blocking pool: the destination is wherever the user picked, + // network shares included, and the parent may need creating. let dest = std::path::PathBuf::from(&dest_path); - if let Some(parent) = dest.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent) - .map_err(|e| AppError::Other(format!("create parent dir: {e}")))?; - } + { + let dest = dest.clone(); + tokio::task::spawn_blocking(move || { + waveflow_core::atomic_write::write_bytes(&dest, out.as_bytes()) + }) + .await + .map_err(|e| AppError::Other(format!("write m3u join: {e}")))? + .map_err(|e| AppError::Other(format!("write m3u file: {e}")))?; } - std::fs::write(&dest, out).map_err(|e| AppError::Other(format!("write m3u file: {e}")))?; tracing::info!( playlist_id, diff --git a/src-tauri/crates/app/src/commands/profile_io.rs b/src-tauri/crates/app/src/commands/profile_io.rs index 984ba15e..ffd5f7a6 100644 --- a/src-tauri/crates/app/src/commands/profile_io.rs +++ b/src-tauri/crates/app/src/commands/profile_io.rs @@ -353,6 +353,12 @@ async fn cleanup_partial_profile(state: &AppState, profile_id: i64) { // is bundled under `metadata_artwork/**` so a restore on another machine // doesn't have to re-fetch every cover. When `None`, the archive stays // lean and the cache will be rebuilt lazily after restore. +// +// Written to `<target>.partial` and renamed over `target` only once the +// archive is complete. Writing in place truncated the destination first, +// so an export over an existing archive that failed halfway destroyed +// the copy the user already had, and an automatic backup cut short left +// an archive-shaped file that retention then kept as the newest backup. pub(crate) fn write_archive( target: &Path, profile_dir: &Path, @@ -361,10 +367,26 @@ pub(crate) fn write_archive( metadata_artwork_dir: Option<&Path>, manifest: &ArchiveManifest, ) -> AppResult<()> { - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - let file = File::create(target)?; + waveflow_core::atomic_write::write_with(target, |file| { + write_archive_into( + file, + profile_dir, + db_path, + artwork_dir, + metadata_artwork_dir, + manifest, + ) + }) +} + +fn write_archive_into( + file: &mut File, + profile_dir: &Path, + db_path: &Path, + artwork_dir: &Path, + metadata_artwork_dir: Option<&Path>, + manifest: &ArchiveManifest, +) -> AppResult<()> { let mut zip = ZipWriter::new(file); let opts: SimpleFileOptions = SimpleFileOptions::default() diff --git a/src-tauri/crates/app/src/error.rs b/src-tauri/crates/app/src/error.rs index ffacc3ff..202ce8ba 100644 --- a/src-tauri/crates/app/src/error.rs +++ b/src-tauri/crates/app/src/error.rs @@ -112,6 +112,15 @@ pub enum AppError { #[error("zip error: {0}")] Zip(#[from] zip::result::ZipError), + /// The tag editor's save found the file no longer the one its dialog + /// read: another program wrote it in the meantime. Refused rather + /// than overwriting that change with the values the dialog showed. + /// The frontend recognises this message (`FILE_CHANGED_ON_DISK` in + /// `src/lib/tauri/track.ts`) and offers to reload, so the wording is + /// part of the contract. + #[error("the file changed on disk since the editor opened it")] + FileChangedOnDisk, + #[error("{0}")] Other(String), } diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index 0fe02584..5fdce98f 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -883,6 +883,8 @@ pub fn run() { commands::track::search_tracks_advanced, commands::edit::update_track_tags, commands::edit::update_tracks_batch, + commands::edit::get_track_file_stamp, + commands::edit::reread_track_folder, commands::edit::update_track_cover, commands::track::toggle_like_track, commands::track::list_liked_track_ids, diff --git a/src-tauri/crates/app/src/watcher.rs b/src-tauri/crates/app/src/watcher.rs index e8ebc6d2..421fab95 100644 --- a/src-tauri/crates/app/src/watcher.rs +++ b/src-tauri/crates/app/src/watcher.rs @@ -18,11 +18,17 @@ //! shutdown channel signals the debounce task to exit. //! - Profile switches must call [`WatcherManager::unwatch_all`] then //! [`WatcherManager::restore_from_db`] with the new pool. +//! - A folder that cannot be armed — usually a drive that is not plugged +//! in yet — is not given up on: [`WatcherManager::watch_or_wait`] +//! keeps a placeholder for it and tries again every +//! [`REARM_INTERVAL`], then scans it once it is back. The placeholder +//! is dropped exactly like a watcher, so unwatching or switching +//! profile stops the retries too. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde::Serialize; @@ -40,6 +46,17 @@ use crate::state::AppState; /// events as the OS finalizes each file) yields a single scan. const DEBOUNCE: Duration = Duration::from_millis(1500); +/// How often a watched folder that could not be armed is tried again. +/// A drive plugged in after launch is picked up within this long; +/// shorter would only spend more stat calls on a drive that is absent +/// for days. +const REARM_INTERVAL: Duration = Duration::from_secs(60); + +/// Shortest gap between two logged errors from one folder's watcher. An +/// error can repeat for every event the backend fails to deliver, and a +/// log flooded with the same line hides everything else in it. +const ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60); + /// Tauri event fired after a watcher-driven rescan completes. The /// frontend should refetch `list_libraries` (which feeds the /// "library.updated_at" reactivity used by the views). @@ -55,13 +72,15 @@ struct LibraryRescannedPayload { } /// Per-folder state held inside [`WatcherManager`]. Dropping this -/// struct stops both the OS-level watcher and the debounce task. +/// struct stops both the OS-level watcher and the debounce task — or, +/// for a folder still waiting to be armed, its retry task. struct FolderHandle { /// Held only so the underlying notify thread stays alive — never - /// read after construction. - _watcher: RecommendedWatcher, - /// Signals the debounce task to exit. Sender is dropped on - /// removal; the task's `recv()` returns `Err(_)` and bails. + /// read after construction. `None` while the folder is waiting to + /// be armed (see [`WatcherManager::watch_or_wait`]). + _watcher: Option<RecommendedWatcher>, + /// Signals the debounce task (or the retry task) to exit. Sender is + /// dropped on removal; the task's receiver resolves and it bails. _shutdown: oneshot::Sender<()>, } @@ -83,57 +102,7 @@ impl WatcherManager { /// Start watching `path` for `folder_id`. If a watcher already /// existed for this id (e.g. after a path edit) it's dropped first. pub fn watch(&self, folder_id: i64, library_id: i64, path: PathBuf) -> AppResult<()> { - if !path.is_dir() { - return Err(AppError::Other(format!( - "watch path is not a directory: {}", - path.display() - ))); - } - - // Bridge the sync notify callback into an async tokio channel. - // Bounded would risk dropping events under load — unbounded is - // fine because the debounce stage collapses bursts to a single - // scan anyway. - let (event_tx, event_rx) = mpsc::unbounded_channel::<()>(); - let event_tx_for_cb = event_tx.clone(); - let watcher_path = path.clone(); - let reserved_root = path.clone(); - - let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| { - let Ok(event) = res else { return }; - if !is_relevant_event(&event) { - return; - } - if is_our_own_media(&reserved_root, &event) { - return; - } - // Best-effort send — the debounce task may be tearing down. - let _ = event_tx_for_cb.send(()); - }) - .map_err(|e| AppError::Other(format!("notify watcher init: {e}")))?; - - watcher - .watch(&watcher_path, RecursiveMode::Recursive) - .map_err(|e| { - AppError::Other(format!("notify watch {}: {e}", watcher_path.display())) - })?; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let app = self.app.clone(); - tauri::async_runtime::spawn(debounce_loop( - event_rx, - shutdown_rx, - app, - folder_id, - library_id, - event_tx, - )); - - let handle = FolderHandle { - _watcher: watcher, - _shutdown: shutdown_tx, - }; - + let (handle, _scan) = arm(&self.app, folder_id, library_id, &path)?; let mut map = self.inner.lock().expect("watcher map poisoned"); // Replacing drops the previous handle, which stops its watcher // and signals its debounce task — exactly what we want when @@ -143,6 +112,46 @@ impl WatcherManager { Ok(()) } + /// [`Self::watch`], and when the folder cannot be armed, keep trying. + /// + /// For the callers that act on the user's standing choice rather + /// than on a click: the folders restored at launch and the toggle. + /// The usual reason a watched folder cannot be armed is that it is + /// not there yet — an external drive plugged in after launch, a + /// share that mounts late — and giving up on it then meant it went + /// unwatched until the next restart, with nothing to say so. + pub fn watch_or_wait(&self, folder_id: i64, library_id: i64, path: PathBuf) { + let Err(err) = self.watch(folder_id, library_id, path.clone()) else { + return; + }; + tracing::warn!( + folder_id, + path = %path.display(), + %err, + "folder watcher not armed; trying again every {}s", + REARM_INTERVAL.as_secs() + ); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + // In the map like an armed watcher, so `unwatch`, `unwatch_all` + // and a later `watch` of the same folder all end the retries the + // same way they stop a watcher: by dropping this handle. + self.inner.lock().expect("watcher map poisoned").insert( + folder_id, + FolderHandle { + _watcher: None, + _shutdown: shutdown_tx, + }, + ); + tauri::async_runtime::spawn(rearm_loop( + self.app.clone(), + Arc::clone(&self.inner), + shutdown_rx, + folder_id, + library_id, + path, + )); + } + /// Stop watching `folder_id`. No-op when the folder isn't watched. pub fn unwatch(&self, folder_id: i64) { let mut map = self.inner.lock().expect("watcher map poisoned"); @@ -162,9 +171,10 @@ impl WatcherManager { } /// Boot-time hydration: query every `is_watched=1` folder in the - /// active profile and arm a watcher for each one. Failures are - /// logged per-folder so a missing path on one folder doesn't - /// disable watching everywhere. + /// active profile and arm a watcher for each one. A folder that + /// cannot be armed is logged and retried on its own (see + /// [`Self::watch_or_wait`]), so a missing path on one folder neither + /// disables watching everywhere nor stays unwatched for the session. pub async fn restore_from_db(&self, pool: &SqlitePool) -> AppResult<()> { let rows: Vec<(i64, i64, String)> = sqlx::query_as("SELECT id, library_id, path FROM library_folder WHERE is_watched = 1") @@ -172,22 +182,201 @@ impl WatcherManager { .await?; for (folder_id, library_id, path) in rows { - if let Err(err) = self.watch(folder_id, library_id, PathBuf::from(&path)) { - tracing::warn!(folder_id, path, %err, "failed to restore watcher"); - } + self.watch_or_wait(folder_id, library_id, PathBuf::from(&path)); } Ok(()) } } +/// Build the OS watcher for `path` and the debounce task behind it. +/// +/// Returns the handle to keep in the map, and a sender that asks the +/// debounce task for a scan exactly as an event would. +fn arm( + app: &AppHandle, + folder_id: i64, + library_id: i64, + path: &Path, +) -> AppResult<(FolderHandle, UnboundedSender<()>)> { + if !path.is_dir() { + return Err(AppError::Other(format!( + "watch path is not a directory: {}", + path.display() + ))); + } + + // Bridge the sync notify callback into an async tokio channel. + // Bounded would risk dropping events under load — unbounded is + // fine because the debounce stage collapses bursts to a single + // scan anyway. + let (event_tx, event_rx) = mpsc::unbounded_channel::<()>(); + let event_tx_for_cb = event_tx.clone(); + let reserved_root = path.to_path_buf(); + let mut errors = ErrorThrottle::default(); + + let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| { + let event = match res { + Ok(event) => event, + Err(err) => { + // Logged, not acted on: an error does not say which + // changes it cost, and lost events are reported as a + // rescan event instead (see `is_relevant_event`). + if let Some(suppressed) = errors.admit(Instant::now()) { + tracing::warn!(folder_id, %err, suppressed, "folder watcher error"); + } + return; + } + }; + if !is_relevant_event(&event) { + return; + } + if is_our_own_media(&reserved_root, &event) { + return; + } + // Best-effort send — the debounce task may be tearing down. + let _ = event_tx_for_cb.send(()); + }) + .map_err(|e| AppError::Other(format!("notify watcher init: {e}")))?; + + watcher + .watch(path, RecursiveMode::Recursive) + .map_err(|e| AppError::Other(format!("notify watch {}: {e}", path.display())))?; + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + tauri::async_runtime::spawn(debounce_loop( + event_rx, + shutdown_rx, + app.clone(), + folder_id, + library_id, + event_tx.clone(), + )); + + Ok(( + FolderHandle { + _watcher: Some(watcher), + _shutdown: shutdown_tx, + }, + event_tx, + )) +} + +/// Retry task for a watched folder that could not be armed. +/// +/// Every [`REARM_INTERVAL`] it looks for the folder; once it is there +/// and armed, the watcher takes the placeholder's place in the map and +/// one scan is asked for. Changes made while the folder was away — the +/// whole drive's worth, if it comes back with new music on it — never +/// produced an event, so without that scan they would wait for the next +/// one. +/// +/// Ends when its placeholder is dropped: the folder was unwatched or +/// removed, the profile switched, or a later `watch` replaced it. +async fn rearm_loop( + app: AppHandle, + inner: Arc<Mutex<HashMap<i64, FolderHandle>>>, + mut shutdown_rx: oneshot::Receiver<()>, + folder_id: i64, + library_id: i64, + path: PathBuf, +) { + loop { + tokio::select! { + biased; + _ = &mut shutdown_rx => return, + _ = tokio::time::sleep(REARM_INTERVAL) => {} + } + // Through tokio's blocking pool: a share that went away can take + // the filesystem's whole timeout to answer a stat. + let present = tokio::fs::metadata(&path) + .await + .is_ok_and(|meta| meta.is_dir()); + if !present { + continue; + } + // On the blocking pool too: arming stats the folder and registers + // a recursive watch, one per directory under inotify, which on a + // large collection is not a runtime worker's job. + let armed = { + let app = app.clone(); + let path = path.clone(); + tokio::task::spawn_blocking(move || arm(&app, folder_id, library_id, &path)).await + }; + let (handle, scan) = match armed { + Ok(Ok(armed)) => armed, + Ok(Err(err)) => { + tracing::debug!(folder_id, %err, "folder is back but could not be armed yet"); + continue; + } + Err(err) => { + tracing::warn!(folder_id, %err, "arming the folder watcher panicked"); + continue; + } + }; + { + let mut map = inner.lock().expect("watcher map poisoned"); + // Only if the placeholder is still ours. Every removal from + // the map happens under this lock and drops the sender with + // it, so a closed channel here means the folder was + // unwatched or replaced meanwhile — and the watcher just + // built is dropped rather than resurrecting it, possibly + // under another profile's folder of the same id. + if !matches!( + shutdown_rx.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + ) { + return; + } + map.insert(folder_id, handle); + } + tracing::info!(folder_id, library_id, path = %path.display(), "watched folder is back; watching it again"); + let _ = scan.send(()); + return; + } +} + /// Filter notify events down to the ones that can change the library. /// We ignore Access (read-only, never mutates content) and /// AccessKind metadata-only changes. +/// +/// Plus the backend's own "events were lost, look again" signal: an +/// inotify queue that overflowed, an FSEvents stream that says a +/// subtree must be rescanned. It comes as `EventKind::Other` with the +/// rescan flag, usually naming no path, and it is precisely what a large +/// copy into the folder produces — dropping it meant that copy was never +/// picked up. It asks for the same debounced scan of the whole folder +/// any other event does. fn is_relevant_event(event: &Event) -> bool { - matches!( - event.kind, - EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) - ) + event.need_rescan() + || matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) +} + +/// Rate limit for the errors one folder's watcher reports. +#[derive(Default)] +struct ErrorThrottle { + last_logged: Option<Instant>, + suppressed: u64, +} + +impl ErrorThrottle { + /// `Some(n)` when the error arriving at `now` should be logged, `n` + /// being how many were swallowed since the last one that was, so + /// the log still says how often it happened. + fn admit(&mut self, now: Instant) -> Option<u64> { + match self.last_logged { + Some(last) if now.duration_since(last) < ERROR_LOG_INTERVAL => { + self.suppressed += 1; + None + } + _ => { + self.last_logged = Some(now); + Some(std::mem::take(&mut self.suppressed)) + } + } + } } /// Whether every path this event names is media WaveFlow itself keeps in @@ -336,11 +525,10 @@ pub async fn apply_toggle( "library_folder {folder_id} not found" ))); }; - if let Err(err) = manager.watch(folder_id, library_id, PathBuf::from(&path)) { - // Surface the error but the DB flag below is still - // updated so the UI reflects the user's intent. - tracing::warn!(folder_id, path, %err, "watch start failed"); - } + // A folder that cannot be armed right now is logged and retried + // until it can; the DB flag below is still updated so the UI + // reflects the user's intent. + manager.watch_or_wait(folder_id, library_id, PathBuf::from(&path)); } else { manager.unwatch(folder_id); } @@ -356,7 +544,7 @@ pub async fn apply_toggle( #[cfg(test)] mod tests { use super::*; - use notify::event::CreateKind; + use notify::event::{AccessKind, CreateKind, Flag}; fn event(paths: &[&str]) -> Event { let mut event = Event::new(EventKind::Create(CreateKind::Any)); @@ -393,4 +581,40 @@ mod tests { fn an_event_without_paths_is_kept() { assert!(!is_our_own_media(Path::new("/music"), &event(&[]))); } + + /// The backend saying it lost events (inotify overflow, FSEvents + /// must-scan) is the one event that most needs a scan: it is what a + /// large copy produces. + #[test] + fn a_lost_events_signal_asks_for_a_scan() { + let rescan = Event::new(EventKind::Other).set_flag(Flag::Rescan); + assert!(is_relevant_event(&rescan)); + assert!(!is_our_own_media(Path::new("/music"), &rescan)); + } + + #[test] + fn reads_and_unflagged_others_do_not() { + assert!(!is_relevant_event(&Event::new(EventKind::Access( + AccessKind::Any + )))); + assert!(!is_relevant_event(&Event::new(EventKind::Other))); + assert!(is_relevant_event(&event(&["/music/a.flac"]))); + } + + #[test] + fn errors_are_logged_once_a_minute_with_a_count() { + let mut throttle = ErrorThrottle::default(); + let start = Instant::now(); + assert_eq!(throttle.admit(start), Some(0)); + assert_eq!(throttle.admit(start + Duration::from_secs(1)), None); + assert_eq!(throttle.admit(start + Duration::from_secs(30)), None); + assert_eq!( + throttle.admit(start + ERROR_LOG_INTERVAL + Duration::from_secs(1)), + Some(2) + ); + assert_eq!( + throttle.admit(start + ERROR_LOG_INTERVAL + Duration::from_secs(2)), + None + ); + } } diff --git a/src-tauri/crates/core/Cargo.toml b/src-tauri/crates/core/Cargo.toml index c5c9d1ee..12210ee0 100644 --- a/src-tauri/crates/core/Cargo.toml +++ b/src-tauri/crates/core/Cargo.toml @@ -223,6 +223,12 @@ wasmtime-wasi = { version = "49", default-features = false, optional = true } pinyin = { version = "0.11.0", default-features = false, features = ["plain"] } unicode-normalization = { version = "0.1.25", optional = true } +# `ReplaceFileW`, so an atomic write keeps the replaced file's ACL the way +# rewriting it in place did (`atomic_write`). Already in the lockfile +# through other crates. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } + [dev-dependencies] # Sandboxed scratch directories for the smart-playlist cover render # tests. Same usage shape as the app crate's existing dev-dep. diff --git a/src-tauri/crates/core/src/atomic_write.rs b/src-tauri/crates/core/src/atomic_write.rs new file mode 100644 index 00000000..37223989 --- /dev/null +++ b/src-tauri/crates/core/src/atomic_write.rs @@ -0,0 +1,431 @@ +//! Replace a file only once its new content is complete. +//! +//! For files WaveFlow writes whole — a profile archive, a playlist +//! export — where the previous copy is worth more than a half-written +//! new one. Writing straight to the destination truncates it first, so +//! a full disk, a share that drops or a crash halfway leaves the user +//! with neither: an export over last week's archive destroys last +//! week's archive, and an automatic backup cut short sits in the backup +//! folder looking like the newest good one. +//! +//! Here the bytes go to a partial file beside the destination, reach +//! the disk, and only then replace it in a single `rename`. Any failure +//! removes the partial and leaves the destination as it was. Each write +//! gets a partial of its own, created exclusively: two writes aimed at +//! the same destination (an export run while a backup writes there) +//! would otherwise truncate each other's half-written file. +//! +//! Not for audio files: those go through [`crate::tagio`], which has to +//! keep the original's inode where it can and copies it before writing. + +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Appended to the destination's file name while it is being written. +/// Anything that lists a folder for finished files (the backup +/// retention, for one) must not take a name ending in it as one. +pub const PARTIAL_SUFFIX: &str = ".partial"; + +/// The partial for one write of `target`: `<stem>.<token>.<extension>` +/// followed by [`PARTIAL_SUFFIX`], or `<name>.<token>` and the suffix for +/// a name without an extension, in the destination's directory. +/// +/// The extension stays last before the suffix so a folder listing can +/// still tell what the partial would have become: the backup retention +/// knows `….waveflow.partial` as an unfinished archive. Beside the +/// destination, never in the system temp directory: `rename` is only +/// atomic within a filesystem, and an export folder is routinely on a +/// different one than `/tmp`. +pub fn partial_path(target: &Path, token: &str) -> PathBuf { + sibling_path(target, token, PARTIAL_SUFFIX) +} + +/// Where the file a Windows replace moves out of the way goes for the +/// length of the swap: like [`partial_path`], but with this suffix, which +/// nothing sweeps. Should the swap fail in a way that leaves the old file +/// there, it may be the only copy of it, and it must not be taken for an +/// unfinished write and deleted a day later. +#[cfg(any(windows, test))] +pub const REPLACED_SUFFIX: &str = ".replaced"; + +/// `<stem>.<token>.<extension><suffix>`, or `<name>.<token><suffix>` for +/// a name without an extension, in the target's directory. +fn sibling_path(target: &Path, token: &str, suffix: &str) -> PathBuf { + let mut name = OsString::new(); + match (target.file_stem(), target.extension()) { + (Some(stem), Some(extension)) => { + name.push(stem); + name.push("."); + name.push(token); + name.push("."); + name.push(extension); + } + _ => { + name.push(target.file_name().unwrap_or_default()); + name.push("."); + name.push(token); + } + } + name.push(suffix); + target.with_file_name(name) +} + +/// A token no other write of this process, or of another process, uses: +/// the process id and a counter. +fn next_token() -> String { + static NEXT: AtomicU64 = AtomicU64::new(0); + format!( + "{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ) +} + +/// Create a partial no one else holds. A name already taken (a leftover +/// from a crashed process that had the same id) moves on to the next +/// token rather than writing over it. +fn create_partial(target: &Path) -> io::Result<(PathBuf, File)> { + let mut last_err = None; + for _ in 0..16 { + let path = partial_path(target, &next_token()); + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => return Ok((path, file)), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => last_err = Some(err), + Err(err) => return Err(err), + } + } + Err(last_err.unwrap_or_else(|| io::Error::other("no free partial name"))) +} + +/// Removes the partial unless it was renamed into place, so every early +/// return between creating it and committing it cleans up after itself. +struct Partial { + path: PathBuf, + committed: bool, +} + +impl Drop for Partial { + fn drop(&mut self) { + if !self.committed { + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// Write `target` through a sibling partial file. +/// +/// `body` writes the complete new content into the handle it is given +/// (opened for reading and writing, empty, positioned at the start). The +/// content is flushed to the disk, then renamed over `target`. When +/// `body` or any step after it fails, `target` is untouched — still +/// absent, or still holding what it held before — and the partial is +/// gone. +/// +/// The parent directory is created if missing. Blocking: call it from a +/// blocking thread, never an async worker. +pub fn write_with<T, E>(target: &Path, body: impl FnOnce(&mut File) -> Result<T, E>) -> Result<T, E> +where + E: From<io::Error>, +{ + let directory = target + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(directory).map_err(E::from)?; + + let (path, mut file) = create_partial(target).map_err(E::from)?; + let mut partial = Partial { + path, + committed: false, + }; + // Replacing a file must not change who can read it: an archive the + // user made private would otherwise come back with default rights + // after the rename. So the destination's mode goes on the partial + // before a byte is written, and the content is never more exposed + // than the file it replaces. A new destination keeps the defaults a + // plain create would have given it. Unix only: on Windows the partial + // takes the folder's inherited ACL, as the destination did, and the + // one mode bit there (read-only) would stop the write itself. + #[cfg(unix)] + if let Ok(meta) = std::fs::metadata(target) { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + // Group bits only mean the same thing for the same group: a `640` + // archive of the "music" group, copied onto a partial owned by the + // user's own group, would let that other group read it. So the + // partial takes the destination's group first, and when that is + // refused (a group the user is not in), it keeps the owner's bits + // alone. + let same_group = std::os::unix::fs::fchown(&file, None, Some(meta.gid())).is_ok(); + // Permission and special bits only, not the file type. + let mode = meta.permissions().mode() & 0o7777; + let mode = if same_group { mode } else { mode & 0o700 }; + file.set_permissions(std::fs::Permissions::from_mode(mode)) + .map_err(E::from)?; + } + let value = { + let value = body(&mut file)?; + file.flush().map_err(E::from)?; + // Before the rename, not after: once renamed, the file is the + // one the user will restore from, and a rename that survives a + // power cut over content that did not would be the very archive + // this exists to prevent. + file.sync_all().map_err(E::from)?; + // Closed before the rename: Windows refuses to rename a file that + // is still open. + drop(file); + value + }; + + replace(&partial.path, target).map_err(E::from)?; + partial.committed = true; + // Durability on top of a replacement that has already happened, so + // its failure is not reported as a failed write. + let _ = sync_directory(directory); + Ok(value) +} + +/// Move the finished partial over `target`. +/// +/// On Windows an existing destination is replaced with `ReplaceFileW`, +/// which keeps its ACL and attributes the way rewriting it in place did. +/// A plain rename would leave the partial's inherited ACL in their place, +/// and an explicit one the user set would be gone. When they cannot be +/// kept, the write fails rather than replacing the file without them. A +/// destination that does not exist is renamed into place. +/// +/// The replaced file goes to a backup name of ours during the swap +/// ([`REPLACED_SUFFIX`]). On one failure, +/// `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`, Windows has already moved it +/// there without putting the new file in its place: it is moved back, and +/// if even that fails the error says where it is, so neither copy is lost +/// to the cleanup. Nothing sweeps that name, since it may then hold the +/// only copy of the old file. +#[cfg(windows)] +fn replace(partial: &Path, target: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, + }; + use windows_sys::Win32::Storage::FileSystem::ReplaceFileW; + + if std::fs::symlink_metadata(target).is_err() { + return std::fs::rename(partial, target); + } + let wide = |path: &Path| -> Vec<u16> { + path.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() + }; + let backup = sibling_path(target, &next_token(), REPLACED_SUFFIX); + let replaced = wide(target); + let replacement = wide(partial); + let backup_name = wide(&backup); + // SAFETY: the three paths are NUL-terminated UTF-16 buffers that + // outlive the call; the two reserved pointers are null and the flags + // 0, as the API documents. + let ok = unsafe { + ReplaceFileW( + replaced.as_ptr(), + replacement.as_ptr(), + backup_name.as_ptr(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + if ok != 0 { + let _ = std::fs::remove_file(&backup); + return Ok(()); + } + let err = io::Error::last_os_error(); + match err.raw_os_error().map(|code| code as u32) { + // The destination went away between the check and the call. + Some(ERROR_FILE_NOT_FOUND) => std::fs::rename(partial, target), + // The old file is at `backup` and the new one is not in place. + Some(ERROR_UNABLE_TO_MOVE_REPLACEMENT_2) => match std::fs::rename(&backup, target) { + Ok(()) => Err(err), + Err(_) => Err(io::Error::other(format!( + "{err}; the previous file is at {}", + backup.display() + ))), + }, + // Nothing was moved; a backup is only made once the swap starts. + _ => { + let _ = std::fs::remove_file(&backup); + Err(err) + } + } +} + +#[cfg(not(windows))] +fn replace(partial: &Path, target: &Path) -> io::Result<()> { + std::fs::rename(partial, target) +} + +/// [`write_with`] for content already in memory. +pub fn write_bytes(target: &Path, bytes: &[u8]) -> io::Result<()> { + write_with(target, |file| file.write_all(bytes)) +} + +/// Make the rename durable on Unix, where it is a separate write to the +/// directory that a crash can lose. Windows has no directory handle to +/// sync, and opening one fails there, so this is Unix-only. +fn sync_directory(directory: &Path) -> io::Result<()> { + #[cfg(unix)] + { + File::open(directory)?.sync_all() + } + #[cfg(not(unix))] + { + let _ = directory; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Partials, and a Windows replace's backups, left in `dir`. + fn partials(dir: &Path) -> usize { + std::fs::read_dir(dir) + .unwrap() + .flatten() + .filter(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + name.ends_with(PARTIAL_SUFFIX) || name.ends_with(REPLACED_SUFFIX) + }) + .count() + } + + #[test] + fn the_partial_sits_beside_the_target_and_keeps_its_extension_last() { + assert_eq!( + partial_path(Path::new("/backups/Main-2026.waveflow"), "7-1"), + Path::new("/backups/Main-2026.7-1.waveflow.partial") + ); + assert_eq!( + partial_path(Path::new("/exports/list"), "7-2"), + Path::new("/exports/list.7-2.partial") + ); + } + + /// A Windows replace's backup must not be swept as an unfinished + /// write: it can hold the only copy of the old file. + #[test] + fn the_replaced_backup_is_not_a_partial() { + let backup = sibling_path( + Path::new("/backups/Main-2026.waveflow"), + "7-3", + REPLACED_SUFFIX, + ); + assert_eq!( + backup, + Path::new("/backups/Main-2026.7-3.waveflow.replaced") + ); + assert!(!backup.to_string_lossy().ends_with(PARTIAL_SUFFIX)); + } + + #[test] + fn every_write_gets_its_own_partial() { + assert_ne!(next_token(), next_token()); + } + + #[test] + fn a_complete_write_replaces_the_target_and_leaves_nothing_behind() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("list.m3u8"); + std::fs::write(&target, b"old").unwrap(); + + write_bytes(&target, b"new").unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(partials(dir.path()), 0); + } + + /// The case this exists for: a write that fails halfway must not + /// have truncated the copy the user already had. + #[test] + fn a_failed_write_keeps_the_previous_content() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("archive.waveflow"); + std::fs::write(&target, b"last week's archive").unwrap(); + + let result: io::Result<()> = write_with(&target, |file| { + file.write_all(b"half of a new one")?; + Err(io::Error::other("disk full")) + }); + + assert!(result.is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"last week's archive"); + assert_eq!(partials(dir.path()), 0); + } + + /// And with nothing there before, a failure leaves nothing at all + /// -- not an archive-shaped file a listing would take for a backup. + #[test] + fn a_failed_first_write_leaves_no_target() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("archive.waveflow"); + + let result: io::Result<()> = write_with(&target, |file| { + file.write_all(b"partial")?; + Err(io::Error::other("cancelled")) + }); + + assert!(result.is_err()); + assert!(!target.exists()); + assert_eq!(partials(dir.path()), 0); + } + + /// A partial someone else holds is neither truncated nor removed: it + /// may be another write in progress. + #[test] + fn another_partial_of_the_same_target_is_left_alone() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("list.m3u8"); + let other = partial_path(&target, "1-999"); + std::fs::write(&other, b"someone else's write").unwrap(); + + write_bytes(&target, b"fresh").unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"fresh"); + assert_eq!(std::fs::read(&other).unwrap(), b"someone else's write"); + } + + /// A replaced file keeps the permissions it had, so a private archive + /// does not come back readable by everyone. + #[cfg(unix)] + #[test] + fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("private.waveflow"); + std::fs::write(&target, b"old").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + + write_bytes(&target, b"new").unwrap(); + + let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn a_missing_parent_directory_is_created() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("new").join("folder").join("list.m3u8"); + + write_bytes(&target, b"x").unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"x"); + } +} diff --git a/src-tauri/crates/core/src/lib.rs b/src-tauri/crates/core/src/lib.rs index 5528f8d0..63068062 100644 --- a/src-tauri/crates/core/src/lib.rs +++ b/src-tauri/crates/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod album_playback; pub mod analysis; pub mod artwork; +pub mod atomic_write; pub mod audio_format; pub mod domain; pub mod error; diff --git a/src-tauri/crates/core/src/tagio.rs b/src-tauri/crates/core/src/tagio.rs index 2006a940..2db286b5 100644 --- a/src-tauri/crates/core/src/tagio.rs +++ b/src-tauri/crates/core/src/tagio.rs @@ -391,6 +391,79 @@ fn rename_over(from: &Path, to: &Path) -> io::Result<()> { } } +/// A cheap fingerprint of `path` as it is on disk: its size and its +/// modification time, to the precision the filesystem keeps. +/// +/// What the tag editor reads when it opens and checks again before it +/// writes. Its form shows the values the library holds, and a save +/// writes them all back, so a file another tagger rewrote in between +/// would quietly lose that program's change. The pair is the one the +/// scanner's fast path trusts to say a file has not changed, and it +/// costs a `stat` rather than a read of the file. +/// +/// Opaque on purpose: it is only ever compared for equality, and a +/// string carries nanoseconds intact through JSON, where a number would +/// be rounded to what a double holds. +/// +/// What it does not catch: a rewrite that keeps both the size and the +/// modification time, which a tagger has to do on purpose (restoring the +/// time after a same-size edit). Comparing content would catch it, at the +/// cost of reading the whole file twice per save; a dialog that guards +/// against an ordinary second tagger does not need that. +pub fn file_stamp(path: &Path) -> io::Result<String> { + let meta = std::fs::metadata(path)?; + let modified = meta.modified()?; + let nanos = match modified.duration_since(std::time::UNIX_EPOCH) { + Ok(after) => after.as_nanos() as i128, + // A file dated before 1970 is unusual, not invalid. + Err(before) => -(before.duration().as_nanos() as i128), + }; + Ok(format!("{}:{nanos}", meta.len())) +} + +#[cfg(test)] +mod stamp_tests { + use super::*; + + #[test] + fn the_stamp_holds_while_the_file_does_not_change() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.flac"); + std::fs::write(&path, b"fLaC").unwrap(); + assert_eq!(file_stamp(&path).unwrap(), file_stamp(&path).unwrap()); + } + + #[test] + fn a_rewrite_changes_the_stamp() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.flac"); + std::fs::write(&path, b"fLaC").unwrap(); + let before = file_stamp(&path).unwrap(); + std::fs::write(&path, b"fLaC with a longer tag").unwrap(); + assert_ne!(file_stamp(&path).unwrap(), before); + } + + /// Same size, new content: what a tagger editing one field in the + /// tag's padding produces. Only the time can tell it apart. + #[test] + fn a_same_size_rewrite_changes_it_through_the_time() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a.flac"); + std::fs::write(&path, b"fLaC").unwrap(); + let before = file_stamp(&path).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + let later = std::fs::metadata(&path).unwrap().modified().unwrap() + Duration::from_secs(5); + file.set_modified(later).unwrap(); + assert_ne!(file_stamp(&path).unwrap(), before); + } + + #[test] + fn a_missing_file_has_no_stamp() { + let dir = tempfile::tempdir().unwrap(); + assert!(file_stamp(&dir.path().join("gone.flac")).is_err()); + } +} + /// The ID3v2 tag already on disk, as a span of bytes at the head of the /// file (#590). #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/components/common/TrackPropertiesModal.tsx b/src/components/common/TrackPropertiesModal.tsx index 6ba20434..d5570bae 100644 --- a/src/components/common/TrackPropertiesModal.tsx +++ b/src/components/common/TrackPropertiesModal.tsx @@ -6,6 +6,7 @@ import { ExternalLink, ImageUp, Pencil, + RotateCcw, Save, Sparkles, TriangleAlert, @@ -18,7 +19,10 @@ import { formatDuration, getTrack, getTrackArtists, + getTrackFileStamp, getTrackGenres, + isFileChangedOnDisk, + rereadTrackFolder, setTrackRating, updateTrackCover, updateTrackTags, @@ -129,6 +133,36 @@ export function TrackPropertiesModal({ // can't tag — and the dialog used to swallow all of them into a // console line while looking like nothing had happened. const [saveError, setSaveError] = useState<string | null>(null); + // The file's stamp (size + modification time) as of the values the + // dialog shows, handed back with the save. The form writes the title, + // numbers and year back whether or not they were touched, so a file + // another tagger rewrote while the dialog was open would lose that + // change; with the stamp, the backend refuses the save instead. + // `null` until loaded, and Save stays disabled until then: the stamp + // has to be the one read with the values the form shows. Taken at the + // click instead, it would already include the change it is meant to + // catch. + const [fileStamp, setFileStamp] = useState<string | null>(null); + // The stamp could not be read: the file is gone, moved or unreadable. + // Save stays disabled, and the dialog says why and offers to reload. + // Reload, not just re-stamp: a file that comes back may have been + // retagged meanwhile, and a fresh stamp over the form's old values + // would let the save overwrite that change after all. + const [stampUnavailable, setStampUnavailable] = useState(false); + // The stamp a reload validated against the values it put in the form. + // The reload changes `track`, which re-runs the stamp fetch below; that + // fetch must hand this one back rather than read the file again, or a + // write landing in between would be stamped over the older values. + const reloadedStampRef = useRef<string | null>(null); + // Set once the file is known to differ from the form (a save refused, + // or a reload that saw the file move). Until a reload whose two stamps + // agree clears it, no stamp is restored, not by a fetch already in + // flight nor by one a later `track:updated` starts, since those would + // re-enable Save over the stale values. + const staleRef = useRef(false); + // Set when a save was refused for that reason: the error then offers + // to reload the values from the file instead of repeating the save. + const [fileChanged, setFileChanged] = useState(false); // Genres as they stand in the database, loaded alongside the track. // `null` means "not loaded yet", which is what gates the save below. const [genres, setGenres] = useState<string[] | null>(null); @@ -189,6 +223,38 @@ export function TrackPropertiesModal({ }; }, [track]); + // Re-read with every refresh of the track, which is what the form is + // hydrated from: a save of our own, a rating or a new cover all + // rewrite the file and emit `track:updated`, and the stamp has to move + // with them or the next save would be refused by our own change. + useEffect(() => { + if (!track) return; + const reloaded = reloadedStampRef.current; + if (reloaded != null) { + reloadedStampRef.current = null; + setFileStamp(reloaded); + setStampUnavailable(false); + return; + } + let cancelled = false; + getTrackFileStamp(track.id) + .then((stamp) => { + if (cancelled) return; + setStampUnavailable(stamp == null); + setFileStamp(staleRef.current ? null : stamp); + }) + .catch((err) => { + console.error("[TrackProperties] get_track_file_stamp failed", err); + if (!cancelled) { + setFileStamp(null); + setStampUnavailable(true); + } + }); + return () => { + cancelled = true; + }; + }, [track]); + // What the library already holds, offered while editing so a value is // picked rather than retyped — a typo in an artist name makes a second // artist. A failed lookup only means fewer suggestions. @@ -337,9 +403,10 @@ export function TrackPropertiesModal({ }; const handleSave = async () => { - if (!track || saving) return; + if (!track || saving || fileStamp == null) return; setSaving(true); setSaveError(null); + setFileChanged(false); try { const edit: TrackEdit = { title: form.title, @@ -384,7 +451,11 @@ export function TrackPropertiesModal({ if (sendingArtist || album !== (track.album_title ?? "").trim()) { edit.album = album; } - await updateTrackTags(track.id, edit); + const stamp = await updateTrackTags(track.id, edit, fileStamp); + // Straight away rather than through the refresh `track:updated` + // triggers: a second save made before that lands must match the + // file this one left. + setFileStamp(stamp); // Only when we sent one — otherwise this would record a genre we // never wrote, and the *next* save would act on it. setGenreTouched(false); @@ -421,6 +492,81 @@ export function TrackPropertiesModal({ // surrounding views listen to and react to (re-fetch the row). } catch (err) { console.error("[TrackProperties] update_track_tags failed", err); + const changed = isFileChangedOnDisk(err); + setFileChanged(changed); + if (changed) { + staleRef.current = true; + setFileStamp(null); + } + setSaveError(String(err)); + } finally { + setSaving(false); + } + }; + + // After a save refused because the file changed: rescan its folder so + // the library holds what the other program wrote, then refill the + // dialog from it. Reloading from the database alone would show the + // same stale values with a fresh stamp, and the next save would + // overwrite the change after all. The user's unsaved input is dropped, + // which is the point: it was typed over values that no longer exist. + const handleReload = async () => { + if (!track || saving) return; + setSaving(true); + try { + // Stamped on both sides of the reread, and the second time before + // anything is applied: a write landing while it runs would leave the + // form with values older than the file, and once `track` changes, + // its effect fetches a fresh stamp that would let Save overwrite + // that write. When the two differ, nothing of this reload is kept. + const before = await getTrackFileStamp(track.id).catch(() => null); + await rereadTrackFolder(track.id); + const [fresh, genreRows, artistRows] = await Promise.all([ + getTrack(track.id), + getTrackGenres(track.id), + getTrackArtists(track.id), + ]); + const after = await getTrackFileStamp(track.id).catch(() => null); + setStampUnavailable(after == null); + if (before == null || after == null || before !== after) { + // Moved during the reload, or unreadable: no stamp, so no save, + // until a reload that finds the file still. + staleRef.current = true; + setFileStamp(null); + if (after != null) { + setFileChanged(true); + setSaveError(t("trackProperties.fileChanged")); + } + return; + } + staleRef.current = false; + setGenres(genreRows); + setGenreTouched(false); + setArtists(artistRows.length > 0 ? artistRows : null); + // The hydration below carries the genre over and reads the + // credits from their ref, so both are put in place here. + setForm((prev) => ({ + ...prev, + genre: genreRows.join("; "), + artist: + artistRows.length > 0 + ? artistRows.join("; ") + : (fresh?.artist_name ?? ""), + })); + // A new `track` re-runs the hydration (which also leaves edit + // mode) and the stamp fetch; the stamp is set here as well, for a + // row that comes back unchanged and so does not move `track`. + if (fresh) { + reloadedStampRef.current = after; + setTrack(fresh); + } + setFileStamp(after); + setSaveError(null); + setFileChanged(false); + } catch (err) { + console.error("[TrackProperties] reread_track_folder failed", err); + // `fileChanged` stays set: the file is still the changed one, and + // the reload button has to stay there to be tried again. setSaveError(String(err)); } finally { setSaving(false); @@ -436,7 +582,12 @@ export function TrackPropertiesModal({ ); if (!path) return; setSaving(true); - setSaveError(null); + // While the form is known to be stale, its notice and Reload stay: + // they are the only way back to a Save that works. + if (!staleRef.current) { + setSaveError(null); + setFileChanged(false); + } await updateTrackCover(track.id, path); // The backend's `track:updated` event triggers a refetch in the // surrounding views; the modal itself stays open on the same @@ -855,15 +1006,53 @@ export function TrackPropertiesModal({ </Section> </div> + {editing && stampUnavailable && ( + <div + role="status" + className="mx-6 mb-4 flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-200" + > + <TriangleAlert size={16} className="mt-0.5 shrink-0" /> + <div className="min-w-0 flex-1"> + <span className="break-words"> + {t("trackProperties.stampUnavailable")} + </span> + <button + type="button" + onClick={handleReload} + disabled={saving} + className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-2.5 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-800 dark:bg-amber-950/60 dark:text-amber-200 dark:hover:bg-amber-900/50 transition-colors" + > + <RotateCcw size={12} /> + <span>{t("trackProperties.reloadFromFile")}</span> + </button> + </div> + </div> + )} + {saveError && ( <div role="alert" className="mx-6 mb-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300" > <TriangleAlert size={16} className="mt-0.5 shrink-0" /> - <span className="min-w-0 break-words"> - {t("trackProperties.saveFailed", { reason: saveError })} - </span> + <div className="min-w-0 flex-1"> + <span className="break-words"> + {fileChanged + ? t("trackProperties.fileChanged") + : t("trackProperties.saveFailed", { reason: saveError })} + </span> + {fileChanged && ( + <button + type="button" + onClick={handleReload} + disabled={saving} + className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-red-300 bg-white px-2.5 py-1 text-xs font-medium text-red-700 hover:bg-red-100 disabled:opacity-50 dark:border-red-800 dark:bg-red-950/60 dark:text-red-200 dark:hover:bg-red-900/50 transition-colors" + > + <RotateCcw size={12} /> + <span>{t("trackProperties.reloadFromFile")}</span> + </button> + )} + </div> </div> )} @@ -881,7 +1070,7 @@ export function TrackPropertiesModal({ <button type="button" onClick={handleSave} - disabled={saving} + disabled={saving || fileStamp == null} className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-emerald-500 text-white hover:bg-emerald-600 transition-colors disabled:opacity-50" > <Save size={14} /> diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 4286e6ae..0a789c9e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1387,6 +1387,9 @@ "save": "حفظ", "saving": "جارٍ الحفظ…", "saveFailed": "تعذّر الحفظ: {{reason}}", + "fileChanged": "عدّل برنامج آخر الملف منذ فتح هذه النافذة. أعد تحميل قيمه قبل الحفظ حتى لا تُستبدل تلك التغييرات.", + "reloadFromFile": "إعادة التحميل من الملف", + "stampUnavailable": "تتعذّر قراءة الملف على القرص: ربما نُقل أو حُذف أو أصبح غير قابل للوصول. سيبقى الحفظ معطّلًا حتى تصبح قراءته ممكنة.", "fields": { "title": "العنوان", "artist": "الفنان (الفنانون)", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 27d32a22..13d9d207 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1238,6 +1238,9 @@ "save": "Speichern", "saving": "Wird gespeichert…", "saveFailed": "Speichern fehlgeschlagen: {{reason}}", + "fileChanged": "Die Datei wurde seit dem Öffnen dieses Fensters von einem anderen Programm geändert. Lade ihre Werte neu, bevor du speicherst, damit diese Änderungen nicht überschrieben werden.", + "reloadFromFile": "Aus der Datei neu laden", + "stampUnavailable": "Die Datei kann auf dem Datenträger nicht gelesen werden: Sie wurde möglicherweise verschoben, gelöscht oder ist nicht zugänglich. Speichern bleibt deaktiviert, bis sie lesbar ist.", "fields": { "title": "Titel", "artist": "Künstler", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 80891f4b..99ea01ed 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1238,6 +1238,9 @@ "save": "Save", "saving": "Saving…", "saveFailed": "Couldn't save: {{reason}}", + "fileChanged": "The file was changed by another program since this window opened. Reload its values before saving, so those changes are not overwritten.", + "reloadFromFile": "Reload from file", + "stampUnavailable": "The file can't be read on disk: it may have been moved, deleted or made inaccessible. Saving stays disabled until it can be read.", "fields": { "title": "Title", "artist": "Artist(s)", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index feda09f5..451e37e4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1281,6 +1281,9 @@ "save": "Guardar", "saving": "Guardando…", "saveFailed": "No se pudo guardar: {{reason}}", + "fileChanged": "Otro programa ha modificado el archivo desde que se abrió esta ventana. Vuelve a cargar sus valores antes de guardar para no sobrescribir esos cambios.", + "reloadFromFile": "Volver a cargar desde el archivo", + "stampUnavailable": "No se puede leer el archivo en el disco: puede que se haya movido, eliminado o que no sea accesible. Guardar seguirá desactivado hasta que se pueda leer.", "fields": { "title": "Título", "artist": "Artista(s)", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a9ec73da..fb6a0b6c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1281,6 +1281,9 @@ "save": "Enregistrer", "saving": "Enregistrement…", "saveFailed": "Échec de l'enregistrement : {{reason}}", + "fileChanged": "Le fichier a été modifié par un autre programme depuis l'ouverture de cette fenêtre. Rechargez ses valeurs avant d'enregistrer, pour ne pas écraser ces changements.", + "reloadFromFile": "Recharger depuis le fichier", + "stampUnavailable": "Impossible de lire le fichier sur le disque : il a peut-être été déplacé, supprimé ou rendu inaccessible. L'enregistrement reste désactivé tant qu'il n'est pas lisible.", "fields": { "title": "Titre", "artist": "Artiste(s)", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 230cd256..ab922611 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1238,6 +1238,9 @@ "save": "सहेजें", "saving": "सहेज रहा है…", "saveFailed": "सहेजा नहीं जा सका: {{reason}}", + "fileChanged": "यह विंडो खुलने के बाद किसी दूसरे प्रोग्राम ने फ़ाइल बदल दी है। उन बदलावों पर ओवरराइट न हो, इसलिए सहेजने से पहले उसके मान फिर से लोड करें।", + "reloadFromFile": "फ़ाइल से फिर से लोड करें", + "stampUnavailable": "डिस्क पर फ़ाइल पढ़ी नहीं जा सकती: हो सकता है उसे हटा दिया गया हो, कहीं और ले जाया गया हो या वह पहुँच से बाहर हो। जब तक वह पढ़ी नहीं जा सकती, सहेजना बंद रहेगा।", "fields": { "title": "शीर्षक", "artist": "कलाकार", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 11e01356..cca1b77d 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1236,6 +1236,9 @@ "save": "Simpan", "saving": "Menyimpan…", "saveFailed": "Gagal menyimpan: {{reason}}", + "fileChanged": "Berkas telah diubah oleh program lain sejak jendela ini dibuka. Muat ulang nilainya sebelum menyimpan agar perubahan tersebut tidak tertimpa.", + "reloadFromFile": "Muat ulang dari berkas", + "stampUnavailable": "File tidak dapat dibaca di disk: mungkin telah dipindahkan, dihapus, atau tidak dapat diakses. Penyimpanan tetap nonaktif sampai file dapat dibaca.", "fields": { "title": "Judul", "artist": "Artis", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 83c9599e..d5e1cf5c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1281,6 +1281,9 @@ "save": "Salva", "saving": "Salvataggio…", "saveFailed": "Salvataggio non riuscito: {{reason}}", + "fileChanged": "Il file è stato modificato da un altro programma dopo l'apertura di questa finestra. Ricarica i suoi valori prima di salvare, per non sovrascrivere quelle modifiche.", + "reloadFromFile": "Ricarica dal file", + "stampUnavailable": "Impossibile leggere il file su disco: potrebbe essere stato spostato, eliminato o reso inaccessibile. Il salvataggio resta disattivato finché non è leggibile.", "fields": { "title": "Titolo", "artist": "Artista/i", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 540f9278..bd52ff3f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1236,6 +1236,9 @@ "save": "保存", "saving": "保存中…", "saveFailed": "保存できませんでした:{{reason}}", + "fileChanged": "このウィンドウを開いた後に、別のプログラムがファイルを変更しました。その変更を上書きしないよう、保存する前に値を再読み込みしてください。", + "reloadFromFile": "ファイルから再読み込み", + "stampUnavailable": "ディスク上のファイルを読み込めません。移動・削除されたか、アクセスできなくなった可能性があります。読み込めるようになるまで保存は無効です。", "fields": { "title": "タイトル", "artist": "アーティスト", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 8e8489b0..f8c1a33a 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1236,6 +1236,9 @@ "save": "저장", "saving": "저장 중…", "saveFailed": "저장하지 못했습니다: {{reason}}", + "fileChanged": "이 창을 연 뒤 다른 프로그램이 파일을 변경했습니다. 그 변경 사항을 덮어쓰지 않도록 저장하기 전에 값을 다시 불러오세요.", + "reloadFromFile": "파일에서 다시 불러오기", + "stampUnavailable": "디스크에서 파일을 읽을 수 없습니다. 이동되었거나 삭제되었거나 접근할 수 없게 되었을 수 있습니다. 읽을 수 있을 때까지 저장이 비활성화됩니다.", "fields": { "title": "제목", "artist": "아티스트", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 127d7322..cdeb88d0 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1238,6 +1238,9 @@ "save": "Opslaan", "saving": "Opslaan…", "saveFailed": "Opslaan mislukt: {{reason}}", + "fileChanged": "Het bestand is door een ander programma gewijzigd sinds dit venster is geopend. Laad de waarden opnieuw voordat je opslaat, zodat die wijzigingen niet worden overschreven.", + "reloadFromFile": "Opnieuw laden uit bestand", + "stampUnavailable": "Het bestand kan niet van de schijf worden gelezen: het is mogelijk verplaatst, verwijderd of ontoegankelijk gemaakt. Opslaan blijft uitgeschakeld tot het leesbaar is.", "fields": { "title": "Titel", "artist": "Artiest(en)", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index a686eb82..792aab20 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1282,6 +1282,9 @@ "save": "Salvar", "saving": "Salvando…", "saveFailed": "Não foi possível salvar: {{reason}}", + "fileChanged": "O arquivo foi alterado por outro programa depois que esta janela foi aberta. Recarregue os valores antes de salvar para não sobrescrever essas alterações.", + "reloadFromFile": "Recarregar do arquivo", + "stampUnavailable": "Não é possível ler o arquivo no disco: ele pode ter sido movido, excluído ou ficado inacessível. Salvar fica desativado até que ele possa ser lido.", "fields": { "title": "Título", "artist": "Artista(s)", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 0e544f7e..aaf9980c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1282,6 +1282,9 @@ "save": "Guardar", "saving": "A guardar…", "saveFailed": "Não foi possível guardar: {{reason}}", + "fileChanged": "O ficheiro foi alterado por outro programa desde que esta janela foi aberta. Recarregue os valores antes de guardar, para não substituir essas alterações.", + "reloadFromFile": "Recarregar do ficheiro", + "stampUnavailable": "Não é possível ler o ficheiro no disco: pode ter sido movido, eliminado ou tornado inacessível. Guardar fica desativado até que possa ser lido.", "fields": { "title": "Título", "artist": "Artista(s)", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index cf18d822..d8aecc92 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1328,6 +1328,9 @@ "save": "Сохранить", "saving": "Сохранение…", "saveFailed": "Не удалось сохранить: {{reason}}", + "fileChanged": "После открытия этого окна файл был изменён другой программой. Перезагрузите значения перед сохранением, чтобы не затереть эти изменения.", + "reloadFromFile": "Перезагрузить из файла", + "stampUnavailable": "Не удаётся прочитать файл на диске: возможно, он перемещён, удалён или недоступен. Сохранение отключено, пока файл не станет доступен для чтения.", "fields": { "title": "Название", "artist": "Исполнитель(и)", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index f45c4dce..af1b55e2 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1238,6 +1238,9 @@ "save": "Kaydet", "saving": "Kaydediliyor…", "saveFailed": "Kaydedilemedi: {{reason}}", + "fileChanged": "Bu pencere açıldıktan sonra dosya başka bir program tarafından değiştirildi. Bu değişikliklerin üzerine yazmamak için kaydetmeden önce değerleri yeniden yükleyin.", + "reloadFromFile": "Dosyadan yeniden yükle", + "stampUnavailable": "Dosya diskte okunamıyor: taşınmış, silinmiş ya da erişilemez hâle gelmiş olabilir. Okunabilir olana kadar kaydetme devre dışı kalır.", "fields": { "title": "Başlık", "artist": "Sanatçı(lar)", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d845f0a6..dc4b60d7 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1236,6 +1236,9 @@ "save": "保存", "saving": "保存中…", "saveFailed": "保存失败:{{reason}}", + "fileChanged": "打开此窗口后,文件已被其他程序修改。请在保存前重新加载其值,以免覆盖这些更改。", + "reloadFromFile": "从文件重新加载", + "stampUnavailable": "无法读取磁盘上的文件:它可能已被移动、删除或无法访问。在文件可读之前,保存将保持禁用。", "fields": { "title": "标题", "artist": "艺人", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 18ed2559..7cacec1c 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1236,6 +1236,9 @@ "save": "儲存", "saving": "儲存中…", "saveFailed": "儲存失敗:{{reason}}", + "fileChanged": "開啟此視窗後,檔案已被其他程式修改。請在儲存前重新載入其值,以免覆寫這些變更。", + "reloadFromFile": "從檔案重新載入", + "stampUnavailable": "無法讀取磁碟上的檔案:它可能已被移動、刪除或無法存取。在檔案可讀取之前,儲存將維持停用。", "fields": { "title": "標題", "artist": "藝人", diff --git a/src/lib/tauri/track.ts b/src/lib/tauri/track.ts index 9bf3c516..1109f341 100644 --- a/src/lib/tauri/track.ts +++ b/src/lib/tauri/track.ts @@ -277,7 +277,7 @@ export async function listLikedTracks(): Promise<Track[]> { * (not transmitted) means "leave this field untouched"; an empty * string means "clear this field" where the format allows it. * - * `artist` accepts a comma-separated multi-artist string ("A, B"), + * `artist` accepts a `"; "`-separated multi-artist string ("A; B"), * which the backend splits via the same parser the scanner uses. */ export interface TrackEdit { @@ -290,12 +290,53 @@ export interface TrackEdit { genre?: string | null; } -/** Persist the edited tags both to the audio file and to the database. */ +/** + * Persist the edited tags both to the audio file and to the database. + * + * `fileStamp` is what {@link getTrackFileStamp} returned when the editor + * opened: given, the backend refuses the write when the file changed + * since (see {@link isFileChangedOnDisk}). Resolves to the file's stamp + * after the write, for the editor's next save. + */ export function updateTrackTags( trackId: number, edit: TrackEdit, -): Promise<void> { - return invoke<void>("update_track_tags", { trackId, edit }); + fileStamp?: string | null, +): Promise<string | null> { + return invoke<string | null>("update_track_tags", { + trackId, + edit, + fileStamp: fileStamp ?? null, + }); +} + +/** + * An opaque fingerprint of the track's file (size + modification time), + * or `null` when the file cannot be reached. Only ever compared for + * equality, by the backend. + */ +export function getTrackFileStamp(trackId: number): Promise<string | null> { + return invoke<string | null>("get_track_file_stamp", { trackId }); +} + +/** + * Rescan the library folder holding the track, so the database catches + * up with a file another program changed. Unchanged files are skipped by + * the scanner's fast path. + */ +export function rereadTrackFolder(trackId: number): Promise<void> { + return invoke<void>("reread_track_folder", { trackId }); +} + +/** + * The backend's `AppError::FileChangedOnDisk` message: a save refused + * because another program wrote the file after the editor opened it. + */ +export const FILE_CHANGED_ON_DISK = "the file changed on disk"; + +/** Whether `err` is a save refused by {@link FILE_CHANGED_ON_DISK}. */ +export function isFileChangedOnDisk(err: unknown): boolean { + return String(err).includes(FILE_CHANGED_ON_DISK); } /** Summary returned by `updateTracksBatch`. */