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
4 changes: 2 additions & 2 deletions docs/features/library.md

Large diffs are not rendered by default.

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. 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.
Expand Down
4 changes: 2 additions & 2 deletions 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`.
- **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.
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

123 changes: 113 additions & 10 deletions src-tauri/crates/app/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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");
}

Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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
);
}
}
Loading
Loading