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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/playlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<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:
`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; a partial left by a write that never finished is removed by the next write to the same file once it is a day old. 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. 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.
- **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. An export that never reaches that cleanup — the app closed or the machine down halfway — leaves its partial, and the next write to the same destination removes the ones a day old before it starts, an age no write in progress reaches; a partial of another file in the folder is never touched. 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.
Expand Down
11 changes: 7 additions & 4 deletions src-tauri/crates/app/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,10 +487,13 @@ 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);
/// How old an unfinished archive has to be before retention removes it:
/// the age the writer itself uses. Far longer than any backup takes to
/// write, so it is never one in progress — another pass can be running
/// beside this one. The writer only clears partials of the file it is
/// writing, and every backup has a name of its own, so a backup cut
/// short is left to this sweep.
const STALE_PARTIAL: Duration = waveflow_core::atomic_write::STALE_PARTIAL_AGE;

/// What a file in the backup folder is to the retention sweep of one
/// profile.
Expand Down
145 changes: 142 additions & 3 deletions src-tauri/crates/core/src/atomic_write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,30 @@
//! the same destination (an export run while a backup writes there)
//! would otherwise truncate each other's half-written file.
//!
//! A write that never reaches its cleanup (a crash, a power cut, the app
//! closed in the middle of an export) leaves its partial behind. The next
//! write to the same destination removes those that are a day old.
//!
//! 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::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime};

/// 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";

/// How old a partial has to be before it is taken for one whose write
/// will never finish. Far longer than any write takes, so it is never
/// one in progress: a write touches its partial with every block.
pub const STALE_PARTIAL_AGE: Duration = Duration::from_secs(24 * 60 * 60);

/// 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.
Expand Down Expand Up @@ -73,6 +83,67 @@ fn sibling_path(target: &Path, token: &str, suffix: &str) -> PathBuf {
target.with_file_name(name)
}

/// Whether `name` is the file name of a partial of `target`, left by any
/// process: [`partial_path`] with a `<pid>-<n>` token. The token is
/// matched whole, so a partial of another destination whose name starts
/// the same way (`Main.old.waveflow` beside `Main.waveflow`) is never
/// taken for one of this destination's. Names are compared as they are
/// on disk, so a destination whose name is not valid UTF-8 still has its
/// partials found; only the token has to be text.
fn is_partial_of(target: &Path, name: &OsStr) -> bool {
let (head, tail) = match (target.file_stem(), target.extension()) {
(Some(stem), Some(extension)) => {
let mut tail = OsString::from(".");
tail.push(extension);
tail.push(PARTIAL_SUFFIX);
(stem, tail)
}
_ => match target.file_name() {
Some(name) => (name, OsString::from(PARTIAL_SUFFIX)),
None => return false,
},
};
let Some(token) = name
.as_encoded_bytes()
.strip_prefix(head.as_encoded_bytes())
.and_then(|rest| rest.strip_prefix(b"."))
.and_then(|rest| rest.strip_suffix(tail.as_encoded_bytes()))
.and_then(|token| std::str::from_utf8(token).ok())
else {
return false;
};
let number = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit());
token
.split_once('-')
.is_some_and(|(pid, n)| number(pid) && number(n))
}

/// Remove the partials of `target` that earlier writes left behind and
/// that are older than [`STALE_PARTIAL_AGE`]. One another write is still
/// filling is younger than that, and one whose age cannot be read is
/// kept. Best effort: what cannot be listed or removed stays, and the
/// write goes ahead.
fn sweep_stale_partials(target: &Path, directory: &Path) {
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
let now = SystemTime::now();
for entry in entries.flatten() {
if !is_partial_of(target, &entry.file_name()) {
continue;
}
let stale = entry
.metadata()
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| age > STALE_PARTIAL_AGE);
if stale {
let _ = std::fs::remove_file(entry.path());
}
}
}

/// A token no other write of this process, or of another process, uses:
/// the process id and a counter.
fn next_token() -> String {
Expand Down Expand Up @@ -129,8 +200,10 @@ impl Drop for Partial {
/// 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.
/// The parent directory is created if missing. Partials of `target` that
/// earlier writes left there are removed once a day old, before this one
/// starts, so the space they hold is free for it. 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>,
Expand All @@ -140,6 +213,7 @@ where
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(directory).map_err(E::from)?;
sweep_stale_partials(target, directory);

let (path, mut file) = create_partial(target).map_err(E::from)?;
let mut partial = Partial {
Expand Down Expand Up @@ -335,6 +409,71 @@ mod tests {
assert!(!backup.to_string_lossy().ends_with(PARTIAL_SUFFIX));
}

#[test]
fn a_partial_is_recognised_as_its_target_s_and_no_other_s() {
let target = Path::new("/exports/Main.waveflow");
let own = partial_path(target, "4242-7");
assert!(is_partial_of(target, own.file_name().unwrap()));

let bare = Path::new("/exports/list");
let own = partial_path(bare, "1-0");
assert!(is_partial_of(bare, own.file_name().unwrap()));

for other in [
"Main.waveflow",
"Main.old.4242-7.waveflow.partial",
"Main.4242-7.waveflow.replaced",
"Main.notes-1.waveflow.partial",
"Main.4242-.waveflow.partial",
"Main.4242-7.m3u8.partial",
] {
assert!(!is_partial_of(target, OsStr::new(other)), "{other}");
}
}

/// A Linux file name need not be UTF-8; its partials are still found.
#[cfg(unix)]
#[test]
fn a_partial_of_a_name_that_is_not_utf8_is_recognised() {
use std::os::unix::ffi::OsStrExt;
let target = Path::new("/exports").join(OsStr::from_bytes(b"Caf\xe9.waveflow"));
let own = partial_path(&target, "12-0");
assert!(is_partial_of(&target, own.file_name().unwrap()));
assert!(!is_partial_of(
&target,
OsStr::from_bytes(b"Caf\xe9.old.12-0.waveflow.partial")
));
}

/// An export cut short (the app closed halfway) never reaches its
/// cleanup; the next write to the same file removes what it left,
/// and nothing that may still be in use.
#[test]
fn the_next_write_removes_a_stale_partial_of_its_target() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("Main.waveflow");
let old = SystemTime::now() - STALE_PARTIAL_AGE - Duration::from_secs(60);
let leave = |path: &Path, modified: SystemTime| {
std::fs::File::create(path)
.unwrap()
.set_modified(modified)
.unwrap();
};
let stale = partial_path(&target, "1-0");
leave(&stale, old);
let fresh = partial_path(&target, "1-1");
leave(&fresh, SystemTime::now());
let foreign = partial_path(&dir.path().join("Other.waveflow"), "1-0");
leave(&foreign, old);

write_bytes(&target, b"new").unwrap();

assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert!(!stale.exists());
assert!(fresh.exists());
assert!(foreign.exists());
}

#[test]
fn every_write_gets_its_own_partial() {
assert_ne!(next_token(), next_token());
Expand Down
Loading