From df3d15553a9b84b009d03891c6f344b16efe3865 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:05:31 -0500 Subject: [PATCH] playlists + lyrics: setlists with pages of their own, and the words timed to the voice --- Cargo.lock | 21 +- Cargo.toml | 1 + crates/audio-formats/Cargo.toml | 7 + crates/audio-formats/src/lib.rs | 166 ++ crates/library/Cargo.toml | 1 + crates/library/src/lib.rs | 1437 ++++++++++++- crates/metadata/src/lib.rs | 954 ++++++++- crates/player/Cargo.toml | 1 + crates/player/examples/probe_formats.rs | 187 ++ crates/player/src/lib.rs | 14 + crates/player/src/vocals.rs | 512 +++++ crates/soulseek-client/Cargo.toml | 1 + crates/soulseek-client/src/distributed.rs | 7 +- crates/soulseek-client/src/share.rs | 22 +- crates/tagger/src/lib.rs | 111 + package.json | 2 +- src-tauri/Cargo.toml | 6 + src-tauri/src/commands.rs | 138 +- src-tauri/src/config.rs | 142 ++ src-tauri/src/import.rs | 532 ++++- src-tauri/src/lib.rs | 44 +- src-tauri/src/loudness.rs | 17 +- src-tauri/src/lyrics.rs | 2298 +++++++++++++++++++++ src-tauri/src/pipeline.rs | 110 +- src-tauri/src/playlist_formats.rs | 1197 +++++++++++ src-tauri/src/playlists.rs | 1279 ++++++++++++ src/App.svelte | 4 + src/components/Icon.svelte | 7 + src/components/ImportPicker.svelte | 150 +- src/components/ImportToast.svelte | 2 + src/components/ImportWindow.svelte | 483 ++++- src/components/LyricsOverlay.svelte | 104 + src/components/LyricsPane.svelte | 911 ++++++++ src/components/LyricsSyncEditor.svelte | 633 ++++++ src/components/NowPlaying.svelte | 38 +- src/components/PlaylistImportModal.svelte | 554 +++++ src/components/PlaylistMenu.svelte | 211 ++ src/components/Sidebar.svelte | 40 + src/lib/api.js | 119 +- src/lib/import-audio.js | Bin 9641 -> 11564 bytes src/lib/import-query.js | 73 + src/lib/import-title.js | 32 +- src/lib/lyric-sync.js | 108 + src/lib/lyrics.svelte.js | Bin 0 -> 12062 bytes src/lib/state.svelte.js | 72 +- src/views/Library.svelte | 191 +- src/views/Playlist.svelte | 1350 +++++++++++- src/views/Playlists.svelte | 653 +++++- tests/lyrics/sync.test.mjs | 76 + 49 files changed, 14419 insertions(+), 599 deletions(-) create mode 100644 crates/audio-formats/Cargo.toml create mode 100644 crates/audio-formats/src/lib.rs create mode 100644 crates/player/examples/probe_formats.rs create mode 100644 crates/player/src/vocals.rs create mode 100644 src-tauri/src/lyrics.rs create mode 100644 src-tauri/src/playlist_formats.rs create mode 100644 src-tauri/src/playlists.rs create mode 100644 src/components/LyricsOverlay.svelte create mode 100644 src/components/LyricsPane.svelte create mode 100644 src/components/LyricsSyncEditor.svelte create mode 100644 src/components/PlaylistImportModal.svelte create mode 100644 src/components/PlaylistMenu.svelte create mode 100644 src/lib/import-query.js create mode 100644 src/lib/lyric-sync.js create mode 100644 src/lib/lyrics.svelte.js create mode 100644 tests/lyrics/sync.test.mjs diff --git a/Cargo.lock b/Cargo.lock index 954dcb9..7d917f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" name = "app" version = "0.1.0-alpha.3" dependencies = [ + "audio-formats", "base64 0.22.1", "image", "keyring", @@ -170,6 +171,8 @@ dependencies = [ "log", "metadata", "player", + "quick-xml 0.37.5", + "reqwest 0.12.28", "serde", "serde_json", "soulseek-client", @@ -231,6 +234,10 @@ dependencies = [ "url", ] +[[package]] +name = "audio-formats" +version = "0.1.0-alpha.3" + [[package]] name = "autocfg" version = "1.5.1" @@ -2299,6 +2306,7 @@ dependencies = [ name = "library" version = "0.1.0-alpha.3" dependencies = [ + "audio-formats", "log", "rusqlite", "serde", @@ -3080,6 +3088,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" name = "player" version = "0.1.0-alpha.3" dependencies = [ + "audio-formats", "env_logger", "log", "rodio", @@ -3096,7 +3105,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -3266,6 +3275,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -4079,6 +4097,7 @@ dependencies = [ name = "soulseek-client" version = "0.1.0-alpha.3" dependencies = [ + "audio-formats", "bytes", "env_logger", "igd-next", diff --git a/Cargo.toml b/Cargo.toml index ef56757..5168774 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/tagger", "crates/metadata", "crates/library", + "crates/audio-formats", ] [workspace.package] diff --git a/crates/audio-formats/Cargo.toml b/crates/audio-formats/Cargo.toml new file mode 100644 index 0000000..d440e15 --- /dev/null +++ b/crates/audio-formats/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "audio-formats" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] diff --git a/crates/audio-formats/src/lib.rs b/crates/audio-formats/src/lib.rs new file mode 100644 index 0000000..d14e827 --- /dev/null +++ b/crates/audio-formats/src/lib.rs @@ -0,0 +1,166 @@ +//! What counts as audio, and what the player can actually decode. +//! +//! These were seven separate hand-kept lists — the library scanner, the share +//! index, the row pruner, the loudness pass, and three in the frontend — and +//! they disagreed. The scanner indexed `.aiff`, the pruner did not recognise it +//! and DELETED the rows on the next startup. The share index knew `.ape`, the +//! scanner did not. Every list named `.opus`, which nothing here can decode. +//! +//! One table now, with a distinction the old lists could not express: +//! +//! - [`PLAYABLE`] — the decoder can turn this into sound. Derived from the +//! codecs symphonia is actually built with (`all-codecs` + `all-formats`: +//! aac, adpcm, alac, flac, mp1/2/3, pcm, vorbis; caf, isomp4, mkv, ogg, +//! aiff, wav) and verified against real files by +//! `cargo run -p player --example probe_formats`. +//! - [`RECOGNISED`] — audio we can index, share, download and show, but cannot +//! play. Naming these is the point: a file the app can see and refuses to +//! play with a clear reason is honest, whereas leaving it out of the tables +//! makes it invisible, and claiming it plays makes the player fail at the +//! moment the user presses play. +//! +//! Adding a codec means adding a decoder, not editing a list. + +/// Extensions the player can decode. Verified, not assumed. +pub const PLAYABLE: &[&str] = &[ + // FLAC + "flac", + // MPEG audio — the bundle provides layers I, II and III + "mp3", "mp2", "mp1", + // ISO/MP4 container: AAC and ALAC live here + "m4a", "m4b", "mp4", "m4r", + // Raw AAC (ADTS) + "aac", + // Ogg container, Vorbis inside + "ogg", "oga", + // RIFF: PCM and ADPCM + "wav", "wave", + // AIFF / AIFF-C, also via the RIFF reader + "aiff", "aif", "aifc", + // Core Audio Format + "caf", + // Matroska — FLAC/Vorbis/AAC in a different wrapper + "mka", "webm", +]; + +/// Audio we recognise but cannot decode. Indexed and shared; never played. +/// +/// `wv` earns its place here more than any of them. A WavPack file that +/// carries the original RIFF header in its wrapper is probed as raw PCM, so +/// the decoder does not fail — it reads compressed bytes as samples and emits +/// NaN into the mixer. Refusing it by extension is the only thing that stops +/// that, because the decoder itself reports success. +pub const RECOGNISED: &[&str] = &[ + "opus", // no Opus decoder is built in + "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr", +]; + +/// Is this an audio file at all — playable or merely recognised? +/// +/// The scanner, the share index and the row pruner must all answer this the +/// same way, or a file is indexed by one and deleted by another. +pub fn is_audio_ext(ext: &str) -> bool { + let e = ext.to_ascii_lowercase(); + PLAYABLE.contains(&e.as_str()) || RECOGNISED.contains(&e.as_str()) +} + +/// Can the player turn this into sound? +pub fn is_playable_ext(ext: &str) -> bool { + PLAYABLE.contains(&ext.to_ascii_lowercase().as_str()) +} + +/// Why this file will not play, in words that can go on screen. +pub fn unplayable_reason(ext: &str) -> Option<&'static str> { + match ext.to_ascii_lowercase().as_str() { + "opus" => Some("Opus isn't supported yet — this build has no Opus decoder"), + "wv" => Some("WavPack isn't supported yet"), + "ape" => Some("Monkey's Audio (APE) isn't supported yet"), + "wma" => Some("Windows Media Audio isn't supported yet"), + "mpc" => Some("Musepack isn't supported yet"), + "tta" => Some("True Audio isn't supported yet"), + "shn" => Some("Shorten isn't supported yet"), + "dsf" | "dff" => Some("DSD isn't supported yet"), + e if RECOGNISED.contains(&e) => Some("this format isn't supported yet"), + _ => None, + } +} + +fn ext_of(path: &std::path::Path) -> Option { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) +} + +pub fn is_audio_path(path: &std::path::Path) -> bool { + ext_of(path).is_some_and(|e| is_audio_ext(&e)) +} + +pub fn is_playable_path(path: &std::path::Path) -> bool { + ext_of(path).is_some_and(|e| is_playable_ext(&e)) +} + +/// Why this path will not play. `None` means "go ahead and try". +pub fn unplayable_path_reason(path: &std::path::Path) -> Option<&'static str> { + unplayable_reason(&ext_of(path)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + /// The bug the single table exists to prevent: the scanner indexed a + /// format the pruner did not recognise, so importing an AIFF album put it + /// in the library and the next startup silently deleted it. + #[test] + fn everything_indexed_survives_the_pruner() { + for ext in PLAYABLE.iter().chain(RECOGNISED.iter()) { + assert!(is_audio_ext(ext), "{ext} indexed but not recognised as audio"); + } + // The extensions that were actually in the divergence. + for ext in ["aiff", "aif", "ape", "wv", "opus"] { + assert!(is_audio_ext(ext), "{ext} must be recognised"); + } + } + + /// A format cannot be both — every entry has one answer. + #[test] + fn no_extension_is_in_both_tables() { + for e in PLAYABLE { + assert!(!RECOGNISED.contains(e), "{e} is in both tables"); + } + } + + /// Recognised-but-not-playable must carry a reason, or the UI has nothing + /// to say when it refuses. + #[test] + fn every_unplayable_format_can_explain_itself() { + for e in RECOGNISED { + assert!(unplayable_reason(e).is_some(), "{e} has no reason"); + } + for e in PLAYABLE { + assert!(unplayable_reason(e).is_none(), "{e} plays but claims it cannot"); + } + } + + /// WavPack is the dangerous one: the decoder does NOT fail on it, so only + /// an extension check keeps NaN out of the mixer. + #[test] + fn wavpack_is_refused_before_the_decoder_sees_it() { + assert!(!is_playable_ext("wv")); + assert!(is_audio_ext("wv")); + assert_eq!( + unplayable_path_reason(Path::new("C:\\m\\a.WV")), + Some("WavPack isn't supported yet"), + "case must not decide whether audio plays" + ); + } + + #[test] + fn case_and_paths() { + assert!(is_playable_ext("FLAC")); + assert!(is_playable_path(Path::new("C:\\m\\01 - Song.Mp3"))); + assert!(!is_audio_path(Path::new("C:\\m\\cover.jpg"))); + assert!(!is_audio_path(Path::new("C:\\m\\noext"))); + } +} diff --git a/crates/library/Cargo.toml b/crates/library/Cargo.toml index f5c2f56..453b173 100644 --- a/crates/library/Cargo.toml +++ b/crates/library/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true [dependencies] rusqlite = { version = "0.32", features = ["bundled"] } +audio-formats = { path = "../audio-formats" } serde.workspace = true thiserror.workspace = true log.workspace = true diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index c0a1775..a9212ce 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -10,6 +10,10 @@ use std::sync::Mutex; pub enum LibraryError { #[error("sqlite: {0}")] Sqlite(#[from] rusqlite::Error), + /// A reorder that is not a permutation of the playlist's current rows — + /// a stale caller, refused rather than silently dropping rows. + #[error("playlist order does not match its rows")] + OrderMismatch, } pub type Result = std::result::Result; @@ -30,6 +34,14 @@ pub struct Album { pub struct Track { pub id: i64, pub album_id: i64, + /// Which disc of the release this track belongs to, 1-based. None for a + /// single-disc album, which is nearly all of them. + /// + /// Without it a multi-disc release is unreadable: every disc numbers its + /// tracks from 1, so eleven volumes of Analord filed as one album put + /// eleven "track 1"s at the top of the list, in alphabetical order, and + /// nothing anywhere said which volume any of them came from. + pub disc_no: Option, pub track_no: Option, pub title: String, pub artist: String, @@ -46,8 +58,86 @@ pub struct Playlist { pub id: i64, pub name: String, pub track_count: u32, + pub description: Option, + /// "manual" for ordinary playlists, "setlist" for imported timestamped + /// tracklists. A kind describes what the rows MEAN (a setlist row carries + /// a position in a source recording), not where the list came from. + pub kind: String, + pub pinned: bool, + /// Managed custom cover, when one has been chosen. The automatic mosaic is + /// composed on demand from `art_seeds` and never stored here. + pub cover_path: Option, + pub created_at: i64, + pub updated_at: i64, + /// Bumped by every mutation. The frontend uses it to drop stale fetches + /// and to key the composed mosaic cache. + pub revision: i64, + /// Sum of the rows' known durations, seconds. + pub duration_secs: i64, + /// Rows that cannot play right now: imported entries never matched to a + /// local file. (Rows whose file has since vanished are found at read time + /// by the command layer, not stored.) + pub unresolved_count: u32, + /// First four DISTINCT art paths in playlist order — the mosaic's seed. + pub art_seeds: Vec, } +/// One occurrence in a playlist. `id` is the row's own identity: the same +/// recording may appear twice, and a reorder must never change which row a +/// remove or highlight addresses. Position is layout, not identity. +#[derive(Debug, Clone, Serialize)] +pub struct PlaylistItem { + pub id: i64, + pub position: i64, + pub title: String, + pub artist: String, + pub album: Option, + pub duration_secs: Option, + /// The playable local file, when resolution found one. An unresolved + /// import row keeps its metadata and order with no path at all. + pub path: Option, + pub art_path: Option, + /// "local" (has a playable path) | "unresolved" (import placeholder). + pub kind: String, + /// Sanitized provenance: the raw locator or pasted line this row came + /// from. Repair tools read it; nothing ever executes it. + pub source_line: Option, + /// Setlist timestamp: where in the source recording this occurrence + /// begins, milliseconds. + pub start_ms: Option, + pub note: Option, +} + +/// Insert payload for one playlist row (`add_playlist_items` / `import_playlist`). +#[derive(Debug, Clone, Default)] +pub struct NewPlaylistItem { + pub title: String, + pub artist: String, + pub album: Option, + pub duration_secs: Option, + pub path: Option, + pub art_path: Option, + /// "local" | "unresolved"; empty defaults to "local" when a path exists. + pub kind: String, + pub source_line: Option, + pub start_ms: Option, + pub note: Option, +} + +/// One row of the whole-library matching index (playlist import resolution: +/// the importer loads this once and matches parsed entries in memory, so the +/// database lock is held for one read, never per entry). +#[derive(Debug, Clone, Serialize)] +pub struct TrackIndexRow { + pub title: String, + pub artist: String, + pub album: String, + pub duration_secs: Option, + pub path: String, + pub art_path: Option, +} + +/// Legacy row shape (pre-occurrence-identity), kept for the migration copy. #[derive(Debug, Clone, Serialize)] pub struct PlaylistTrack { pub position: i64, @@ -137,7 +227,7 @@ pub struct Library { impl Library { pub fn open(db_path: &Path) -> Result { - let conn = Connection::open(db_path)?; + let mut conn = Connection::open(db_path)?; conn.execute_batch( " PRAGMA journal_mode = WAL; @@ -155,6 +245,7 @@ impl Library { CREATE TABLE IF NOT EXISTS tracks ( id INTEGER PRIMARY KEY, album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE, + disc_no INTEGER, track_no INTEGER, title TEXT NOT NULL, artist TEXT NOT NULL, @@ -182,6 +273,29 @@ impl Library { art_path TEXT, PRIMARY KEY (playlist_id, position) ); + -- The occurrence table that replaced playlist_tracks. Every row has + -- its OWN id: position is layout, never identity, so duplicates and + -- reorders are safe and an import can keep unresolved placeholders + -- (path NULL) in their source order. playlist_tracks stays behind, + -- read once by the migration below and never written again. + CREATE TABLE IF NOT EXISTS playlist_items ( + id INTEGER PRIMARY KEY, + playlist_id INTEGER NOT NULL, + position INTEGER NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL DEFAULT '', + album TEXT, + duration_secs INTEGER, + path TEXT, + art_path TEXT, + kind TEXT NOT NULL DEFAULT 'local', + source_line TEXT, + start_ms INTEGER, + note TEXT, + added_at INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_playlist_items + ON playlist_items(playlist_id, position); CREATE TABLE IF NOT EXISTS plays ( id INTEGER PRIMARY KEY, artist TEXT NOT NULL, @@ -197,6 +311,14 @@ impl Library { portrait TEXT, followed_at INTEGER NOT NULL ); + -- What this database knows about ITSELF. `library_root` is the + -- folder its paths were last written against; when that changes, + -- every stored path is re-based (see `rebase_to_root`), which is + -- what lets the library travel between machines and drive letters. + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); ", )?; // Migrate databases created before artist portraits were added. @@ -244,6 +366,33 @@ impl Library { let _ = conn.execute("ALTER TABLE tracks ADD COLUMN sample_rate INTEGER", []); let _ = conn.execute("ALTER TABLE tracks ADD COLUMN bit_depth INTEGER", []); let _ = conn.execute("ALTER TABLE tracks ADD COLUMN size INTEGER", []); + // Migrate databases created before a release could have more than one + // disc. Existing rows stay NULL, which orders exactly as they did + // before — a null disc sorts with every other null disc, so a + // single-disc album is untouched and a multi-disc one is only fixed + // once its files are read again. + let _ = conn.execute("ALTER TABLE tracks ADD COLUMN disc_no INTEGER", []); + // Playlist management upgrade (2026-08): metadata the original + // three-column table never carried. + let _ = conn.execute("ALTER TABLE playlists ADD COLUMN description TEXT", []); + let _ = conn.execute( + "ALTER TABLE playlists ADD COLUMN kind TEXT NOT NULL DEFAULT 'manual'", + [], + ); + let _ = conn.execute( + "ALTER TABLE playlists ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", + [], + ); + let _ = conn.execute("ALTER TABLE playlists ADD COLUMN cover_path TEXT", []); + let _ = conn.execute( + "ALTER TABLE playlists ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE playlists ADD COLUMN revision INTEGER NOT NULL DEFAULT 0", + [], + ); + migrate_playlist_rows(&mut conn)?; Ok(Library { conn: Mutex::new(conn), }) @@ -494,6 +643,7 @@ impl Library { pub fn upsert_track( &self, album_id: i64, + disc_no: Option, track_no: Option, title: &str, artist: &str, @@ -507,10 +657,11 @@ impl Library { ) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO tracks (album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, added_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + "INSERT INTO tracks (album_id, disc_no, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(path) DO UPDATE SET album_id = excluded.album_id, + disc_no = excluded.disc_no, track_no = excluded.track_no, title = excluded.title, artist = excluded.artist, @@ -519,7 +670,7 @@ impl Library { sample_rate = excluded.sample_rate, bit_depth = excluded.bit_depth, size = excluded.size", - rusqlite::params![album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, now], + rusqlite::params![album_id, disc_no, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, now], )?; let id = conn.query_row( "SELECT id FROM tracks WHERE path = ?1", @@ -726,23 +877,28 @@ impl Library { pub fn album_tracks(&self, album_id: i64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path + // Disc first, and a disc that says nothing sorts BEFORE one that + // does: a single-disc album is all nulls and orders exactly as it + // always has, while a release whose discs are known plays 1 then 2 + // rather than interleaving eleven track-1s. + "SELECT id, album_id, disc_no, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path FROM tracks WHERE album_id = ?1 - ORDER BY track_no IS NULL, track_no, title", + ORDER BY disc_no IS NOT NULL, disc_no, track_no IS NULL, track_no, title", )?; let rows = stmt.query_map([album_id], |r| { Ok(Track { id: r.get(0)?, album_id: r.get(1)?, - track_no: r.get(2)?, - title: r.get(3)?, - artist: r.get(4)?, - duration_secs: r.get(5)?, - bitrate_kbps: r.get(6)?, - sample_rate: r.get(7)?, - bit_depth: r.get(8)?, - size: r.get::<_, Option>(9)?.map(|n| n as u64), - path: r.get(10)?, + disc_no: r.get(2)?, + track_no: r.get(3)?, + title: r.get(4)?, + artist: r.get(5)?, + duration_secs: r.get(6)?, + bitrate_kbps: r.get(7)?, + sample_rate: r.get(8)?, + bit_depth: r.get(9)?, + size: r.get::<_, Option>(10)?.map(|n| n as u64), + path: r.get(11)?, }) })?; Ok(rows.filter_map(|r| r.ok()).collect()) @@ -837,102 +993,461 @@ impl Library { } // ---- playlists ---- + // + // Every mutation bumps the playlist's revision and updated_at inside the + // same transaction as the change itself, so a revision can never describe + // rows it did not see. Multi-row edits are single transactions: a failed + // import or reorder leaves the playlist exactly as it was. - pub fn create_playlist(&self, name: &str, now: i64) -> Result { + pub fn create_playlist(&self, name: &str, kind: &str, now: i64) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO playlists (name, created_at) VALUES (?1, ?2)", - rusqlite::params![name, now], + "INSERT INTO playlists (name, kind, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", + rusqlite::params![name, kind, now], )?; Ok(conn.last_insert_rowid()) } - pub fn rename_playlist(&self, id: i64, name: &str) -> Result<()> { + pub fn rename_playlist(&self, id: i64, name: &str, now: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( - "UPDATE playlists SET name = ?2 WHERE id = ?1", - rusqlite::params![id, name], + "UPDATE playlists SET name = ?2, updated_at = ?3, revision = revision + 1 + WHERE id = ?1", + rusqlite::params![id, name, now], + )?; + Ok(()) + } + + pub fn set_playlist_description(&self, id: i64, description: Option<&str>, now: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE playlists SET description = ?2, updated_at = ?3, revision = revision + 1 + WHERE id = ?1", + rusqlite::params![id, description, now], + )?; + Ok(()) + } + + pub fn set_playlist_pinned(&self, id: i64, pinned: bool, now: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE playlists SET pinned = ?2, updated_at = ?3 WHERE id = ?1", + rusqlite::params![id, pinned as i64, now], + )?; + Ok(()) + } + + /// `cover_path` None clears the custom cover (the automatic mosaic takes + /// over again — "custom wins, removing it restores the fallback"). + pub fn set_playlist_cover(&self, id: i64, cover_path: Option<&str>, now: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE playlists SET cover_path = ?2, updated_at = ?3, revision = revision + 1 + WHERE id = ?1", + rusqlite::params![id, cover_path, now], )?; Ok(()) } pub fn delete_playlist(&self, id: i64) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute("DELETE FROM playlist_tracks WHERE playlist_id = ?1", [id])?; - conn.execute("DELETE FROM playlists WHERE id = ?1", [id])?; + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute("DELETE FROM playlist_items WHERE playlist_id = ?1", [id])?; + tx.execute("DELETE FROM playlist_tracks WHERE playlist_id = ?1", [id])?; + tx.execute("DELETE FROM playlists WHERE id = ?1", [id])?; + tx.commit()?; Ok(()) } - /// (id, name, track_count), newest first. + /// All playlists with their aggregates, pinned first, then newest first. pub fn list_playlists(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT p.id, p.name, - (SELECT COUNT(*) FROM playlist_tracks t WHERE t.playlist_id = p.id) - FROM playlists p ORDER BY p.created_at DESC", + "SELECT p.id, p.name, p.description, p.kind, p.pinned, p.cover_path, + p.created_at, p.updated_at, p.revision, + (SELECT COUNT(*) FROM playlist_items i WHERE i.playlist_id = p.id), + (SELECT COALESCE(SUM(i.duration_secs), 0) FROM playlist_items i + WHERE i.playlist_id = p.id), + (SELECT COUNT(*) FROM playlist_items i + WHERE i.playlist_id = p.id AND i.kind = 'unresolved') + FROM playlists p + ORDER BY p.pinned DESC, p.created_at DESC", )?; - let rows = stmt.query_map([], |r| { - Ok(Playlist { - id: r.get(0)?, - name: r.get(1)?, - track_count: r.get::<_, i64>(2)? as u32, - }) + let mut lists: Vec = stmt + .query_map([], |r| { + Ok(Playlist { + id: r.get(0)?, + name: r.get(1)?, + description: r.get(2)?, + kind: r.get(3)?, + pinned: r.get::<_, i64>(4)? != 0, + cover_path: r.get(5)?, + created_at: r.get(6)?, + updated_at: r.get(7)?, + revision: r.get(8)?, + track_count: r.get::<_, i64>(9)? as u32, + duration_secs: r.get(10)?, + unresolved_count: r.get::<_, i64>(11)? as u32, + art_seeds: Vec::new(), + }) + })? + .collect::>()?; + // Mosaic seeds: the first four DISTINCT art paths in playlist order. + // One pass over all rows rather than a query per playlist — a sidebar + // refresh runs this, and a playlist is a few hundred rows at most. + let mut seed_stmt = conn.prepare( + "SELECT playlist_id, art_path FROM playlist_items + WHERE art_path IS NOT NULL ORDER BY playlist_id, position", + )?; + let mut seeds: std::collections::HashMap> = std::collections::HashMap::new(); + let rows = seed_stmt.query_map([], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) })?; - Ok(rows.filter_map(|r| r.ok()).collect()) + for row in rows.flatten() { + let (pid, art) = row; + let list = seeds.entry(pid).or_default(); + if list.len() < 4 && !list.contains(&art) { + list.push(art); + } + } + for playlist in &mut lists { + if let Some(list) = seeds.remove(&playlist.id) { + playlist.art_seeds = list; + } + } + Ok(lists) } - pub fn add_to_playlist(&self, playlist_id: i64, t: &PlaylistTrack) -> Result<()> { + pub fn playlist_meta(&self, id: i64) -> Result> { + Ok(self.list_playlists()?.into_iter().find(|p| p.id == id)) + } + + pub fn playlist_items(&self, playlist_id: i64) -> Result> { let conn = self.conn.lock().unwrap(); - let next: i64 = conn.query_row( - "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1", + let mut stmt = conn.prepare( + "SELECT id, position, title, artist, album, duration_secs, path, art_path, + kind, source_line, start_ms, note + FROM playlist_items WHERE playlist_id = ?1 ORDER BY position", + )?; + let rows = stmt.query_map([playlist_id], |r| { + Ok(PlaylistItem { + id: r.get(0)?, + position: r.get(1)?, + title: r.get(2)?, + artist: r.get(3)?, + album: r.get(4)?, + duration_secs: r.get(5)?, + path: r.get(6)?, + art_path: r.get(7)?, + kind: r.get(8)?, + source_line: r.get(9)?, + start_ms: r.get(10)?, + note: r.get(11)?, + }) + })?; + rows.collect::>>().map_err(Into::into) + } + + /// Append (or insert at `at`) a batch of rows in ONE transaction. A batch + /// that fails leaves nothing behind — the old per-row loop could commit + /// half an album. Returns how many rows were added. + pub fn add_playlist_items( + &self, + playlist_id: i64, + items: &[NewPlaylistItem], + at: Option, + now: i64, + ) -> Result { + if items.is_empty() { + return Ok(0); + } + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + // The playlist must exist: playlist_items has no enforced FK, so an + // add racing a delete would otherwise write permanent invisible rows. + let exists: i64 = tx.query_row( + "SELECT COUNT(*) FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0), )?; - conn.execute( - "INSERT INTO playlist_tracks - (playlist_id, position, title, artist, duration_secs, path, art_path) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - rusqlite::params![ - playlist_id, - next, - t.title, - t.artist, - t.duration_secs, - t.path, - t.art_path - ], + if exists == 0 { + return Err(LibraryError::Sqlite(rusqlite::Error::QueryReturnedNoRows)); + } + let count: i64 = tx.query_row( + "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?1", + [playlist_id], + |r| r.get(0), )?; + let insert_at = at.unwrap_or(count).clamp(0, count); + if insert_at < count { + // Shift the tail out of the way. Offset by a large stride first so + // the (playlist, position) pairs never collide mid-update. + tx.execute( + "UPDATE playlist_items SET position = position + 1000000 + WHERE playlist_id = ?1 AND position >= ?2", + rusqlite::params![playlist_id, insert_at], + )?; + tx.execute( + "UPDATE playlist_items + SET position = position - 1000000 + ?3 + WHERE playlist_id = ?1 AND position >= 1000000 + ?2", + rusqlite::params![playlist_id, insert_at, items.len() as i64], + )?; + } + { + let mut ins = tx.prepare( + "INSERT INTO playlist_items + (playlist_id, position, title, artist, album, duration_secs, + path, art_path, kind, source_line, start_ms, note, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + )?; + for (offset, item) in items.iter().enumerate() { + let kind = if item.kind.is_empty() { + if item.path.is_some() { "local" } else { "unresolved" } + } else { + &item.kind + }; + ins.execute(rusqlite::params![ + playlist_id, + insert_at + offset as i64, + item.title, + item.artist, + item.album, + item.duration_secs, + item.path, + item.art_path, + kind, + item.source_line, + item.start_ms, + item.note, + now, + ])?; + } + } + Self::touch_playlist(&tx, playlist_id, now)?; + tx.commit()?; + Ok(items.len() as u32) + } + + /// Remove rows by their own ids and close the position gaps, atomically. + pub fn remove_playlist_items(&self, playlist_id: i64, item_ids: &[i64], now: i64) -> Result { + if item_ids.is_empty() { + return Ok(0); + } + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let mut removed = 0u32; + for id in item_ids { + removed += tx.execute( + "DELETE FROM playlist_items WHERE playlist_id = ?1 AND id = ?2", + rusqlite::params![playlist_id, id], + )? as u32; + } + Self::compact_positions(&tx, playlist_id)?; + Self::touch_playlist(&tx, playlist_id, now)?; + tx.commit()?; + Ok(removed) + } + + /// Persist a complete new order, given every item id in its new sequence. + /// Refuses an order that is not a permutation of the playlist's rows — + /// a stale frontend must not silently drop or duplicate anything. + pub fn set_playlist_order(&self, playlist_id: i64, ordered_ids: &[i64], now: i64) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let existing: Vec = tx + .prepare("SELECT id FROM playlist_items WHERE playlist_id = ?1")? + .query_map([playlist_id], |r| r.get(0))? + .collect::>()?; + let mut want: Vec = ordered_ids.to_vec(); + let mut have = existing.clone(); + want.sort_unstable(); + have.sort_unstable(); + if want != have { + return Err(LibraryError::OrderMismatch); + } + // Two passes through a disjoint range so old and new positions never + // collide under the (playlist, position) index. + tx.execute( + "UPDATE playlist_items SET position = position + 1000000 WHERE playlist_id = ?1", + [playlist_id], + )?; + { + let mut set = tx.prepare( + "UPDATE playlist_items SET position = ?3 WHERE playlist_id = ?1 AND id = ?2", + )?; + for (position, id) in ordered_ids.iter().enumerate() { + set.execute(rusqlite::params![playlist_id, id, position as i64])?; + } + } + Self::touch_playlist(&tx, playlist_id, now)?; + tx.commit()?; Ok(()) } - pub fn playlist_tracks(&self, playlist_id: i64) -> Result> { + /// Repair one row: attach a playable local file (or correct its display + /// metadata). Passing `path` Some flips the row to "local". + #[allow(clippy::too_many_arguments)] + pub fn relink_playlist_item( + &self, + playlist_id: i64, + item_id: i64, + title: Option<&str>, + artist: Option<&str>, + album: Option<&str>, + duration_secs: Option, + path: Option<&str>, + art_path: Option<&str>, + now: i64, + ) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute( + "UPDATE playlist_items SET + title = COALESCE(?3, title), + artist = COALESCE(?4, artist), + album = COALESCE(?5, album), + duration_secs = COALESCE(?6, duration_secs), + path = COALESCE(?7, path), + art_path = COALESCE(?8, art_path), + kind = CASE WHEN ?7 IS NOT NULL THEN 'local' ELSE kind END + WHERE playlist_id = ?1 AND id = ?2", + rusqlite::params![playlist_id, item_id, title, artist, album, duration_secs, path, art_path], + )?; + Self::touch_playlist(&tx, playlist_id, now)?; + tx.commit()?; + Ok(()) + } + + /// Copy a playlist — metadata (except pin state) and every row, in order. + pub fn duplicate_playlist(&self, id: i64, new_name: &str, now: i64) -> Result { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let inserted = tx.execute( + "INSERT INTO playlists (name, description, kind, created_at, updated_at) + SELECT ?2, description, kind, ?3, ?3 FROM playlists WHERE id = ?1", + rusqlite::params![id, new_name, now], + )?; + if inserted == 0 { + // The source vanished (raced a delete). Without this check, + // last_insert_rowid would hand back some EARLIER insert's id. + return Err(LibraryError::Sqlite(rusqlite::Error::QueryReturnedNoRows)); + } + let new_id = tx.last_insert_rowid(); + tx.execute( + "INSERT INTO playlist_items + (playlist_id, position, title, artist, album, duration_secs, + path, art_path, kind, source_line, start_ms, note, added_at) + SELECT ?2, position, title, artist, album, duration_secs, + path, art_path, kind, source_line, start_ms, note, ?3 + FROM playlist_items WHERE playlist_id = ?1", + rusqlite::params![id, new_id, now], + )?; + tx.commit()?; + Ok(new_id) + } + + /// One transactional import: the playlist and all its ordered rows land + /// together or not at all. + pub fn import_playlist( + &self, + name: &str, + description: Option<&str>, + kind: &str, + items: &[NewPlaylistItem], + now: i64, + ) -> Result { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO playlists (name, description, kind, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?4)", + rusqlite::params![name, description, kind, now], + )?; + let playlist_id = tx.last_insert_rowid(); + { + let mut ins = tx.prepare( + "INSERT INTO playlist_items + (playlist_id, position, title, artist, album, duration_secs, + path, art_path, kind, source_line, start_ms, note, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + )?; + for (position, item) in items.iter().enumerate() { + let kind = if item.kind.is_empty() { + if item.path.is_some() { "local" } else { "unresolved" } + } else { + &item.kind + }; + ins.execute(rusqlite::params![ + playlist_id, + position as i64, + item.title, + item.artist, + item.album, + item.duration_secs, + item.path, + item.art_path, + kind, + item.source_line, + item.start_ms, + item.note, + now, + ])?; + } + } + tx.commit()?; + Ok(playlist_id) + } + + /// Every track with its album context, for in-memory import matching. + pub fn track_index(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT position, title, artist, duration_secs, path, art_path - FROM playlist_tracks WHERE playlist_id = ?1 ORDER BY position", + "SELECT t.title, t.artist, a.title, t.duration_secs, t.path, a.art_path + FROM tracks t JOIN albums a ON a.id = t.album_id", )?; - let rows = stmt.query_map([playlist_id], |r| { - Ok(PlaylistTrack { - position: r.get(0)?, - title: r.get(1)?, - artist: r.get(2)?, + let rows = stmt.query_map([], |r| { + Ok(TrackIndexRow { + title: r.get(0)?, + artist: r.get(1)?, + album: r.get(2)?, duration_secs: r.get(3)?, path: r.get(4)?, art_path: r.get(5)?, }) })?; - Ok(rows.filter_map(|r| r.ok()).collect()) + rows.collect::>>().map_err(Into::into) } - pub fn remove_from_playlist(&self, playlist_id: i64, position: i64) -> Result<()> { - let conn = self.conn.lock().unwrap(); + fn touch_playlist(conn: &Connection, id: i64, now: i64) -> rusqlite::Result<()> { conn.execute( - "DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND position = ?2", - rusqlite::params![playlist_id, position], + "UPDATE playlists SET updated_at = ?2, revision = revision + 1 WHERE id = ?1", + rusqlite::params![id, now], )?; Ok(()) } + /// Rewrite positions 0..n preserving current order. + fn compact_positions(conn: &Connection, playlist_id: i64) -> rusqlite::Result<()> { + let ids: Vec = conn + .prepare( + "SELECT id FROM playlist_items WHERE playlist_id = ?1 ORDER BY position", + )? + .query_map([playlist_id], |r| r.get(0))? + .collect::>()?; + conn.execute( + "UPDATE playlist_items SET position = position + 1000000 WHERE playlist_id = ?1", + [playlist_id], + )?; + let mut set = conn.prepare( + "UPDATE playlist_items SET position = ?3 WHERE playlist_id = ?1 AND id = ?2", + )?; + for (position, id) in ids.iter().enumerate() { + set.execute(rusqlite::params![playlist_id, id, position as i64])?; + } + Ok(()) + } + /// Drop tracks whose files vanished; returns how many were removed. pub fn prune_missing(&self) -> Result { let conn = self.conn.lock().unwrap(); @@ -986,6 +1501,232 @@ impl Library { Ok(repaired) } + /// Every column in the schema that holds a filesystem path. + /// `(table, column)` — kept in one place so a new path column cannot be + /// silently left behind when the library moves. + const PATH_COLUMNS: &'static [(&'static str, &'static str)] = &[ + ("tracks", "path"), + ("albums", "art_path"), + ("albums", "artist_art_path"), + ("playlist_tracks", "path"), + ("playlist_tracks", "art_path"), + ("playlist_items", "path"), + ("playlist_items", "art_path"), + ("playlists", "cover_path"), + ]; + + /// Repoint every stored path from one folder to another. + /// + /// This is what makes the library portable. Paths are stored absolute — + /// converting the whole schema to relative would mean touching some + /// twenty-five read and write sites, and a single missed READ site is a + /// silent corruption. Rewriting the prefix once, when the folder actually + /// changes, gets the same portability with one place to be wrong. + /// + /// Windows path comparison is case-insensitive, and only a match at a + /// component boundary counts — `…\SoulSeek Music 2` is not inside + /// `…\SoulSeek Music`. + pub fn rewrite_path_prefix(&self, from: &Path, to: &Path) -> Result { + let norm = |p: &Path| { + p.to_string_lossy() + .trim_end_matches(['/', '\\']) + .replace('/', "\\") + }; + let (from, to) = (norm(from), norm(to)); + if from.is_empty() || from.eq_ignore_ascii_case(&to) { + return Ok(0); + } + let conn = self.conn.lock().unwrap(); + let mut changed = 0; + for (table, column) in Self::PATH_COLUMNS { + // LIKE is case-insensitive for ASCII in SQLite, which is the + // behaviour wanted here; the escape keeps a literal % or _ in a + // folder name from turning into a wildcard. + let sql = format!( + "UPDATE {table} + SET {column} = ?2 || substr({column}, ?3) + WHERE {column} LIKE ?1 ESCAPE '\\'" + ); + // Backslashes are doubled FIRST — escaping `%`/`_` afterwards adds + // escape backslashes that must not themselves be doubled. The tail + // is an escaped separator followed by a real wildcard (`\\` then + // `%`); writing `\%` there would ask for a literal percent sign and + // match nothing at all. + let pattern = format!( + "{}\\\\%", + from.replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") + ); + // substr is 1-based and the prefix keeps its trailing separator + // on the new side, so the cut starts one past the old prefix. + changed += conn.execute( + &sql, + rusqlite::params![pattern, to, (from.chars().count() + 1) as i64], + )?; + } + Ok(changed) + } + + /// Read a remembered value (the library root this database was last + /// opened against, and anything else worth surviving a restart). + pub fn meta(&self, key: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + Ok(conn + .query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0)) + .optional()?) + } + + pub fn set_meta(&self, key: &str, value: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + rusqlite::params![key, value], + )?; + Ok(()) + } + + /// Point the library at `root`, rewriting stored paths if it moved. + /// + /// A database that has never recorded a root simply learns the current one + /// — its paths are already correct for wherever it is being opened from. + /// After that, any change (a new drive letter, a folder carried to another + /// machine, a renamed library) re-bases every path on first open. + pub fn rebase_to_root(&self, root: &Path) -> Result { + let current = root.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let previous = self.meta("library_root")?; + let moved = match previous.as_deref() { + Some(old) if !old.eq_ignore_ascii_case(¤t) => { + self.rewrite_path_prefix(Path::new(old), root)? + } + _ => 0, + }; + self.set_meta("library_root", ¤t)?; + Ok(moved) + } + + /// Drop everything that does not live under `root` — the library folder. + /// + /// The library folder is the library. Everything that legitimately enters + /// it is organized INTO that folder (`save_to_library` and the download + /// pipeline both do), so a row whose file sits somewhere else belongs to a + /// library that is no longer the one being shown. + /// + /// Changing the folder used to leave those rows behind for good: the + /// scanner started reading the new folder while `prune_missing` kept every + /// old row alive on the grounds that its file still existed. The library + /// became the UNION of every folder that had ever been the library folder, + /// and emptying the current one could not empty it — six albums from a + /// previous folder on another drive outlived a wipe of the whole library. + /// + /// This removes rows only. Files on disk are never touched, so pointing the + /// setting back at the old folder re-indexes it from scratch. + pub fn prune_outside_root(&self, root: &Path) -> Result { + let conn = self.conn.lock().unwrap(); + let paths: Vec<(i64, String)> = conn + .prepare("SELECT id, path FROM tracks")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + let mut removed = 0; + for (id, path) in paths { + if !is_under(Path::new(&path), root) { + conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; + removed += 1; + } + } + if removed > 0 { + conn.execute( + "DELETE FROM albums + WHERE id NOT IN (SELECT album_id FROM tracks WHERE album_id IS NOT NULL)", + [], + )?; + } + Ok(removed) + } + + /// Re-anchor album covers to the folder the album's own tracks live in. + /// + /// Every writer of `art_path` derives it from the album's own folder, so + /// the stored value is right when it is written and stays right until the + /// FILES move out from under it — a re-import into a new layout, a library + /// reorganized by hand, a drive that comes back at a different letter. + /// Nothing re-asks after a move: `upsert_album` COALESCEs art (it will not + /// overwrite a value it already has) and the startup scan only processes + /// files it does not already know. So a stale path survives indefinitely, + /// and because every album folder names its cover `cover.jpg`, a stale + /// path does not read as broken — it reads as ANOTHER ALBUM'S COVER, which + /// renders perfectly and is simply the wrong picture. + /// + /// The invariant this restores: an album's cover lives either in one of + /// that album's own track folders, or in the shared art cache. A path + /// pointing anywhere else is somebody else's artwork. + /// + /// Deliberately conservative — it only ever moves a cover to a file that + /// EXISTS, and never clears one. A missing file is left alone: with the + /// music drive unplugged every path is missing at once, and that is not + /// the moment to rewrite the library's idea of its own artwork. + pub fn reanchor_album_art(&self, art_dir: &Path) -> Result { + let conn = self.conn.lock().unwrap(); + let rows: Vec<(i64, Option, String)> = conn + .prepare( + "SELECT a.id, a.art_path, t.path + FROM albums a JOIN tracks t ON t.album_id = a.id + ORDER BY a.id, t.track_no IS NULL, t.track_no, t.title", + )? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .filter_map(|r| r.ok()) + .collect(); + + // Group by album, preserving track order so "the album's folder" means + // the first track's folder for a normal album and still sees every + // folder of a multi-disc spread. + let mut albums: Vec<(i64, Option, Vec)> = Vec::new(); + for (id, art, track) in rows { + let dir = match Path::new(&track).parent() { + Some(d) => d.to_path_buf(), + None => continue, + }; + match albums.last_mut() { + Some((last, _, dirs)) if *last == id => { + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + _ => albums.push((id, art, vec![dir])), + } + } + + let mut moved = 0; + for (id, art_path, dirs) in albums { + let Some(own) = dirs.iter().find_map(|d| existing_cover(d)) else { + continue; + }; + let keep = match art_path.as_deref() { + None => false, + Some(p) => { + let file = Path::new(p); + // A path that is not on disk is not evidence of anything — + // see the note above about an absent drive. + !file.is_file() + || file.parent().is_some_and(|parent| { + dirs.iter().any(|d| same_dir(parent, d)) || same_dir(parent, art_dir) + }) + } + }; + if keep || art_path.as_deref() == Some(own.as_str()) { + continue; + } + conn.execute( + "UPDATE albums SET art_path = ?2 WHERE id = ?1", + rusqlite::params![id, own], + )?; + moved += 1; + } + Ok(moved) + } + /// Remove rows accidentally created for non-audio download extras (cover /// images, playlists, text files, etc.). The library should only ever /// expose playable audio tracks. @@ -998,17 +1739,11 @@ impl Library { .collect(); let mut removed = 0; for (id, path) in rows { - let audio = Path::new(&path) - .extension() - .and_then(|e| e.to_str()) - .map(|e| { - matches!( - e.to_ascii_lowercase().as_str(), - "flac" | "mp3" | "m4a" | "ogg" | "opus" | "wav" | "aac" | "wma" - ) - }) - .unwrap_or(false); - if !audio { + // The SAME table the scanner indexes by. This list used to be its + // own, shorter one — it knew nothing of `.aiff`, `.aif` or `.ape`, + // so every AIFF album the scanner added was deleted here on the + // next startup, with the album row following it out. + if !audio_formats::is_audio_path(Path::new(&path)) { conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; removed += 1; } @@ -1021,21 +1756,382 @@ impl Library { } } +/// One-time copy of legacy `playlist_tracks` rows into `playlist_items`. +/// +/// Runs inside a single transaction and marks itself done in `meta`, so a +/// half-finished copy can never be observed and a second open never doubles +/// the rows. The legacy table is left in place, read-only — it is the backup +/// until this migration has been trusted for a while. +/// +/// Order is preserved exactly (including gaps left by the old +/// remove-by-position, which never compacted), and duplicates survive: each +/// legacy row becomes its own occurrence with a fresh id. +fn migrate_playlist_rows(conn: &mut Connection) -> Result<()> { + let done: Option = conn + .query_row( + "SELECT value FROM meta WHERE key = 'playlist_items_migrated'", + [], + |r| r.get(0), + ) + .optional()?; + if done.is_some() { + return Ok(()); + } + let tx = conn.transaction()?; + // A database that already has item rows but no marker predates nothing — + // it was created by this code; just stamp it. + let already: i64 = tx.query_row("SELECT COUNT(*) FROM playlist_items", [], |r| r.get(0))?; + if already == 0 { + tx.execute( + "INSERT INTO playlist_items + (playlist_id, position, title, artist, album, duration_secs, + path, art_path, kind, added_at) + SELECT playlist_id, + (SELECT COUNT(*) FROM playlist_tracks q + WHERE q.playlist_id = t.playlist_id AND q.position < t.position), + title, artist, NULL, duration_secs, + path, art_path, 'local', 0 + FROM playlist_tracks t + ORDER BY playlist_id, position", + [], + )?; + let copied = tx.query_row("SELECT COUNT(*) FROM playlist_items", [], |r| r.get::<_, i64>(0))?; + let legacy: i64 = tx.query_row("SELECT COUNT(*) FROM playlist_tracks", [], |r| r.get(0))?; + if copied != legacy { + // Refuse to stamp a copy that lost rows; the transaction unwinds. + return Err(LibraryError::OrderMismatch); + } + } + tx.execute( + "INSERT INTO meta (key, value) VALUES ('playlist_items_migrated', '1') + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [], + )?; + tx.commit()?; + Ok(()) +} + fn cached_art_path(path: &str, art_dir: &Path) -> Option { if Path::new(path).is_file() { return None; } let name = Path::new(path).file_name()?; + // A file name is only an identity when it names THIS album. Every album + // folder calls its cover `cover.jpg`, so matching on that name alone would + // repoint the whole library at one shared file the moment the music drive + // is unplugged — every path missing at once is precisely when this runs. + // The art cache names its entries after the artist and album; those are + // the names worth recovering. + if is_generic_cover_name(&name.to_string_lossy()) { + return None; + } let candidate = art_dir.join(name); candidate .is_file() .then(|| candidate.to_string_lossy().into_owned()) } +/// `cover.jpg` / `folder.jpg` and friends: the conventional names an album +/// folder gives its own artwork, which say nothing about WHICH album. +fn is_generic_cover_name(name: &str) -> bool { + let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name); + matches!( + stem.to_ascii_lowercase().as_str(), + "cover" | "folder" | "front" | "album" | "albumart" | "thumb" | "artwork" + ) +} + +/// The album folder's own cover, under the two names every writer here uses. +fn existing_cover(dir: &Path) -> Option { + ["cover.jpg", "folder.jpg"].into_iter().find_map(|name| { + let p = dir.join(name); + p.is_file().then(|| p.to_string_lossy().into_owned()) + }) +} + +/// Is `path` inside `root`? Compared as normalized text rather than by walking +/// the filesystem: this has to answer for a drive that is not plugged in. +/// +/// The boundary check matters — `…\SoulSeek Music 2` must not read as inside +/// `…\SoulSeek Music`, which a bare `starts_with` on strings would say it is. +fn is_under(path: &Path, root: &Path) -> bool { + let norm = |p: &Path| { + p.to_string_lossy() + .trim_end_matches(['/', '\\']) + .to_lowercase() + .replace('/', "\\") + }; + let (p, r) = (norm(path), norm(root)); + if r.is_empty() { + return true; + } + p.strip_prefix(&r).is_some_and(|rest| rest.starts_with('\\')) +} + +/// Directory equality for stored paths. Windows paths compare case-insensitively +/// and a trailing separator must not make a folder differ from itself. +fn same_dir(a: &Path, b: &Path) -> bool { + let norm = |p: &Path| { + p.to_string_lossy() + .trim_end_matches(['/', '\\']) + .to_lowercase() + .replace('/', "\\") + }; + norm(a) == norm(b) +} + #[cfg(test)] mod tests { use super::*; + /// Build a real album folder holding audio and its own `cover.jpg`. + fn album_folder(root: &Path, name: &str) -> std::path::PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("01.mp3"), b"audio").unwrap(); + std::fs::write(dir.join("cover.jpg"), name.as_bytes()).unwrap(); + dir + } + + /// The point of the whole portability change: carry the drive to another + /// machine, where it mounts at a different letter under a different user, + /// and the library still finds every file. The art cache travels inside + /// the library folder, so it re-bases with everything else. + #[test] + fn the_library_travels_to_another_machine() { + let dir = std::env::temp_dir().join("ssplus-lib-rebase"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let lib = Library::open(&dir.join("t.db")).unwrap(); + + let work = Path::new("D:\\Music\\SoulSeek Music"); + let home = Path::new("F:\\Media\\Library"); + + let id = lib + .upsert_album( + "AJJ", "Knife Man", None, + Some("D:\\Music\\SoulSeek Music\\.soulstream\\art\\ajj - knife man.jpg"), + Some("D:\\Music\\SoulSeek Music\\.soulstream\\art\\artist-ajj.jpg"), + None, 100, + ) + .unwrap(); + lib.upsert_track( + id, None, Some(1), "Sad Songs", "AJJ", None, None, None, None, None, + "D:\\Music\\SoulSeek Music\\AJJ\\2011 - Knife Man\\01.mp3", 100, + ) + .unwrap(); + + // First open just learns where it is — nothing to re-base yet. + assert_eq!(lib.rebase_to_root(work).unwrap(), 0); + // Now the drive is somewhere else entirely. + assert_eq!(lib.rebase_to_root(home).unwrap(), 3, "track + cover + portrait"); + + let album = lib.get_album(id).unwrap().unwrap().0; + assert_eq!( + album.art_path.as_deref(), + Some("F:\\Media\\Library\\.soulstream\\art\\ajj - knife man.jpg") + ); + assert_eq!( + album.artist_art_path.as_deref(), + Some("F:\\Media\\Library\\.soulstream\\art\\artist-ajj.jpg") + ); + assert_eq!( + lib.album_tracks(id).unwrap()[0].path, + "F:\\Media\\Library\\AJJ\\2011 - Knife Man\\01.mp3" + ); + // Opening again at the same place changes nothing. + assert_eq!(lib.rebase_to_root(home).unwrap(), 0); + } + + /// A prefix rewrite must not cut at the wrong place. The sibling folder + /// shares a name prefix, and a `%` or `_` in a folder name is a LIKE + /// wildcard unless it is escaped — either mistake silently mangles paths. + #[test] + fn rebasing_respects_folder_boundaries_and_wildcards() { + let dir = std::env::temp_dir().join("ssplus-lib-rebase-edges"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let lib = Library::open(&dir.join("t.db")).unwrap(); + + let add = |artist: &str, path: &str| { + let id = lib.upsert_album(artist, "a", None, None, None, None, 100).unwrap(); + lib.upsert_track(id, None, Some(1), "t", artist, None, None, None, None, None, path, 100) + .unwrap(); + id + }; + let inside = add("in", "F:\\Music\\Lib\\a\\01.mp3"); + let sibling = add("sib", "F:\\Music\\Lib 2\\a\\01.mp3"); + let wild = add("wild", "F:\\Music\\Lib\\100%_live\\01.mp3"); + + assert_eq!(lib.rewrite_path_prefix(Path::new("F:\\Music\\Lib"), Path::new("X:\\New")).unwrap(), 2); + assert_eq!(lib.album_tracks(inside).unwrap()[0].path, "X:\\New\\a\\01.mp3"); + assert_eq!(lib.album_tracks(wild).unwrap()[0].path, "X:\\New\\100%_live\\01.mp3"); + assert_eq!( + lib.album_tracks(sibling).unwrap()[0].path, + "F:\\Music\\Lib 2\\a\\01.mp3", + "a folder that merely starts with the old name is a different folder" + ); + + // A folder name containing a LIKE wildcard is matched literally. + assert_eq!( + lib.rewrite_path_prefix(Path::new("X:\\New\\100%_live"), Path::new("X:\\New\\live")).unwrap(), + 1 + ); + assert_eq!(lib.album_tracks(wild).unwrap()[0].path, "X:\\New\\live\\01.mp3"); + } + + /// Emptying the library folder left six albums standing, because they were + /// indexed while a DIFFERENT folder was the library and their files still + /// existed at the old location. `prune_missing` saw files on disk and kept + /// them; the library was the union of every folder ever configured. + /// + /// Also pins the boundary: a sibling folder whose name merely STARTS with + /// the root's name is outside it. + #[test] + fn a_folder_that_is_no_longer_the_library_does_not_stay_in_it() { + let dir = std::env::temp_dir().join("ssplus-lib-outside-root"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let db = dir.join("t.db"); + let lib = Library::open(&db).unwrap(); + + let add = |artist: &str, title: &str, path: &str| { + let id = lib + .upsert_album(artist, title, None, None, None, None, 100) + .unwrap(); + lib.upsert_track( + id, None, Some(1), "t", artist, None, None, None, None, None, path, 100, + ) + .unwrap(); + id + }; + let old = add("Ween", "Quebec", "C:\\Users\\Kyle\\Music\\SoulSeek+\\Ween\\2014 - Quebec\\01.flac"); + let now = add("AJJ", "Knife Man", "F:\\Music\\SoulSeek Music\\AJJ\\2011 - Knife Man\\01.mp3"); + // Case and separator style are not identity, and a sibling that shares + // a name prefix is a different folder. + let cased = add("GUM", "Ill Times", "f:/music/soulseek music/GUM/2025 - Ill Times/01.flac"); + let sibling = add("POLYSICS", "Now", "F:\\Music\\SoulSeek Music 2\\POLYSICS\\01.flac"); + + let root = Path::new("F:\\Music\\SoulSeek Music"); + assert_eq!(lib.prune_outside_root(root).unwrap(), 2, "the old folder and the sibling"); + + let left: Vec = lib.list_albums().unwrap().into_iter().map(|a| a.id).collect(); + assert!(left.contains(&now) && left.contains(&cased)); + assert!(!left.contains(&old) && !left.contains(&sibling)); + assert_eq!(left.len(), 2, "albums left with no tracks must go too"); + + // Idempotent, and an unplugged drive is still 'inside' the root: this + // is a path question, not a disk question. + assert_eq!(lib.prune_outside_root(root).unwrap(), 0); + } + + /// The bug this shipped with: 30 albums showing one other album's cover. + /// Every album folder names its artwork `cover.jpg`, so a stale path is + /// not a broken image — it is a perfectly rendered WRONG picture, and + /// nothing in the write path ever re-asks once a value is stored. + /// + /// Also pins the two paths that must NOT be touched: a cover that lives in + /// the shared art cache is legitimate, and one already inside the album's + /// own folder is correct by definition. + #[test] + fn art_is_reanchored_to_the_albums_own_folder_not_a_neighbours() { + let root = std::env::temp_dir().join("ssplus-lib-reanchor"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let art_dir = root.join("artcache"); + std::fs::create_dir_all(&art_dir).unwrap(); + let db = root.join("t.db"); + let lib = Library::open(&db).unwrap(); + + let painting = album_folder(&root, "2016 - Painting With"); + let prester = album_folder(&root, "2021 - Prester John"); + let cached = art_dir.join("ween - quebec.jpg"); + std::fs::write(&cached, b"art").unwrap(); + let quebec = album_folder(&root, "2014 - Quebec"); + + let add = |artist: &str, title: &str, art: Option<&str>, dir: &Path| { + let id = lib + .upsert_album(artist, title, None, art, None, None, 100) + .unwrap(); + lib.upsert_track( + id, None, Some(1), "t", artist, None, None, None, None, None, + dir.join("01.mp3").to_str().unwrap(), 100, + ) + .unwrap(); + id + }; + + // The damage: this album's tracks are its own, its cover is next door. + let wrong = add( + "Animal Collective", "Painting With", + Some(prester.join("cover.jpg").to_str().unwrap()), &painting, + ); + // Correct already — must be left exactly as it is. + let right = add( + "Animal Collective", "Prester John", + Some(prester.join("cover.jpg").to_str().unwrap()), &prester, + ); + // A legitimate art-cache entry — not this album's folder, still right. + let cache = add("Ween", "Quebec", Some(cached.to_str().unwrap()), &quebec); + // No art at all, but its folder has a cover sitting there unused. + let none = add("Animal Collective", "Feels", None, &album_folder(&root, "2005 - Feels")); + + let moved = lib.reanchor_album_art(&art_dir).unwrap(); + assert_eq!(moved, 2, "only the foreign cover and the empty one move"); + + let art_of = |id: i64| lib.get_album(id).unwrap().unwrap().0.art_path.unwrap(); + assert_eq!(art_of(wrong), painting.join("cover.jpg").to_str().unwrap()); + assert_eq!(art_of(right), prester.join("cover.jpg").to_str().unwrap()); + assert_eq!(art_of(cache), cached.to_str().unwrap()); + assert!(art_of(none).contains("2005 - Feels")); + + // Idempotent: a second pass has nothing left to say. + assert_eq!(lib.reanchor_album_art(&art_dir).unwrap(), 0); + } + + /// The music drive comes home from work and is not plugged in yet. Every + /// path in the library is missing at once — which is the WORST moment to + /// rewrite what the library believes about its own artwork. + #[test] + fn an_absent_drive_never_rewrites_artwork() { + let root = std::env::temp_dir().join("ssplus-lib-reanchor-absent"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let db = root.join("t.db"); + let lib = Library::open(&db).unwrap(); + + let id = lib + .upsert_album("Ween", "Quebec", None, Some("X:\\gone\\cover.jpg"), None, None, 100) + .unwrap(); + lib.upsert_track( + id, None, Some(1), "t", "Ween", None, None, None, None, None, + "X:\\gone\\01.mp3", 100, + ) + .unwrap(); + + assert_eq!(lib.reanchor_album_art(&root.join("artcache")).unwrap(), 0); + assert_eq!( + lib.get_album(id).unwrap().unwrap().0.art_path.unwrap(), + "X:\\gone\\cover.jpg" + ); + } + + /// `cover.jpg` names an album folder's artwork, never WHICH album — so it + /// must never be the key that recovers a path from the shared cache. It + /// would hand every album in the library the same picture. + #[test] + fn a_generic_cover_name_is_not_an_identity() { + let dir = std::env::temp_dir().join("ssplus-lib-generic-name"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("cover.jpg"), b"shared").unwrap(); + std::fs::write(dir.join("ween - quebec.jpg"), b"mine").unwrap(); + + assert_eq!(cached_art_path("X:\\gone\\cover.jpg", &dir), None); + assert_eq!(cached_art_path("X:\\gone\\Folder.JPG", &dir), None); + assert!(cached_art_path("X:\\gone\\ween - quebec.jpg", &dir).is_some()); + } + /// One record, played both as library files and as a stream, is ONE /// recents row. This is the "two Origins in the sidebar" bug: a saved /// album's streamed plays carry only the catalog id (album_id NULL), and @@ -1056,7 +2152,7 @@ mod tests { .upsert_album("Vanilla", "Origin", Some(2015), Some("origin.jpg"), None, None, 100) .unwrap(); lib.upsert_track( - album, Some(1), "Intro", "Vanilla", Some(120), None, None, None, None, + album, None, Some(1), "Intro", "Vanilla", Some(120), None, None, None, None, "C:\\m\\origin\\01.mp3", 100, ) .unwrap(); @@ -1103,6 +2199,181 @@ mod tests { assert_eq!(rows[0].title, "Origin"); } + /// A pre-upgrade database: playlists + position-keyed playlist_tracks, + /// including a position GAP (the old remove never compacted) and a + /// duplicate path (intentional repetition). The upgrade must carry order, + /// gaps-as-order, and both duplicate occurrences — and must run once. + #[test] + fn legacy_playlist_rows_become_occurrences() { + let dir = std::env::temp_dir().join("ssplus-lib-playlist-migrate"); + std::fs::create_dir_all(&dir).unwrap(); + let db = dir.join("t.db"); + let _ = std::fs::remove_file(&db); + { + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE playlists (id INTEGER PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL); + CREATE TABLE playlist_tracks ( + playlist_id INTEGER NOT NULL, position INTEGER NOT NULL, + title TEXT NOT NULL, artist TEXT NOT NULL, duration_secs INTEGER, + path TEXT NOT NULL, art_path TEXT, + PRIMARY KEY (playlist_id, position)); + INSERT INTO playlists VALUES (1, 'Mix', 100); + INSERT INTO playlist_tracks VALUES + (1, 0, 'Alpha', 'A', 200, 'C:\\m\\alpha.mp3', NULL), + (1, 2, 'Beta', 'B', 180, 'C:\\m\\beta.mp3', 'C:\\m\\b.jpg'), + (1, 5, 'Alpha', 'A', 200, 'C:\\m\\alpha.mp3', NULL);", + ) + .unwrap(); + } + let lib = Library::open(&db).unwrap(); + let items = lib.playlist_items(1).unwrap(); + assert_eq!(items.len(), 3); + assert_eq!( + items.iter().map(|i| i.position).collect::>(), + vec![0, 1, 2], + "gapped legacy positions become contiguous order" + ); + assert_eq!(items[0].title, "Alpha"); + assert_eq!(items[1].title, "Beta"); + assert_eq!(items[2].title, "Alpha"); + assert_ne!(items[0].id, items[2].id, "duplicates are distinct occurrences"); + // Re-opening must not double the rows. + drop(lib); + let lib = Library::open(&db).unwrap(); + assert_eq!(lib.playlist_items(1).unwrap().len(), 3); + } + + #[test] + fn playlist_editing_is_occurrence_based() { + let dir = std::env::temp_dir().join("ssplus-lib-playlist-edit"); + std::fs::create_dir_all(&dir).unwrap(); + let db = dir.join("t.db"); + let _ = std::fs::remove_file(&db); + let lib = Library::open(&db).unwrap(); + + let id = lib.create_playlist("Mix", "manual", 100).unwrap(); + let row = |title: &str, path: Option<&str>, art: Option<&str>| NewPlaylistItem { + title: title.into(), + artist: "Artist".into(), + duration_secs: Some(100), + path: path.map(String::from), + art_path: art.map(String::from), + ..Default::default() + }; + // Two copies of the same file, on purpose, plus an unresolved import row. + lib.add_playlist_items( + id, + &[ + row("One", Some("C:\\m\\one.mp3"), Some("C:\\m\\a.jpg")), + row("Two", Some("C:\\m\\two.mp3"), Some("C:\\m\\b.jpg")), + row("One", Some("C:\\m\\one.mp3"), Some("C:\\m\\a.jpg")), + row("Ghost", None, None), + ], + None, + 110, + ) + .unwrap(); + + let items = lib.playlist_items(id).unwrap(); + assert_eq!(items.len(), 4); + assert_eq!(items[3].kind, "unresolved", "no path means placeholder, never dropped"); + + // Reorder: move the SECOND "One" first; the two copies stay distinct. + let ids: Vec = items.iter().map(|i| i.id).collect(); + lib.set_playlist_order(id, &[ids[2], ids[0], ids[1], ids[3]], 120).unwrap(); + let items = lib.playlist_items(id).unwrap(); + assert_eq!(items[0].id, ids[2]); + assert_eq!(items[1].id, ids[0]); + + // A stale order (missing a row) is refused outright. + assert!(lib.set_playlist_order(id, &[ids[0], ids[1]], 121).is_err()); + + // Remove exactly one of the duplicates, by id; the other survives. + lib.remove_playlist_items(id, &[ids[0]], 130).unwrap(); + let items = lib.playlist_items(id).unwrap(); + assert_eq!(items.len(), 3); + assert!(items.iter().any(|i| i.id == ids[2] && i.title == "One")); + assert_eq!( + items.iter().map(|i| i.position).collect::>(), + vec![0, 1, 2], + "removal compacts positions" + ); + + // Insert at the top. + lib.add_playlist_items(id, &[row("Zero", Some("C:\\m\\zero.mp3"), None)], Some(0), 140) + .unwrap(); + let items = lib.playlist_items(id).unwrap(); + assert_eq!(items[0].title, "Zero"); + assert_eq!(items.len(), 4); + + // Aggregates: unresolved count, duration sum, distinct art seeds. + let meta = lib.playlist_meta(id).unwrap().unwrap(); + assert_eq!(meta.track_count, 4); + assert_eq!(meta.unresolved_count, 1); + assert_eq!(meta.duration_secs, 400, "all four rows carry 100s"); + assert_eq!(meta.art_seeds, vec!["C:\\m\\a.jpg".to_string(), "C:\\m\\b.jpg".into()]); + assert!(meta.revision > 0); + + // Duplicate the playlist: same rows, same order, its own identity. + let copy = lib.duplicate_playlist(id, "Mix copy", 150).unwrap(); + let copied = lib.playlist_items(copy).unwrap(); + assert_eq!(copied.len(), 4); + assert_eq!(copied[0].title, "Zero"); + assert_ne!(copy, id); + + // Transactional import: rows and playlist land together. + let imported = lib + .import_playlist( + "Imported set", + Some("From a pasted tracklist"), + "setlist", + &[ + NewPlaylistItem { + title: "Opener".into(), + artist: "DJ".into(), + start_ms: Some(0), + source_line: Some("00:00 DJ - Opener".into()), + ..Default::default() + }, + NewPlaylistItem { + title: "Closer".into(), + artist: "DJ".into(), + start_ms: Some(3_600_000), + path: Some("C:\\m\\closer.mp3".into()), + ..Default::default() + }, + ], + 160, + ) + .unwrap(); + let rows = lib.playlist_items(imported).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].kind, "unresolved"); + assert_eq!(rows[1].kind, "local"); + assert_eq!(rows[1].start_ms, Some(3_600_000)); + let meta = lib.playlist_meta(imported).unwrap().unwrap(); + assert_eq!(meta.kind, "setlist"); + + // Repair: the unresolved opener finds its file. + lib.relink_playlist_item( + imported, + rows[0].id, + None, + None, + None, + Some(240), + Some("C:\\m\\opener.mp3"), + None, + 170, + ) + .unwrap(); + let rows = lib.playlist_items(imported).unwrap(); + assert_eq!(rows[0].kind, "local"); + assert_eq!(rows[0].path.as_deref(), Some("C:\\m\\opener.mp3")); + assert_eq!(rows[0].source_line.as_deref(), Some("00:00 DJ - Opener")); + } + #[test] fn roundtrip() { let dir = std::env::temp_dir().join("ssplus-lib-test"); @@ -1138,6 +2409,7 @@ mod tests { lib.upsert_track( album, + None, Some(1), "Airbag", "Radiohead", @@ -1152,6 +2424,7 @@ mod tests { .unwrap(); lib.upsert_track( album, + None, Some(2), "Paranoid Android", "Radiohead", @@ -1178,10 +2451,14 @@ mod tests { .has_track("Radiohead", "OK Computer", "Karma Police") .unwrap()); + // The art cache names entries after the artist and album — those names + // identify a record and are what a data-directory migration can + // recover. (A fixture named `cover.jpg` would not: see + // `a_generic_cover_name_is_not_an_identity`.) let art_dir = dir.join("art"); std::fs::create_dir_all(&art_dir).unwrap(); - let cover = art_dir.join("cover.jpg"); - let artist = art_dir.join("artist.jpg"); + let cover = art_dir.join("stale artist - stale album.jpg"); + let artist = art_dir.join("artist-stale_artist.jpg"); std::fs::write(&cover, []).unwrap(); std::fs::write(&artist, []).unwrap(); let stale = lib @@ -1189,8 +2466,8 @@ mod tests { "Stale Artist", "Stale Album", None, - Some("C:\\old-app-data\\art\\cover.jpg"), - Some("C:\\old-app-data\\art\\artist.jpg"), + Some("C:\\old-app-data\\art\\stale artist - stale album.jpg"), + Some("C:\\old-app-data\\art\\artist-stale_artist.jpg"), None, 100, ) diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 4861fe5..e7e960b 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -8164,10 +8164,24 @@ impl MetadataClient { /// A title that is merely an edition of ours does NOT read as wrong /// here: containment either way counts as a match, and the edition rule /// lives with the judge, not the fetcher. - pub async fn identify_album(&self, artist: &str, raw_title: &str) -> IdentifyResult { - let tries = search_titles(artist, raw_title); + /// + /// `folder` is the record's folder name, asked after its own claim — the + /// second witness for a record whose tags are wrong. See + /// [`identify_titles`]. `shape` is everything else the scan knows about the + /// record, which is what the discography pass compares against when no + /// phrase can find it ([`RecordShape`]). + pub async fn identify_album( + &self, + artist: &str, + raw_title: &str, + folder: Option<&str>, + shape: &RecordShape, + ) -> IdentifyResult { + let tracks = shape.tracks; + let tries = identify_titles(artist, raw_title, folder); let mut candidate = None; let mut used = None; + let mut mb_failed = false; for t in &tries { if let Some(c) = self.deezer_identify(artist, t).await { candidate = Some(c); @@ -8179,7 +8193,9 @@ impl MetadataClient { .as_ref() .is_some_and(|c| !tries.iter().any(|t| normx_overlap(&c.title, t))); if candidate.is_none() || deezer_wrong { - if let Some((mb, t)) = self.mb_identify(artist, &tries).await { + let (found, failed) = self.mb_identify(artist, &tries, tracks).await; + mb_failed = failed; + if let Some((mb, t)) = found { candidate = Some(mb); used = Some(t); } @@ -8187,28 +8203,37 @@ impl MetadataClient { // PURPOSE: the judge shows it as "Wrong record" honestly, which // beats an empty card (Jenny Death stays wrong, deliberately). } - // Still nothing: stop SEARCHING for the title and start COMPARING it. - // Both passes above ask a provider to find a phrase, so a folder whose - // name no release carries — "1989 Pauls Boutique" — cannot be found by - // either, however it is cleaned. Ranking the artist's own catalogue - // asks a different question, and one that tolerates a stray year, a - // missing apostrophe and a rip descriptor at once. + // Still nothing: stop SEARCHING for the title and start COMPARING the + // record. Both passes above ask a provider to find a phrase, so a + // record whose name no release carries cannot be found by either, + // however it is cleaned — "1989 Pauls Boutique", or "Rock Bottom 7\"" + // for a single the label called "Rock Bottom / Octopus". Comparing the + // record against the artist's own discography asks a different + // question, and one that can use every fact we have about it rather + // than just its name. // // Only when there is NOTHING: a wrong Deezer answer is left standing // for the judge to call wrong, exactly as above. + let mut matched_by = None; if candidate.is_none() { - if let Some((c, matched, _score)) = - self.deezer_artist_catalogue(artist, raw_title, &tries).await + if let Some((c, matched, why)) = self + .discography_identify(artist, raw_title, &tries, shape) + .await { candidate = Some(c); used = Some(matched); + matched_by = Some(why.to_string()); } } IdentifyResult { tries, used, + // Nothing found AND a request failed: say so, rather than letting + // a 503 be filed as an answer about the record. + incomplete: candidate.is_none() && mb_failed, candidate, deezer_wrong, + matched_by, } } @@ -8299,26 +8324,176 @@ impl MetadataClient { /// this pass is that the candidate set is already known to be the right /// artist's. Every name we have for the record is scored, and the best /// title across all of them wins if it clears `MIN`. - async fn deezer_artist_catalogue( + async fn discography_identify( &self, artist: &str, raw_title: &str, tries: &[String], - ) -> Option<(IdentifyCandidate, String, f32)> { - /// Below this, "best match" is just "least bad" — better to say - /// nothing and let the record read as unmatched. - const MIN: f32 = 0.72; + shape: &RecordShape, + ) -> Option<(IdentifyCandidate, String, &'static str)> { + /// A name this strong stands on its own — the old single threshold, + /// unchanged, so every record that matches today still matches. + const NAME_ALONE: f32 = 0.72; + /// Below this a name is not evidence at all, and only the artwork can + /// speak for the record. + const NAME_WITH_HELP: f32 = 0.34; + /// Enough of the tracklist to be the same record. Two-track singles are + /// common, so this is a ratio, not a count. + const TRACKS_AGREE: f32 = 0.6; + /// How many candidates are worth PAYING for (a release fetch each, at + /// MusicBrainz's 1 req/s). Ranked before spending, and only spent where + /// a tracklist could actually change the answer. Five, because a large + /// discography puts real answers well down the name ranking — the + /// Animal Collective white label scores 0.5 against 113 groups. + const CONFIRM_TOP: usize = 5; + let groups = self.mb_discography(artist).await?; + if groups.is_empty() { + return None; + } + + // ---- free evidence: the name, the shape, the year ------------------ + let mut names: Vec<&str> = vec![raw_title]; + for t in tries { + names.push(t.as_str()); + } + let mut ranked: Vec = groups + .iter() + .map(|g| { + let name = names + .iter() + .map(|n| title_match_score(n, &g.title)) + .fold(0.0_f32, f32::max); + DiscoCandidate { + group: g.clone(), + name, + type_fit: mb_type_fit(g.primary_type.as_deref(), shape.tracks), + year_agrees: match (shape.year, g.year()) { + (Some(a), Some(b)) => Some(a == b), + _ => None, + }, + art_distance: None, + tracks_agree: None, + } + }) + .collect(); + + // ---- artwork: the discography's own covers, hashed and compared ---- + // + // This is what rescues a record whose NAME is useless — a folder called + // "ACPP", a title the label never used. Thumbnails, not masters: CAA's + // front-250 is ~20 KB and 11 of 12 groups have one, so a whole + // discography costs a few hundred KB, cached per URL by the shared image + // cache and per artist by the list above. + if let Some(ours) = shape.art_dhash { + let hashes = futures::future::join_all( + ranked + .iter() + .map(|c| async { self.caa_group_dhash(&c.group.id).await }), + ) + .await; + for (c, theirs) in ranked.iter_mut().zip(hashes) { + c.art_distance = theirs.map(|h| (h ^ ours).count_ones()); + } + } + + // Best first: an image match outranks everything, then the name, then + // how well the type fits the record's own size. + ranked.sort_by(|a, b| { + b.art_match() + .cmp(&a.art_match()) + .then(b.name.total_cmp(&a.name)) + .then(a.type_fit.cmp(&b.type_fit)) + }); + + // ---- paid evidence: the tracklist, for the few that could win ------- + // + // The strongest signal there is, and the one a name cannot carry: his + // two files are "Octopus" and "Rock Bottom", and the release is called + // "Rock Bottom / Octopus". + // + // Paid for ONLY where it can change the answer — a name already past + // NAME_ALONE has won, an artwork match has won, and a name below + // NAME_WITH_HELP has nothing for a tracklist to corroborate. Each of + // these is a paced MusicBrainz request, so the window is small and + // spent on the best-ranked candidates first. + if !shape.track_titles.is_empty() { + let mut spent = 0usize; + for c in ranked.iter_mut() { + if spent >= CONFIRM_TOP { + break; + } + if c.art_match() == 1 || c.name >= NAME_ALONE || c.name < NAME_WITH_HELP { + continue; + } + spent += 1; + let listed = self.mb_group_tracks(&c.group.id).await; + if !listed.is_empty() { + c.tracks_agree = Some(tracklist_overlap(&shape.track_titles, &listed)); + } + } + } + + // ---- the decision -------------------------------------------------- + // + // Several independent facts, not one loosened threshold. A weak name + // with nothing behind it stays unmatched, which is what the old single + // floor was really protecting. + let winner = ranked.iter().find_map(|c| { + let why = if c.art_match() == 1 { + "the same artwork" + } else if c.name >= NAME_ALONE { + "its name" + } else if c.name >= NAME_WITH_HELP + && c.tracks_agree.is_some_and(|o| o >= TRACKS_AGREE) + { + "its tracklist" + } else { + return None; + }; + Some((c, why)) + }); + let (winner, why) = winner?; + + // A year that positively DISAGREES kills a name-only match: two records + // can share a title, and the folder's year is the cheapest way to tell + // them apart. Artwork and tracklists outrank it — they are about this + // record, where a year is about a pressing. + if why == "its name" && winner.year_agrees == Some(false) && winner.tracks_agree.is_none() { + return None; + } + + let candidate = self.mb_candidate_of(artist, &winner.group).await?; + Some((candidate, winner.group.title.clone(), why)) + } + + /// Every release group MusicBrainz files under this artist, by NAME — the + /// candidate set the comparison passes rank against. + /// + /// MusicBrainz, not Deezer, because Deezer's artist listing is incomplete in + /// exactly the places this matters: measured on King Gizzard it returns 31 + /// entries (20 `album`, 11 `single`, not one `ep`), missing both "Murder of + /// the Universe" and "Willoughby's Beach" — records Deezer itself sells and + /// serves at `/album/{id}`. MusicBrainz returned all 24 of King Krule's, + /// singles and EPs included. + /// + /// Anchored on an exactly-normalized artist name, never a name-alike: the + /// whole value of the pass is that the candidate set is already known to be + /// the right artist's. Cached per URL, memory and disk (24h), so one fetch + /// serves every record by that artist in the session. + async fn mb_discography(&self, artist: &str) -> Option> { let want = normalize(artist); if want.is_empty() { return None; } - let q = urlencode(artist); + let query = urlencode(&format!("artist:\"{}\"", escape(artist))); let found = self - .deezer(&format!("https://api.deezer.com/search/artist?q={q}&limit=5")) + .musicbrainz_json(&format!( + "https://musicbrainz.org/ws/2/artist?query={query}&fmt=json&limit=5" + )) .await?; - let artist_id = found - .get("data")? + let mbid = found + .get("artists")? .as_array()? .iter() .find(|a| { @@ -8327,51 +8502,117 @@ impl MetadataClient { .is_some_and(|n| normalize(n) == want) }) .and_then(|a| a.get("id")) - .and_then(|v| v.as_u64())?; + .and_then(|v| v.as_str())? + .to_string(); - // The catalogue, bounded: a prolific artist with hundreds of singles - // must not turn one unmatched record into an unbounded walk. - let mut albums: Vec<(u64, String)> = Vec::new(); - let mut next = Some(format!( - "https://api.deezer.com/artist/{artist_id}/albums?limit=100" - )); - while let Some(url) = next { - let Some(page) = self.deezer(&url).await else { break }; - for al in page.get("data").and_then(|d| d.as_array()).into_iter().flatten() { - if let (Some(id), Some(t)) = ( - al.get("id").and_then(|v| v.as_u64()), - al.get("title").and_then(|v| v.as_str()), - ) { - albums.push((id, t.to_string())); - } + // Bounded exactly as the Deezer walk was: a prolific artist must not + // turn one unmatched record into an unbounded crawl. + let mut out: Vec = Vec::new(); + for offset in [0usize, 100, 200] { + let url = format!( + "https://musicbrainz.org/ws/2/release-group?artist={mbid}&limit=100&offset={offset}&fmt=json" + ); + let Some(body) = self.musicbrainz_json(&url).await else { break }; + let Some(groups) = body.get("release-groups").and_then(|g| g.as_array()) else { + break; + }; + let returned = groups.len(); + for g in groups { + let (Some(id), Some(title)) = ( + g.get("id").and_then(|v| v.as_str()), + g.get("title").and_then(|v| v.as_str()), + ) else { + continue; + }; + out.push(MbGroup { + id: id.to_string(), + title: title.to_string(), + primary_type: g + .get("primary-type") + .and_then(|v| v.as_str()) + .map(String::from), + first_release_date: g + .get("first-release-date") + .and_then(|v| v.as_str()) + .map(String::from), + }); } - if albums.len() >= 300 { + let total = body + .get("release-group-count") + .and_then(|c| c.as_u64()) + .unwrap_or(returned as u64) as usize; + if returned == 0 || offset + returned >= total { break; } - next = page.get("next").and_then(|v| v.as_str()).map(String::from); } + Some(out) + } - // Score every name we have for this record against every album. - let mut names: Vec<&str> = vec![raw_title]; - for t in tries { - names.push(t.as_str()); - } - let mut best: Option<(u64, String, f32)> = None; - for (id, title) in &albums { - let score = names - .iter() - .map(|n| title_match_score(n, title)) - .fold(0.0_f32, f32::max); - if best.as_ref().is_none_or(|(_, _, b)| score > *b) { - best = Some((*id, title.clone(), score)); + /// The perceptual hash of a release group's front cover, from Cover Art + /// Archive's 250px thumbnail. Small on purpose — the question is "is this + /// the same picture", which survives scaling, and a discography of masters + /// would be tens of megabytes. + async fn caa_group_dhash(&self, group_mbid: &str) -> Option { + let url = format!("https://coverartarchive.org/release-group/{group_mbid}/front-250"); + let bytes = self.get_image_shared(&url).await?; + decode_cover_async(bytes, "caa", false, url).await.map(|c| c.dhash) + } + + /// One release group's tracklist, from its first release. Read through the + /// shared cache, so confirming a candidate and then building it costs ONE + /// paced request, not two — same URL both times. + /// + /// Official pressings first, then ANY pressing. The status filter alone is + /// too strict for exactly the records that need this pass: Automine's + /// "Padington Band" has no official release and one unfiltered release with + /// the 3 tracks he has, and MusicBrainz files his Animal Collective white + /// label as "[The Purple Bottle]" the same way. A tracklist that exists and + /// is not returned reads as a record with no songs, which corroborates + /// nothing and quietly loses the match. + async fn mb_release_tracks(&self, group_mbid: &str) -> Vec { + for status in ["&status=official", ""] { + let url = format!( + "https://musicbrainz.org/ws/2/release?release-group={group_mbid}\ + &inc=recordings{status}&fmt=json&limit=1" + ); + let Some(body) = self.musicbrainz_json(&url).await else { continue }; + let tracks = parse_mb_tracklist(&body); + if !tracks.is_empty() { + return tracks; } } - let (id, title, score) = best?; - if score < MIN { - return None; - } - let candidate = self.deezer_album_by_id(id, None).await?; - Some((candidate, title, score)) + Vec::new() + } + + /// Just the titles, for the tracklist comparison. + async fn mb_group_tracks(&self, group_mbid: &str) -> Vec { + self.mb_release_tracks(group_mbid) + .await + .into_iter() + .map(|t| t.title) + .collect() + } + + /// A discography candidate as the record the review will show. + async fn mb_candidate_of(&self, artist: &str, group: &MbGroup) -> Option { + let tracklist = self.mb_release_tracks(&group.id).await; + Some(IdentifyCandidate { + id: format!("mb:{}", group.id), + source: "MusicBrainz".into(), + title: group.title.clone(), + artist: Some(artist.to_string()), + cover: Some(format!( + "https://coverartarchive.org/release-group/{}/front", + group.id + )), + cover_big: None, + cover_medium: None, + label: None, + release_date: group.first_release_date.clone(), + genre: None, + track_count: tracklist.len(), + tracklist, + }) } /// MusicBrainz release-group as the album RECORD, not just as cover art @@ -8382,33 +8623,51 @@ impl MetadataClient { &self, artist: &str, tries: &[String], - ) -> Option<(IdentifyCandidate, String)> { + tracks: Option, + ) -> (Option<(IdentifyCandidate, String)>, bool) { + let mut failed = false; for t in tries { let query = format!("releasegroup:\"{}\" AND artist:\"{}\"", escape(t), escape(artist)); let url = format!( "https://musicbrainz.org/ws/2/release-group?query={}&fmt=json&limit=3", urlencode(&query) ); - let Some(body) = self.mb_get(&url).await else { continue }; + // None here means the REQUEST failed, not that MB has nothing — + // `mb_get` only returns None after its retry also failed. Carrying + // that out is what stops a 503 being cached as "no such record". + let Some(body) = self.mb_get(&url).await else { + failed = true; + continue; + }; let empty = Vec::new(); let groups = body .get("release-groups") .and_then(|v| v.as_array()) .unwrap_or(&empty); - let rg = groups - .iter() - .find(|g| { - g.get("title") - .and_then(|v| v.as_str()) - .is_some_and(|title| normx(title) == normx(t)) - }) - .or_else(|| { - groups.iter().find(|g| { + // Several release groups can carry the SAME title, and MB's order + // between equally-scored ones is not stable. "Murder of the + // Universe" is both a 21-track Album and a 1-track Single, both + // scored 100; taking whichever came first identified a 21-track + // record as a one-track single, and differently between runs + // (measured: Single first on one request, Album first on the next). + // So the record's own shape breaks the tie. Stable sort, so equal + // ranks keep MB's relevance order. + let pick = |matches: &dyn Fn(&str) -> bool| -> Option<&serde_json::Value> { + let mut hits: Vec<&serde_json::Value> = groups + .iter() + .filter(|g| { g.get("title") .and_then(|v| v.as_str()) - .is_some_and(|title| normx_overlap(title, t)) + .is_some_and(|title| matches(title)) }) + .collect(); + hits.sort_by_key(|g| { + mb_type_fit(g.get("primary-type").and_then(|v| v.as_str()), tracks) }); + hits.into_iter().next() + }; + let rg = pick(&|title| normx(title) == normx(t)) + .or_else(|| pick(&|title| normx_overlap(title, t))); let Some(rg) = rg else { continue }; let Some(rgid) = rg.get("id").and_then(|v| v.as_str()) else { continue }; @@ -8417,31 +8676,8 @@ impl MetadataClient { &inc=recordings&status=official&fmt=json&limit=1" ); let rel = self.mb_get(&rel_url).await; - let tracklist: Vec = rel - .as_ref() - .and_then(|r| r.pointer("/releases/0/media")) - .and_then(|m| m.as_array()) - .map(|media| { - media - .iter() - .flat_map(|m| { - m.get("tracks").and_then(|t| t.as_array()).cloned().unwrap_or_default() - }) - .map(|tr| IdentifyTrack { - n: tr.get("position").and_then(|v| v.as_u64()).map(|v| v as u32), - title: tr - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - secs: tr - .get("length") - .and_then(|v| v.as_u64()) - .map(|ms| ((ms as f64) / 1000.0).round() as u32), - }) - .collect() - }) - .unwrap_or_default(); + let tracklist: Vec = + rel.as_ref().map(parse_mb_tracklist).unwrap_or_default(); let candidate = IdentifyCandidate { id: format!("mb:{rgid}"), @@ -8470,9 +8706,9 @@ impl MetadataClient { track_count: tracklist.len(), tracklist, }; - return Some((candidate, t.clone())); + return (Some((candidate, t.clone())), failed); } - None + (None, failed) } /// Fetch a chosen cover's bytes for the import apply — a plain GET, the @@ -11524,6 +11760,26 @@ pub struct IdentifyResult { /// so MusicBrainz was consulted. When MB also missed, `candidate` still /// carries Deezer's wrong answer — shown honestly as "Wrong record". pub deezer_wrong: bool, + /// A provider REQUEST failed (503, timeout, unparseable) while nothing was + /// found — so "nothing found" is not a fact about the record, it is a fact + /// about the network. Caching it as an answer is what made "Murder of the + /// Universe" a permanent deep cut: MusicBrainz holds it, scored 100, and + /// answered on the next ask. + /// + /// MusicBrainz only. Its 1 req/s limiter makes a 503 routine — the shared + /// Deezer helper has ~35 call sites and reports no failure reason, so a + /// Deezer outage still reads as a miss. Honest about what it covers. + pub incomplete: bool, + /// Which piece of evidence carried a DISCOGRAPHY match — "its name", "its + /// tracklist", "the same artwork". Absent when a phrase search answered, or + /// when nothing did. + /// + /// Carried for the review to SAY so — a record identified by its songs or + /// its sleeve rather than its name is a different kind of answer, and the + /// verdict panel currently cannot tell the user which it was. Nothing + /// renders it yet; the wording is a look change and belongs in a mockup + /// first. + pub matched_by: Option, } /// ASCII-normalize for title matching, exactly as the mock's normx: only @@ -11752,6 +12008,275 @@ fn strip_rip_words(s: &str) -> String { out } +/// Does this carry a word OUTSIDE brackets? +/// +/// Bracketed groups are decoration — catalogue numbers, rip descriptors, +/// edition notes — and a "title" made only of them names nothing. Unicode +/// alphanumerics count, so `ワールドイズユアーズ` is a word like any other. +fn has_bare_word(s: &str) -> bool { + let mut depth = 0i32; + for c in s.chars() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth = (depth - 1).max(0), + _ if depth == 0 && c.is_alphanumeric() => return true, + _ => {} + } + } + false +} + +/// Release-type words a folder or a tag appends that no service files as part +/// of the TITLE. +/// +/// Both providers require every word of the phrase, so one appended type word +/// is enough to make a famous record unfindable. Measured on +/// "Willoughby's Beach EP" (King Gizzard's 2011 debut, tagged with the suffix): +/// Deezer answered `total=0` for `… Willoughby's Beach EP` and returned the +/// record — 9 tracks, the exact release — for the same query without it, and +/// MusicBrainz's `releasegroup:"Willoughby's Beach EP"` returned `count=0` +/// while the plain title scored 100. MusicBrainz files that release group's +/// `primary-type` AS `EP`, which is the whole argument: the type is metadata +/// about the record, never part of its name. +/// +/// The artist's-own-catalogue pass cannot cover for this. Deezer's +/// `/artist/3973121/albums` lists 31 releases for King Gizzard — 20 `album` +/// and 11 `single`, not one `ep` — so the EP is reachable only by a search +/// that spells its title the way Deezer does. +/// +/// Measured across the 497-record session: 14 records carry one of these as a +/// trailing word (ep 5, single 3, soundtrack 3, album 1, lp 1, mixtape 1) and +/// 13 of them already match, because the service's own title carries the word +/// too. That is why the stripped form is ADDED AFTER the exact ones and never +/// replaces them — the exact title is always asked first, so no record that +/// works today changes its answer. +/// +/// "Live" and "Compilation" are deliberately absent: those name the work far +/// more often than they annotate it ("Alchemy Live" is not "Alchemy"). +const TYPE_WORDS: [&str; 7] = ["ep", "lp", "single", "album", "mixtape", "soundtrack", "ost"]; + +/// The title with a trailing release-type word removed, or None when there is +/// no such word — or when removing it would leave nothing to search for. +/// +/// Punctuation folds into the word, so "E.P." is "ep". A separator left behind +/// goes with it, which is what handles the store convention +/// "Willoughby's Beach - EP". +fn strip_type_suffix(s: &str) -> Option { + let words: Vec<&str> = s.split_whitespace().collect(); + if words.len() < 2 { + return None; + } + let last: String = words[words.len() - 1] + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + if !TYPE_WORDS.contains(&last.as_str()) { + return None; + } + let kept = words[..words.len() - 1].join(" "); + let kept = kept.trim_end(); + let kept = kept.strip_suffix(['-', '–', '—']).unwrap_or(kept).trim_end(); + // A title that is ONLY its type word ("EP", "Album") keeps it: stripping + // leaves nothing, and nothing names no record. + if kept.is_empty() || !has_bare_word(kept) { + return None; + } + Some(kept.to_string()) +} + +/// A MusicBrainz release lookup's first release, as a tracklist. Discs are +/// flattened in media order, which is the order the review shows them. +fn parse_mb_tracklist(body: &serde_json::Value) -> Vec { + body.pointer("/releases/0/media") + .and_then(|m| m.as_array()) + .map(|media| { + media + .iter() + .flat_map(|m| { + m.get("tracks").and_then(|t| t.as_array()).cloned().unwrap_or_default() + }) + .map(|tr| IdentifyTrack { + n: tr.get("position").and_then(|v| v.as_u64()).map(|v| v as u32), + title: tr + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + secs: tr + .get("length") + .and_then(|v| v.as_u64()) + .map(|ms| ((ms as f64) / 1000.0).round() as u32), + }) + .collect() + }) + .unwrap_or_default() +} + +/// One release group from an artist's MusicBrainz discography. +#[derive(Debug, Clone)] +struct MbGroup { + id: String, + title: String, + primary_type: Option, + first_release_date: Option, +} + +impl MbGroup { + /// The release year, when the date says one. MusicBrainz dates are + /// `YYYY`, `YYYY-MM` or `YYYY-MM-DD`, so the year is the first four. + fn year(&self) -> Option { + let d = self.first_release_date.as_deref()?; + d.get(..4)?.parse().ok() + } +} + +/// One candidate release group with every piece of evidence gathered about it. +/// Nothing here is a verdict — [`MetadataClient::discography_identify`] weighs +/// them together, because any single one of them can be innocently wrong. +#[derive(Debug, Clone)] +struct DiscoCandidate { + group: MbGroup, + /// Wording similarity, 0.0–1.0, best over every name we have. + name: f32, + /// How well the release TYPE fits our track count — 0 is best. + type_fit: u8, + /// Some(false) means the years positively disagree; None means one of them + /// is unknown, which is not a disagreement. + year_agrees: Option, + /// Hamming distance between our cover and theirs, when both exist. + art_distance: Option, + /// How much of the tracklist is shared, 0.0–1.0. + tracks_agree: Option, +} + +impl DiscoCandidate { + /// 1 when the artwork proves this is the same record. Sorted descending, so + /// an image match outranks every name. + fn art_match(&self) -> u8 { + u8::from(self.art_distance.is_some_and(|d| d <= COVER_SAME_ART_MAX_DISTANCE)) + } +} + +/// How much of two tracklists is the same music, 0.0–1.0 — the larger side +/// decides, so a 2-track single does not "fully match" a 20-track album that +/// happens to contain both songs. +/// +/// Titles are compared through [`normx`], which discards punctuation and case: +/// a tagger's "Rock Bottom" and MusicBrainz's "Rock Bottom" are one song. +fn tracklist_overlap(ours: &[String], theirs: &[String]) -> f32 { + if ours.is_empty() || theirs.is_empty() { + return 0.0; + } + let mut pool: Vec = theirs.iter().map(|t| normx(t)).filter(|t| !t.is_empty()).collect(); + let mut shared = 0usize; + for t in ours { + let key = normx(t); + if key.is_empty() { + continue; + } + if let Some(at) = pool.iter().position(|x| *x == key) { + pool.remove(at); + shared += 1; + } + } + shared as f32 / ours.len().max(theirs.len()) as f32 +} + +/// Everything the SCAN already knows about a local record, for the passes that +/// COMPARE rather than search. +/// +/// A phrase search can only use the album name, which is why a record whose +/// name is wrong, annotated, or simply not what the label called it has been +/// unfindable. Every other fact about the record is evidence: how many files it +/// has, what those files are called, what year the folder claims, and what its +/// own cover looks like. See [`MetadataClient::discography_identify`]. +#[derive(Debug, Clone, Default)] +pub struct RecordShape { + /// How many audio files the record has. + pub tracks: Option, + /// The track titles, in order — the strongest signal available, and free: + /// "Rock Bottom 7\"" scores 0.667 on its name against the release actually + /// called "Rock Bottom / Octopus", and 2 of 2 on its tracks. + pub track_titles: Vec, + /// The year the folder or tags claim, if any. + pub year: Option, + /// Perceptual hash of the record's OWN cover — embedded art, usually a real + /// front sleeve. Compared against the discography's artwork with the same + /// gate `cover_report` uses, so a match is the same IMAGE, not a similar + /// name. See [`cover_dhash`] and [`COVER_SAME_ART_MAX_DISTANCE`]. + pub art_dhash: Option, +} + +/// The perceptual hash of one cover's bytes, for callers outside this crate +/// (the import review hands over the record's own embedded art). +pub fn cover_dhash(bytes: &[u8]) -> Option { + image::load_from_memory(bytes).ok().map(|img| dhash64(&img)) +} + +/// The Hamming distance at or below which two covers are the SAME artwork, +/// re-exported so a caller can describe its own confidence in the same terms. +pub const SAME_ART_MAX_DISTANCE: u32 = COVER_SAME_ART_MAX_DISTANCE; + +/// How well a MusicBrainz release group's TYPE fits the record in hand, as a +/// sort key — 0 best. Only ever used to break a tie between groups that match +/// the title equally well. +/// +/// The thresholds are the frontend's `localFallback` (`localReleaseType.js`) +/// verbatim: 3 tracks or fewer is a single, 7 or fewer an EP, more an album. +/// With no count to reason from, an Album is the safe general answer — and, +/// unlike MusicBrainz's ordering, a deterministic one. +fn mb_type_fit(primary_type: Option<&str>, tracks: Option) -> u8 { + let ty = primary_type.unwrap_or_default().to_ascii_lowercase(); + let want = tracks.map(|n| { + if n <= 3 { + "single" + } else if n <= 7 { + "ep" + } else { + "album" + } + }); + if want.is_some_and(|w| ty == w) { + 0 + } else if ty == "album" { + 1 + } else { + 2 + } +} + +/// Every name we have for one record, as queries, in the order they earn: the +/// record's OWN claim first (what its tags say), then the folder it sits in +/// when that says something different. +/// +/// Identification used to ask about ONE name — whatever `albumNameOf` resolved, +/// which is the tag majority — so a record whose tags are wrong had no second +/// witness. The Soulseek copy search has consulted both since it was written +/// (`findQueries` rung 2, "a fallback for records whose tags are wrong or +/// missing"); this is that same rung, for the providers. +/// +/// Measured on King Gizzard's "Nonagon Infinity": all nine files tag the album +/// `Nonagon Infiniry`, and the folder is spelled correctly. MusicBrainz answers +/// `count=0` for the typo and `score=100` for the folder's spelling. Deezer's +/// search index does not answer for this record AT ALL — not the typo, not the +/// correct title, not `artist:"…" album:"…"` — although `/album/12965126` is +/// there, available, with the right 9 tracks. So for this record the folder is +/// the only witness that can find it, and the catalogue pass is the only route. +/// +/// The folder's rungs come LAST, and for the same reason `findQueries` puts +/// them second: it is the ripper's naming, carrying catalogue numbers, years +/// and rip descriptors, so it asks the narrower question. +pub fn identify_titles(artist: &str, raw_title: &str, folder: Option<&str>) -> Vec { + let mut out = search_titles(artist, raw_title); + for t in folder.map(|f| search_titles(artist, f)).unwrap_or_default() { + if !out.contains(&t) { + out.push(t); + } + } + out +} + /// The cleaned query try-list for one folder title, least- to most- /// aggressive, deduped; may be empty when nothing survives cleaning. pub fn search_titles(artist: &str, raw_title: &str) -> Vec { @@ -11775,7 +12300,15 @@ pub fn search_titles(artist: &str, raw_title: &str) -> Vec { let rest = rest.trim_start(); let rest = rest.strip_prefix(['-', '–', '—']).unwrap_or(rest); let rest = rest.trim_start(); - if !rest.is_empty() { + // A strip that leaves only decoration is not a title. Where the album + // IS the artist's name — a self-titled record, or a folder named after + // the band rather than the act — this takes the whole name away and + // leaves the catalogue number behind it: "Band Of Gypsys (SICP 2643)" + // became "(SICP 2643)", which no service has ever heard of and which + // nothing downstream can rescue, because cutting at the first "(" + // leaves nothing at all. Six files that all say "Jimi Hendrix" sat + // unidentifiable behind that one line. + if has_bare_word(rest) { t = rest.to_string(); } } @@ -11809,6 +12342,16 @@ pub fn search_titles(artist: &str, raw_title: &str) -> Vec { out.push(v); } } + // Type-suffix forms LAST, so the title as the record spells it is always + // the first thing asked and a record that matches today cannot change its + // answer. See [`TYPE_WORDS`]. + for v in out.clone() { + if let Some(stripped) = strip_type_suffix(&v) { + if !out.contains(&stripped) { + out.push(stripped); + } + } + } out } @@ -14598,6 +15141,221 @@ mod tests { "The Doctor Came At Dawn".to_string(), ] ); + + // The artist strip must not eat the whole title. Where the folder is + // named after the band, taking the "artist" off the front left only + // the catalogue number — the providers were asked for "(SICP 2643)" + // and a famous record read as a deep cut nothing had. + assert_eq!( + search_titles("Band Of Gypsys", "Band Of Gypsys (SICP 2643)"), + vec![ + "Band Of Gypsys (SICP 2643)".to_string(), + "Band Of Gypsys".to_string(), + ] + ); + // A self-titled record, same shape, nothing but the name. + assert_eq!( + search_titles("Weezer", "Weezer"), + vec!["Weezer".to_string()] + ); + // …while a strip that leaves a real title still happens. + assert_eq!( + search_titles("Battles", "Battles - Mirrored"), + vec!["Mirrored".to_string()] + ); + } + + /// An appended release type is not part of the title, and one extra word + /// is enough to make a record unfindable at both providers (see + /// [`super::TYPE_WORDS`] for the measured Deezer/MusicBrainz evidence). + #[test] + fn search_titles_offers_the_title_without_its_release_type() { + use super::search_titles; + + // The record that found this: 9 tracks, tagged with the suffix, and + // Deezer sells it as "Willoughby's Beach". + assert_eq!( + search_titles("King Gizzard & The Lizard Wizard", "Willoughby's Beach EP"), + vec![ + "Willoughby's Beach EP".to_string(), + "Willoughby's Beach".to_string(), + ] + ); + // The store convention, separator and all. + assert_eq!( + search_titles("Burial", "Street Halo - EP"), + vec!["Street Halo - EP".to_string(), "Street Halo".to_string()] + ); + // Punctuation folds into the word. + assert_eq!( + search_titles("Alvvays", "Antisocialites E.P."), + vec![ + "Antisocialites E.P.".to_string(), + "Antisocialites".to_string(), + ] + ); + // The exact title is always first, so the 13 records whose service + // title carries the word too are unaffected. + assert_eq!( + search_titles("Ratatat", "LP3")[0], + "LP3".to_string() + ); + + // A title that is ONLY its type word keeps it — stripping leaves + // nothing, and nothing names no record. + assert_eq!(search_titles("Ratatat", "EP"), vec!["EP".to_string()]); + assert_eq!( + search_titles("Public Image Ltd", "Album"), + vec!["Album".to_string()] + ); + // Bracketed decoration is not a title either, so the type word stays + // rather than reducing this to "(WARP 24)". + assert_eq!( + search_titles("Nightmares On Wax", "(WARP 24) EP"), + vec!["(WARP 24) EP".to_string()] + ); + + // Words that name the work far more often than they annotate it are + // NOT stripped. + assert_eq!( + search_titles("Dire Straits", "Alchemy Live"), + vec!["Alchemy Live".to_string()] + ); + // And a title merely ENDING in one of these letters is untouched. + assert_eq!( + search_titles("Sonic Youth", "Sister"), + vec!["Sister".to_string()] + ); + } + + /// The folder as the SECOND witness — a record whose tags are wrong. + #[test] + fn identify_titles_asks_the_folder_after_the_records_own_claim() { + use super::identify_titles; + const A: &str = "King Gizzard & The Lizard Wizard"; + + // The record that found this: nine files tagged "Nonagon Infiniry", + // sitting in a correctly-spelled folder. MusicBrainz answers count=0 + // for the first and score=100 for the second. + assert_eq!( + identify_titles(A, "Nonagon Infiniry", Some("(2016) - Nonagon Infinity")), + vec![ + "Nonagon Infiniry".to_string(), + "Nonagon Infinity".to_string(), + ] + ); + // The claim always leads, so a record that matches on its tags today + // asks exactly what it asked before. + assert_eq!( + identify_titles(A, "Nonagon Infiniry", Some("(2016) - Nonagon Infinity"))[0], + "Nonagon Infiniry".to_string() + ); + // Agreement adds nothing — no second query for the same words. + assert_eq!( + identify_titles("Battles", "Mirrored", Some("Battles - Mirrored")), + vec!["Mirrored".to_string()] + ); + // No folder is the old behaviour, exactly. + assert_eq!( + identify_titles("Battles", "Mirrored", None), + super::search_titles("Battles", "Mirrored") + ); + // The folder's own rip descriptors are cleaned before it is asked, and + // it still contributes the rungs its cleaning produces. + assert_eq!( + identify_titles("Vashti Bunyan", "Heartleep", Some("Heartleap - WEB V0")), + vec!["Heartleep".to_string(), "Heartleap".to_string()] + ); + } + + /// The tracklist comparison that identifies a record its NAME cannot. + #[test] + fn tracklist_overlap_reads_the_same_music_through_different_names() { + use super::tracklist_overlap as overlap; + let ours = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + + // The record that argued for this pass: his two files are "Octopus" and + // "Rock Bottom", filed as `Rock Bottom 7"`; MusicBrainz calls the + // release "Rock Bottom / Octopus". The NAME scores 0.667, under the + // 0.72 floor. The music is identical. + assert_eq!( + overlap(&ours(&["Octopus", "Rock Bottom"]), &ours(&["Rock Bottom", "Octopus"])), + 1.0 + ); + // Punctuation and case are a tagger's business, not the record's. + assert_eq!( + overlap(&ours(&["Alter Me I"]), &ours(&["alter me i."])), + 1.0 + ); + // The LARGER side decides, so a 2-track single does not read as a full + // match for the 20-track album that happens to contain both songs. + let single = ours(&["Rock Bottom", "Octopus"]); + let album: Vec = (0..20) + .map(|i| if i == 0 { "Rock Bottom".into() } else if i == 1 { "Octopus".into() } else { format!("Filler {i}") }) + .collect(); + assert!(overlap(&single, &album) < 0.2); + // Different records share nothing. + assert_eq!(overlap(&ours(&["Robot Stop"]), &ours(&["Easy Easy"])), 0.0); + // Nothing to compare is not agreement. + assert_eq!(overlap(&[], &ours(&["Rock Bottom"])), 0.0); + assert_eq!(overlap(&ours(&["Rock Bottom"]), &[]), 0.0); + // A repeated title counts once per actual occurrence, both ways. + assert_eq!( + overlap(&ours(&["Intro", "Intro"]), &ours(&["Intro"])), + 0.5 + ); + } + + /// The artwork gate, in the terms the import review reasons about. + #[test] + fn same_art_distance_is_the_covers_gate() { + // Re-exported so a caller can describe its confidence in one vocabulary + // rather than inventing a second threshold. + assert_eq!(super::SAME_ART_MAX_DISTANCE, super::COVER_SAME_ART_MAX_DISTANCE); + // Identical artwork is distance 0 — the same picture from two CDNs + // lands at 0-6, different covers cluster at 20 and above. + let h: u64 = 0x0f1e_2d3c_4b5a_6978; + assert_eq!((h ^ h).count_ones(), 0); + assert!((h ^ h).count_ones() <= super::SAME_ART_MAX_DISTANCE); + } + + /// A release group's year, from MusicBrainz's three date shapes. + #[test] + fn mb_group_year_reads_partial_dates() { + let g = |d: Option<&str>| super::MbGroup { + id: "x".into(), + title: "t".into(), + primary_type: None, + first_release_date: d.map(String::from), + }; + assert_eq!(g(Some("2012-09-24")).year(), Some(2012)); + assert_eq!(g(Some("2016-04")).year(), Some(2016)); + assert_eq!(g(Some("2017")).year(), Some(2017)); + assert_eq!(g(Some("")).year(), None); + assert_eq!(g(None).year(), None); + } + + /// One title, several release groups: the record's own shape breaks the + /// tie, because MusicBrainz's order between equal scores does not. + #[test] + fn mb_type_fit_prefers_the_group_shaped_like_the_record() { + use super::mb_type_fit as fit; + + // The case that found this: "Murder of the Universe" is a 21-track + // Album AND a 1-track Single, both scored 100 by MB. + assert!(fit(Some("Album"), Some(21)) < fit(Some("Single"), Some(21))); + // …and the same collision from the other side: a real one-track single + // must not be answered with the album of the same name. + assert!(fit(Some("Single"), Some(1)) < fit(Some("Album"), Some(1))); + // An EP-sized record prefers the EP. + assert!(fit(Some("EP"), Some(6)) < fit(Some("Single"), Some(6))); + // Type casing is MusicBrainz's, not ours. + assert_eq!(fit(Some("album"), Some(21)), fit(Some("Album"), Some(21))); + // With no count to reason from, an Album is the deterministic answer. + assert!(fit(Some("Album"), None) < fit(Some("Single"), None)); + assert!(fit(Some("Album"), None) < fit(Some("EP"), None)); + // A missing type is the worst rank, never a coincidental best. + assert!(fit(Some("Album"), Some(21)) < fit(None, Some(21))); } /// The ranking rule that replaced "strip the leading year". @@ -14609,7 +15367,7 @@ mod tests { #[test] fn title_match_score_ranks_without_stripping() { use super::title_match_score as score; - const MIN: f32 = 0.72; // the threshold deezer_artist_catalogue uses + const MIN: f32 = 0.72; // `discography_identify`'s NAME_ALONE // The case this exists for: a year the folder added, an apostrophe the // folder dropped, and a provider selling the anniversary pressing. diff --git a/crates/player/Cargo.toml b/crates/player/Cargo.toml index 1e07e8e..2b7ad89 100644 --- a/crates/player/Cargo.toml +++ b/crates/player/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +audio-formats = { path = "../audio-formats" } rodio = { version = "0.21", features = ["symphonia-all"] } symphonia = { version = "0.5", features = ["all"] } thiserror.workspace = true diff --git a/crates/player/examples/probe_formats.rs b/crates/player/examples/probe_formats.rs new file mode 100644 index 0000000..791cad1 --- /dev/null +++ b/crates/player/examples/probe_formats.rs @@ -0,0 +1,187 @@ +//! Ask the REAL decoder what it can open. +//! +//! The app carries several hand-maintained lists of "audio extensions", and a +//! list is a claim, not a capability — the only thing that settles whether a +//! file plays is building the same decoder the player builds. +//! +//! cargo run -p player --example probe_formats -- [more...] +//! +//! A folder is walked and one file per extension is probed, which is what makes +//! this cheap to point at a whole library. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + eprintln!("usage: probe_formats ..."); + std::process::exit(2); + } + + // One representative per extension: the question is "can this FORMAT be + // decoded", and a thousand MP3s answer it no better than one. + let mut sample: BTreeMap = BTreeMap::new(); + let mut counts: BTreeMap = BTreeMap::new(); + for a in &args { + collect(Path::new(a), &mut sample, &mut counts, 0); + } + + println!("{:<8} {:>7} {:<9} {}", "EXT", "FILES", "VERDICT", "DETAIL"); + for (ext, path) in &sample { + let (verdict, detail) = probe(path); + println!( + "{:<8} {:>7} {:<9} {}", + ext, + counts.get(ext).copied().unwrap_or(0), + verdict, + detail + ); + } +} + +fn collect( + p: &Path, + sample: &mut BTreeMap, + counts: &mut BTreeMap, + depth: u8, +) { + if depth > 8 { + return; + } + if p.is_dir() { + let Ok(entries) = std::fs::read_dir(p) else { return }; + for e in entries.flatten() { + collect(&e.path(), sample, counts, depth + 1); + } + return; + } + let Some(ext) = p.extension().and_then(|e| e.to_str()) else { return }; + let ext = ext.to_ascii_lowercase(); + // Anything that is plausibly audio, INCLUDING formats no list mentions — + // the point is to discover the gap, not to confirm the lists. + const PLAUSIBLE: &[&str] = &[ + "flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "aac", "alac", "ogg", "oga", "opus", + "wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm", "wma", "ape", "wv", "mpc", + "tta", "shn", "dsf", "dff", "ra", "au", "amr", "3gp", "spx", + ]; + if !PLAUSIBLE.contains(&ext.as_str()) { + return; + } + *counts.entry(ext.clone()).or_insert(0) += 1; + sample.entry(ext).or_insert_with(|| p.to_path_buf()); +} + +/// Build exactly what `player` builds for a local file. +fn probe(path: &Path) -> (&'static str, String) { + use rodio::Decoder; + let file = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) => return ("ERROR", format!("open failed: {e}")), + }; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + let reader = std::io::BufReader::new(file); + let mut builder = Decoder::builder().with_data(reader).with_seekable(true); + if len > 0 { + builder = builder.with_byte_len(len); + } + // What did symphonia actually RECOGNISE? A verdict of "plays" is worth + // little if the container was mistaken for something else, so name the + // format and codec rather than inferring capability from the extension. + let detected = detect(path).unwrap_or_else(|| "?".into()); + match builder.build() { + Ok(d) => { + // Opening is not playing. A container can parse while its codec is + // missing, and a truncated decode looks like success if you only + // pull a few thousand samples — so decode the WHOLE thing and check + // the duration against the file. A 30 MB lossless track that yields + // two seconds of audio has not been decoded, it has been guessed at. + use rodio::Source as _; + let rate = d.sample_rate(); + let ch = d.channels(); + let mut src = d; + // Amplitude tells apart "decoded" from "mis-identified". Compressed + // bytes read as raw PCM are full-scale noise: near-100% of samples + // land loud, where real music spends most of its time quiet. + let (mut n, mut sumsq, mut hot, mut bad) = (0u64, 0f64, 0u64, 0u64); + for s in src.by_ref() { + let v = s as f64; + // A non-finite sample is proof on its own: no decoder emits + // NaN from real audio. It comes from compressed bytes being + // reinterpreted as floats, and it poisons every mixer and + // normaliser stage downstream of here. + if !v.is_finite() { + bad += 1; + n += 1; + continue; + } + sumsq += v * v; + if v.abs() > 0.5 { + hot += 1; + } + n += 1; + } + let rms = if n > 0 { (sumsq / n as f64).sqrt() } else { 0.0 }; + let hot_pct = if n > 0 { hot as f64 * 100.0 / n as f64 } else { 0.0 }; + let frames = n as f64 / (ch.max(1) as f64); + let secs = frames / rate.max(1) as f64; + let mb = len as f64 / 1_048_576.0; + if secs < 1.0 { + return ("NO AUDIO", format!("{rate} Hz, {ch}ch — {secs:.2}s from {mb:.1} MB")); + } + // Even the densest lossless runs well under 10 MB per second. + if mb / secs > 10.0 { + return ( + "PARTIAL", + format!("{rate} Hz, {ch}ch — only {secs:.1}s from {mb:.1} MB"), + ); + } + // Half the samples above half scale is not music. This is the + // WavPack case: a RIFF header inside the wrapper makes symphonia + // read the compressed body as PCM, so it "succeeds" into noise — + // which is worse than refusing, because nothing reports an error. + if bad > 0 { + return ( + "GARBAGE", + format!( + "{detected} mis-identified — {} of {n} samples are NaN/Inf", + bad + ), + ); + } + if hot_pct > 40.0 && rms > 0.35 { + return ( + "NOISE", + format!("{detected} mis-identified — rms {rms:.2}, {hot_pct:.0}% of samples above half scale"), + ); + } + ( + "PLAYS", + format!("{detected} {rate} Hz, {ch}ch, {secs:.0}s, rms {rms:.2}"), + ) + } + Err(e) => ("CANNOT", format!("{detected} {e}")), + } +} + +/// The codec symphonia identifies, straight from the probe — the ground truth +/// behind a "plays" verdict. +fn detect(path: &Path) -> Option { + use symphonia::core::io::MediaSourceStream; + use symphonia::core::probe::Hint; + let file = std::fs::File::open(path).ok()?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + let probed = symphonia::default::get_probe() + .format(&hint, mss, &Default::default(), &Default::default()) + .ok()?; + let track = probed.format.default_track()?; + let codec = track.codec_params.codec; + let name = symphonia::default::get_codecs() + .get_codec(codec) + .map(|d| d.short_name.to_string()) + .unwrap_or_else(|| format!("codec:{codec:?}")); + Some(name) +} diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index c4277a9..0673697 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -8,6 +8,7 @@ mod loudness; mod source; +pub mod vocals; mod waveform; pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter}; @@ -494,6 +495,19 @@ fn start_decoder( .into(), ) .spawn(move || { + // Refuse formats there is no decoder for, BEFORE opening them. + // + // Most simply fail to build and report themselves. WavPack does + // not: a `.wv` carrying the original RIFF header in its wrapper is + // probed as raw PCM, so the decoder reports success and streams the + // compressed body as samples — measured at 136,815 NaN/Inf values + // in a single track, straight into the mixer and the normaliser. + // An extension check is the only thing that can stop that, because + // by the time there is a decoder there is no error to notice. + if let Some(reason) = audio_formats::unplayable_path_reason(&track.path) { + let _ = tx.send(Err((generation, reason.to_string()))); + return; + } let (reader, byte_len): (Box, u64) = match track.stream.clone() { Some(progress) => { match GrowingFile::open(&track.path, track.total_bytes, progress) { diff --git a/crates/player/src/vocals.rs b/crates/player/src/vocals.rs new file mode 100644 index 0000000..191d1f6 --- /dev/null +++ b/crates/player/src/vocals.rs @@ -0,0 +1,512 @@ +//! Vocal-band DSP for the lyrics feature: a leading-silence probe (auto-sync +//! evidence) and an offline vocal-reduction renderer (the classic band-limited +//! center-cancel, NOT a learned separator — honest about what it is). +//! +//! Everything here is offline file work. Nothing touches the live audio +//! engine: the renderer writes an ordinary WAV that plays through the normal +//! local-file path, so a bug in this module can never destabilize playback. + +use std::io::Write as _; +use std::path::Path; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +/// Longest source the renderer accepts. A WAV render holds the whole track +/// in memory (16-bit stereo ≈ 10.6 MB/min at 44.1k); twenty minutes bounds +/// that at ~212 MB and excludes accidental full-set files. +const MAX_RENDER_SECS: f64 = 20.0 * 60.0; + +/// RMS floor treated as "audible": −50 dBFS, sustained. Modern releases sit +/// far above this; dithered lead-in noise sits below it. +const SILENCE_RMS: f32 = 0.003_16; +/// Frame size for the silence scan (about 20 ms at 44.1 kHz). +const SILENCE_FRAME: usize = 1024; +/// How many consecutive audible frames end the silence (~60 ms) — a single +/// vinyl tick or DC blip must not count as the music starting. +const SILENCE_RUN: usize = 3; +/// The silence probe never reads past this point; a track that has not +/// started within thirty seconds is not a case auto-sync should judge. +const SILENCE_SCAN_SECS: f64 = 30.0; + +// ---- decoding -------------------------------------------------------------- + +struct Decoded { + /// Interleaved f32 frames. + samples: Vec, + channels: usize, + sample_rate: u32, +} + +/// Decode `path` fully (or up to `limit_secs`) into interleaved f32. +fn decode(path: &Path, limit_secs: Option) -> Result { + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|e| e.to_string())?; + let mut format = probed.format; + let track = format + .default_track() + .ok_or("no default audio track")? + .clone(); + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|e| e.to_string())?; + + let mut samples = Vec::new(); + let mut channels = 0usize; + let mut sample_rate = 0u32; + let mut buffer: Option> = None; + loop { + let packet = match format.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(_)) => break, + Err(symphonia::core::errors::Error::ResetRequired) => break, + Err(_) => break, + }; + if packet.track_id() != track.id { + continue; + } + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(_) => break, + }; + let spec = *decoded.spec(); + channels = spec.channels.count(); + sample_rate = spec.rate; + let out = buffer.get_or_insert_with(|| { + SampleBuffer::::new(decoded.capacity() as u64, spec) + }); + if out.capacity() < decoded.capacity() * channels { + *out = SampleBuffer::::new(decoded.capacity() as u64, spec); + } + out.copy_interleaved_ref(decoded); + samples.extend_from_slice(out.samples()); + if let Some(limit) = limit_secs { + if sample_rate > 0 + && (samples.len() / channels.max(1)) as f64 / sample_rate as f64 >= limit + { + break; + } + } + } + if samples.is_empty() || channels == 0 || sample_rate == 0 { + return Err("nothing decodable".into()); + } + Ok(Decoded { + samples, + channels, + sample_rate, + }) +} + +// ---- leading silence ------------------------------------------------------- + +/// Milliseconds of leading silence in `path`, or None when the file cannot be +/// decoded / never becomes audible inside the scan window. +pub fn leading_silence_ms(path: &Path) -> Option { + let decoded = decode(path, Some(SILENCE_SCAN_SECS)).ok()?; + let frames_rms = fold_rms(&decoded.samples, decoded.channels, SILENCE_FRAME); + let onset_frame = onset_from_rms(&frames_rms)?; + Some( + (onset_frame as u64 * SILENCE_FRAME as u64 * 1000) + / decoded.sample_rate.max(1) as u64, + ) +} + +/// Interleaved samples → per-frame mono RMS. +fn fold_rms(samples: &[f32], channels: usize, frame: usize) -> Vec { + let channels = channels.max(1); + let per_frame = frame * channels; + samples + .chunks(per_frame) + .map(|chunk| { + let sum: f32 = chunk.iter().map(|s| s * s).sum(); + (sum / chunk.len().max(1) as f32).sqrt() + }) + .collect() +} + +/// First frame index beginning [`SILENCE_RUN`] consecutive audible frames. +fn onset_from_rms(rms: &[f32]) -> Option { + let mut run = 0usize; + for (index, &value) in rms.iter().enumerate() { + if value > SILENCE_RMS { + run += 1; + if run >= SILENCE_RUN { + return Some(index + 1 - run); + } + } else { + run = 0; + } + } + None +} + +// ---- vocal activity -------------------------------------------------------- + +/// A coarse "is someone likely singing here" envelope: the energy of the +/// CENTER (mid) channel band-passed to the vocal range, one value per +/// `frame_ms`, normalized 0..1 against its own loud end. The same physics +/// the vocal reducer exploits — center-mixed vocals dominate the mid band — +/// read instead of removed. A proxy, not a detector: instruments living +/// center-mid (lead guitar, synth hooks) light it up too, which is why the +/// rough-sync generator that consumes it is labeled ESTIMATED and hands the +/// result to the correction editor rather than presenting it as truth. +pub struct VocalActivity { + pub frame_ms: u32, + /// 0..1 per frame. + pub frames: Vec, +} + +pub fn vocal_activity(path: &Path, frame_ms: u32) -> Result { + let decoded = decode(path, Some(MAX_RENDER_SECS + 2.0))?; + let frame_ms = frame_ms.clamp(40, 500); + let frame_len = (decoded.sample_rate as usize * frame_ms as usize) / 1000; + let channels = decoded.channels; + let mut low = Biquad::highpass(decoded.sample_rate as f32, 180.0); + let mut high = Biquad::lowpass(decoded.sample_rate as f32, 7_500.0); + let mut frames = Vec::new(); + let mut acc = 0.0f64; + let mut n = 0usize; + for frame in decoded.samples.chunks(channels) { + let mid = if channels >= 2 { + 0.5 * (frame[0] + frame[1]) + } else { + frame[0] + }; + let banded = high.process(low.process(mid)); + acc += (banded * banded) as f64; + n += 1; + if n >= frame_len { + frames.push((acc / n as f64).sqrt() as f32); + acc = 0.0; + n = 0; + } + } + if n > frame_len / 4 { + frames.push((acc / n.max(1) as f64).sqrt() as f32); + } + if frames.is_empty() { + return Err("nothing decodable".into()); + } + // Normalize against the loud end (95th percentile), so one hot chorus + // does not flatten the whole song to zero. + let mut sorted: Vec = frames.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let p95 = sorted[(sorted.len() * 95 / 100).min(sorted.len() - 1)].max(1e-6); + for value in &mut frames { + *value = (*value / p95).min(1.0); + } + Ok(VocalActivity { frame_ms, frames }) +} + +// ---- vocal reduction ------------------------------------------------------- + +/// What the renderer produced (for the caller's labels and sanity checks). +pub struct VocalReduction { + pub duration_secs: f64, + pub sample_rate: u32, +} + +/// Render a vocal-reduced copy of `src` to `dest_wav` (16-bit stereo WAV). +/// +/// The method is the classic out-of-phase cancellation with band limiting: +/// the center (mid) channel is removed in the vocal band while the lows +/// (bass, kick) and the highest air band are kept, and the side content +/// passes through untouched. It works in proportion to how center-mixed the +/// lead vocal is — modern wide/reverbed vocals survive partially. That limit +/// is inherent to the technique; the honest fix is the future learned +/// separator the research bundle scopes. +pub fn render_vocal_reduced(src: &Path, dest_wav: &Path) -> Result { + // The decode itself stops just past the cap, so an accidental multi-hour + // file cannot balloon memory before the refusal fires. + let decoded = decode(src, Some(MAX_RENDER_SECS + 2.0))?; + if decoded.channels < 2 { + return Err("This recording is mono — there is no center channel to cancel".into()); + } + let frames = decoded.samples.len() / decoded.channels; + let duration = frames as f64 / decoded.sample_rate as f64; + if duration > MAX_RENDER_SECS { + return Err("Track is too long for the instrumental renderer (20 min cap)".into()); + } + // First two channels carry the stereo image; extra channels are dropped. + let mut stereo: Vec = Vec::with_capacity(frames * 2); + for frame in decoded.samples.chunks(decoded.channels) { + stereo.push(frame[0]); + stereo.push(frame[1]); + } + process_center_cancel(&mut stereo, decoded.sample_rate); + write_wav_16(dest_wav, &stereo, decoded.sample_rate)?; + Ok(VocalReduction { + duration_secs: duration, + sample_rate: decoded.sample_rate, + }) +} + +/// In-place band-limited center cancellation over interleaved stereo. +/// +/// out_L = side + LP(mid) + HP(mid); out_R = −side + LP(mid) + HP(mid) — +/// removing the mid band where vocals live (~180 Hz–7.5 kHz) and keeping the +/// side image, the lows, and the air. +fn process_center_cancel(stereo: &mut [f32], sample_rate: u32) { + let mut low = Biquad::lowpass(sample_rate as f32, 180.0); + let mut high = Biquad::highpass(sample_rate as f32, 7_500.0); + let keep_air = sample_rate >= 22_050; + for frame in stereo.chunks_mut(2) { + let (l, r) = (frame[0], frame[1]); + let mid = 0.5 * (l + r); + let side = 0.5 * (l - r); + let lows = low.process(mid); + let air = if keep_air { high.process(mid) } else { 0.0 }; + let kept = lows + air; + frame[0] = ((side + kept) * 0.95).clamp(-1.0, 1.0); + frame[1] = ((kept - side) * 0.95).clamp(-1.0, 1.0); + } +} + +/// RBJ-cookbook biquad, Butterworth Q. +struct Biquad { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + x1: f32, + x2: f32, + y1: f32, + y2: f32, +} + +impl Biquad { + fn new(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Biquad { + Biquad { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + } + } + fn lowpass(sample_rate: f32, fc: f32) -> Biquad { + let w = 2.0 * std::f32::consts::PI * fc / sample_rate; + // Butterworth: alpha = sin(w)/(2·Q) with Q = 1/√2. + let alpha = w.sin() * std::f32::consts::FRAC_1_SQRT_2; + let c = w.cos(); + Biquad::new( + (1.0 - c) / 2.0, + 1.0 - c, + (1.0 - c) / 2.0, + 1.0 + alpha, + -2.0 * c, + 1.0 - alpha, + ) + } + fn highpass(sample_rate: f32, fc: f32) -> Biquad { + let w = 2.0 * std::f32::consts::PI * fc / sample_rate; + let alpha = w.sin() * std::f32::consts::FRAC_1_SQRT_2; + let c = w.cos(); + Biquad::new( + (1.0 + c) / 2.0, + -(1.0 + c), + (1.0 + c) / 2.0, + 1.0 + alpha, + -2.0 * c, + 1.0 - alpha, + ) + } + fn process(&mut self, x: f32) -> f32 { + let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2 + - self.a1 * self.y1 + - self.a2 * self.y2; + self.x2 = self.x1; + self.x1 = x; + self.y2 = self.y1; + self.y1 = y; + y + } +} + +/// Minimal 16-bit PCM stereo WAV writer (44-byte canonical header). +fn write_wav_16(dest: &Path, interleaved: &[f32], sample_rate: u32) -> Result<(), String> { + let data_len = (interleaved.len() * 2) as u32; + let mut out = std::io::BufWriter::new( + std::fs::File::create(dest).map_err(|e| e.to_string())?, + ); + let byte_rate = sample_rate * 2 * 2; + let mut header = Vec::with_capacity(44); + header.extend_from_slice(b"RIFF"); + header.extend_from_slice(&(36 + data_len).to_le_bytes()); + header.extend_from_slice(b"WAVEfmt "); + header.extend_from_slice(&16u32.to_le_bytes()); + header.extend_from_slice(&1u16.to_le_bytes()); // PCM + header.extend_from_slice(&2u16.to_le_bytes()); // stereo + header.extend_from_slice(&sample_rate.to_le_bytes()); + header.extend_from_slice(&byte_rate.to_le_bytes()); + header.extend_from_slice(&4u16.to_le_bytes()); // block align + header.extend_from_slice(&16u16.to_le_bytes()); // bits + header.extend_from_slice(b"data"); + header.extend_from_slice(&data_len.to_le_bytes()); + out.write_all(&header).map_err(|e| e.to_string())?; + let mut bytes = Vec::with_capacity(interleaved.len() * 2); + for &sample in interleaved { + bytes.extend_from_slice(&((sample.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes()); + } + out.write_all(&bytes).map_err(|e| e.to_string())?; + out.flush().map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rms(stereo: &[f32]) -> f32 { + (stereo.iter().map(|s| s * s).sum::() / stereo.len() as f32).sqrt() + } + + fn sine_stereo(freq: f32, secs: f32, rate: u32, pan: &str) -> Vec { + let frames = (secs * rate as f32) as usize; + let mut out = Vec::with_capacity(frames * 2); + for i in 0..frames { + let s = (2.0 * std::f32::consts::PI * freq * i as f32 / rate as f32).sin() * 0.5; + match pan { + "center" => { + out.push(s); + out.push(s); + } + "left" => { + out.push(s); + out.push(0.0); + } + // Anti-phase: pure side content. + _ => { + out.push(s); + out.push(-s); + } + } + } + out + } + + /// The whole point: a center-panned vocal-band tone dies, a center bass + /// tone survives, side content survives. + #[test] + fn center_vocal_band_is_cancelled_lows_and_sides_survive() { + let rate = 44_100; + let mut vocal = sine_stereo(1_000.0, 0.5, rate, "center"); + let before = rms(&vocal); + process_center_cancel(&mut vocal, rate); + assert!( + rms(&vocal) < before * 0.06, + "center 1kHz must drop >24dB, got {} → {}", + before, + rms(&vocal) + ); + + let mut bass = sine_stereo(60.0, 0.5, rate, "center"); + let bass_before = rms(&bass); + process_center_cancel(&mut bass, rate); + assert!( + rms(&bass) > bass_before * 0.6, + "center 60Hz must survive the low-band keep, got {} → {}", + bass_before, + rms(&bass) + ); + + let mut side = sine_stereo(1_000.0, 0.5, rate, "side"); + let side_before = rms(&side); + process_center_cancel(&mut side, rate); + assert!( + rms(&side) > side_before * 0.7, + "side content must pass through, got {} → {}", + side_before, + rms(&side) + ); + } + + #[test] + fn onset_ignores_a_lone_tick() { + // 40 silent frames, one loud blip, more silence, then real audio. + let mut frames = vec![0.0005f32; 40]; + frames[10] = 0.2; // vinyl tick — one frame only + frames.extend(vec![0.1f32; 20]); + assert_eq!(onset_from_rms(&frames), Some(40)); + assert_eq!(onset_from_rms(&[0.0001f32; 50]), None, "never audible"); + } + + /// End-to-end through the WAV writer and back through symphonia: the + /// rendered file decodes, keeps the length, and is quieter in the mid. + #[test] + fn renders_a_playable_wav() { + let dir = std::env::temp_dir().join("ssplus-vocals-test"); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("src.wav"); + let dest = dir.join("out.wav"); + let rate = 44_100u32; + // A "song": center 1kHz vocal + left-panned 3kHz guitar. + let mut mix = sine_stereo(1_000.0, 1.0, rate, "center"); + let guitar = sine_stereo(3_000.0, 1.0, rate, "left"); + for (m, g) in mix.iter_mut().zip(guitar.iter()) { + *m = (*m + g).clamp(-1.0, 1.0); + } + write_wav_16(&src, &mix, rate).unwrap(); + + let result = render_vocal_reduced(&src, &dest).unwrap(); + assert_eq!(result.sample_rate, rate); + assert!((result.duration_secs - 1.0).abs() < 0.05); + + let back = decode(&dest, None).unwrap(); + assert_eq!(back.channels, 2); + let frames = back.samples.len() / 2; + assert!((frames as f64 / rate as f64 - 1.0).abs() < 0.05); + + // Mono files are refused, not mangled. + let mono = dir.join("mono.wav"); + { + use std::io::Write as _; + let samples: Vec = sine_stereo(440.0, 0.2, rate, "center") + .chunks(2) + .map(|c| c[0]) + .collect(); + let data_len = (samples.len() * 2) as u32; + let mut out = std::fs::File::create(&mono).unwrap(); + let mut header = Vec::new(); + header.extend_from_slice(b"RIFF"); + header.extend_from_slice(&(36 + data_len).to_le_bytes()); + header.extend_from_slice(b"WAVEfmt "); + header.extend_from_slice(&16u32.to_le_bytes()); + header.extend_from_slice(&1u16.to_le_bytes()); + header.extend_from_slice(&1u16.to_le_bytes()); + header.extend_from_slice(&rate.to_le_bytes()); + header.extend_from_slice(&(rate * 2).to_le_bytes()); + header.extend_from_slice(&2u16.to_le_bytes()); + header.extend_from_slice(&16u16.to_le_bytes()); + header.extend_from_slice(b"data"); + header.extend_from_slice(&data_len.to_le_bytes()); + out.write_all(&header).unwrap(); + for s in &samples { + out.write_all(&((s * 32767.0) as i16).to_le_bytes()).unwrap(); + } + } + assert!(render_vocal_reduced(&mono, &dest).is_err()); + } +} diff --git a/crates/soulseek-client/Cargo.toml b/crates/soulseek-client/Cargo.toml index 20ab78e..1249fa1 100644 --- a/crates/soulseek-client/Cargo.toml +++ b/crates/soulseek-client/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true [dependencies] soulseek-proto = { path = "../soulseek-proto" } +audio-formats = { path = "../audio-formats" } tokio.workspace = true tokio-util = "0.7" bytes.workspace = true diff --git a/crates/soulseek-client/src/distributed.rs b/crates/soulseek-client/src/distributed.rs index 4ef9a5e..277ace6 100644 --- a/crates/soulseek-client/src/distributed.rs +++ b/crates/soulseek-client/src/distributed.rs @@ -93,8 +93,11 @@ async fn run_parent(inner: &Arc, mut stream: TcpStream) -> std::io::Resul responder::respond_to_search(inner.clone(), username, token, query); } Ok(dist::DistributedEvent::BranchLevel(level)) => { - // Report our level (parent + 1) to the server. - let _ = inner.send_server(server::branch_level(level + 1)); + // Report our level (parent + 1) to the server. Saturating: the + // level is a peer-supplied u32, and one parent really did send + // a value at the top of the range — `+ 1` panicked this worker + // (debug overflow checks) mid-session on 2026-08-14. + let _ = inner.send_server(server::branch_level(level.saturating_add(1))); } Ok(dist::DistributedEvent::BranchRoot(root)) => { let _ = inner.send_server(server::branch_root(&root)); diff --git a/crates/soulseek-client/src/share.rs b/crates/soulseek-client/src/share.rs index e41f41f..df99c6e 100644 --- a/crates/soulseek-client/src/share.rs +++ b/crates/soulseek-client/src/share.rs @@ -11,9 +11,10 @@ use std::sync::RwLock; use soulseek_proto::types::{Directory, File}; -const AUDIO_EXTS: [&str; 10] = [ - "flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff", -]; +// Shared with the library scanner and the row pruner (`audio-formats`). The +// share index deliberately offers formats this app cannot itself PLAY — a peer +// asking for `.ape` is well served by a file we hold, and refusing to share it +// helps nobody. /// One shared file: its remote (virtual) path and where it lives locally. #[derive(Debug, Clone)] @@ -161,8 +162,14 @@ fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) { let path = entry.path(); if path.is_dir() { // The download staging pen is not part of anyone's library — - // half-finished transfers are not shareable records. - if path.file_name().is_some_and(|n| n == "(Cache)") { + // half-finished transfers are not shareable records. `.soulstream` + // holds the library's own database, review session and artwork + // cache; only audio is ever indexed, so it could not be served + // anyway, but it has no business appearing in a browse listing. + if path + .file_name() + .is_some_and(|n| n == "(Cache)" || n == ".soulstream") + { continue; } walk(&path, base, root, index); @@ -203,10 +210,7 @@ fn to_remote_path(path: &Path, base: &str, root: &Path) -> String { } fn is_audio(path: &Path) -> bool { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str())) - .unwrap_or(false) + audio_formats::is_audio_path(path) } #[cfg(test)] diff --git a/crates/tagger/src/lib.rs b/crates/tagger/src/lib.rs index 480af4f..64c4d22 100644 --- a/crates/tagger/src/lib.rs +++ b/crates/tagger/src/lib.rs @@ -26,6 +26,10 @@ pub struct TagSummary { pub album: Option, pub title: Option, pub track_no: Option, + /// Which disc of the release, 1-based, when the file says so. Every disc + /// numbers its tracks from 1, so without this a two-disc rip reads as two + /// track 1s, two track 2s, and so on down the album. + pub disc_no: Option, pub year: Option, pub duration_secs: Option, pub bitrate_kbps: Option, @@ -50,6 +54,7 @@ pub fn read_summary(path: &Path) -> Result { summary.album = non_empty(tag.album().as_deref()); summary.title = non_empty(tag.title().as_deref()); summary.track_no = tag.track(); + summary.disc_no = tag.disk(); summary.year = tag.year(); summary.album_artist = non_empty( tag.get_string(&lofty::tag::ItemKey::AlbumArtist) @@ -69,6 +74,10 @@ pub struct TagWrite { pub album: Option, pub title: Option, pub track_no: Option, + /// Written into the file, not just the database: a release joined from + /// disc folders loses that structure the moment it is filed flat, and the + /// tag is the only place the answer survives being read by anything else. + pub disc_no: Option, pub year: Option, pub label: Option, pub genre: Option, @@ -109,6 +118,7 @@ pub fn write_tags( || write.album.is_some() || write.title.is_some() || write.track_no.is_some() + || write.disc_no.is_some() || write.year.is_some() || write.label.is_some() || write.genre.is_some(); @@ -147,6 +157,9 @@ pub fn write_tags( if let Some(v) = write.track_no { tag.set_track(v); } + if let Some(v) = write.disc_no { + tag.set_disk(v); + } if let Some(v) = write.year { tag.set_year(v); } @@ -222,6 +235,104 @@ pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> { Ok(()) } +/// Lyrics carried inside the audio file itself. +#[derive(Debug, Clone, Default)] +pub struct EmbeddedLyrics { + /// Unsynchronized text (ID3 USLT or the generic LYRICS field). + pub plain: Option, + /// Synchronized lines from an ID3v2 SYLT frame, (milliseconds, text), + /// sorted by time. Empty when the file has none. + pub synced: Vec<(u32, String)>, +} + +/// Read embedded lyrics. The plain field comes from the generic tag on every +/// format lofty knows; SYLT lives only in ID3v2, which lofty exposes as a +/// binary frame on the concrete MPEG file type, so that pass is MP3-only. +pub fn read_lyrics(path: &Path) -> Result { + use lofty::id3::v2::{Frame, SynchronizedTextFrame, TimestampFormat}; + use lofty::prelude::*; + + let mut out = EmbeddedLyrics::default(); + let tagged = Probe::open(path)?.read()?; + if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) { + out.plain = non_empty( + tag.get_string(&lofty::tag::ItemKey::Lyrics) + .map(|s| s.to_string()) + .as_deref(), + ); + } + + let is_mp3 = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("mp3")); + if is_mp3 { + if let Ok(mut file) = std::fs::File::open(path) { + if let Ok(mpeg) = lofty::mpeg::MpegFile::read_from( + &mut file, + lofty::config::ParseOptions::new(), + ) { + if let Some(id3) = mpeg.id3v2() { + for frame in id3 { + let Frame::Binary(binary) = frame else { continue }; + if frame.id().as_str() != "SYLT" { + continue; + } + let Ok(sylt) = SynchronizedTextFrame::parse( + &binary.data, + lofty::id3::v2::FrameFlags::default(), + ) else { + continue; + }; + // MPEG-frame-counted timestamps cannot be turned into + // time without decoding; only millisecond SYLT is usable. + if sylt.timestamp_format != TimestampFormat::MS { + continue; + } + let mut lines: Vec<(u32, String)> = sylt + .content + .into_iter() + .map(|(ms, text)| (ms, text.trim().to_string())) + .collect(); + lines.sort_by_key(|(ms, _)| *ms); + if lines.iter().any(|(_, t)| !t.is_empty()) { + out.synced = lines; + break; + } + } + } + } + } + } + Ok(out) +} + +/// The file's own ISRC tag, normalized to the bare 12-character form +/// (CC-XXX-YY-NNNNN with the dashes people sometimes write stripped), or +/// None when absent or malformed. An ISRC names the exact RECORDING, which +/// makes it the one tag strong enough to match against external catalogs +/// without touching names. +pub fn read_isrc(path: &Path) -> Result> { + let tagged = Probe::open(path)?.read()?; + let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else { + return Ok(None); + }; + let raw = tag + .get_string(&lofty::tag::ItemKey::Isrc) + .map(|s| s.to_string()); + Ok(raw.and_then(|s| { + let normalized: String = s + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) + .collect(); + let shaped = normalized.len() == 12 + && normalized.chars().take(2).all(|c| c.is_ascii_alphabetic()) + && normalized.chars().skip(5).all(|c| c.is_ascii_digit()); + shaped.then_some(normalized) + })) +} + fn non_empty(s: Option<&str>) -> Option { s.map(str::trim).filter(|s| !s.is_empty()).map(String::from) } diff --git a/package.json b/package.json index f2d5e4d..4a7336d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build", "tauri": "tauri", - "test": "node --test tests/preview/*.test.mjs" + "test": "node --test tests/preview/" }, "license": "ISC", "devDependencies": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c987c51..4e3ca5a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,11 @@ image.workspace = true tokio.workspace = true base64 = "0.22" +# XSPF playlist import (playlist_formats.rs). quick-xml never resolves DTDs +# or external entities, which is exactly the safety posture the importer wants. +quick-xml = "0.37" +# LRCLIB lyrics client (lyrics.rs). Already in the tree via the metadata crate. +reqwest = { version = "0.12", features = ["json"] } keyring = { version = "3", features = ["windows-native"] } sysinfo = "0.33" soulseek-proto = { path = "../crates/soulseek-proto" } @@ -37,6 +42,7 @@ player = { path = "../crates/player" } tagger = { path = "../crates/tagger" } metadata = { path = "../crates/metadata" } library = { path = "../crates/library" } +audio-formats = { path = "../crates/audio-formats" } [target.'cfg(windows)'.dependencies] windows = { version = "0.61.3", features = [ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a79ab51..43ecd7e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -143,6 +143,9 @@ pub async fn set_settings( volume_normalization: Option, ) -> Result<(), String> { let mut cfg = config::load(&app); + let library_folder_moved = download_dir + .as_deref() + .is_some_and(|d| Some(d) != cfg.download_dir.as_deref()); if let Some(d) = download_dir { cfg.download_dir = Some(d); } @@ -208,6 +211,25 @@ pub async fn set_settings( } config::save(&app, &cfg); + // A new library folder is a new library. Re-base immediately rather than + // at the next launch, so what the shelves show is what the folder holds — + // the old behaviour left the previous folder's albums standing forever, + // and emptying the new folder could not remove them. + if library_folder_moved { + let app2 = app.clone(); + let root = config::download_dir(&app, &cfg); + tauri::async_runtime::spawn(async move { + let lib = app2.state::().library.clone(); + if let Ok(Ok(dropped)) = + tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await + { + log::info!("library folder changed: dropped {dropped} tracks from the old folder"); + } + let _ = crate::pipeline::rescan_library(app2.clone()).await; + let _ = app2.emit("soulseek:library-changed", ()); + }); + } + // Tighter limits take effect right away, not at the next stream/startup. if retention_changed { let app2 = app.clone(); @@ -1263,10 +1285,13 @@ pub struct CatalogHeroPreviewResult { const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1"; const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16; +/// The CHOSEN hero per artist — a decision about this library, not a render +/// cache — so it travels with the library. (The upscaled results keyed by URL +/// and recipe stay in the machine's cache dir: they regenerate cheaply and +/// would otherwise put gigabytes of derived pixels on the drive.) fn artist_hero_dir(app: &tauri::AppHandle) -> Result { - let dir = app - .path() - .app_data_dir() + let cfg = config::load(app); + let dir = config::ensure_library_data_dir(app, &cfg) .map_err(|e| e.to_string())? .join("artist-heroes"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; @@ -4366,7 +4391,7 @@ pub async fn home_warm(app: tauri::AppHandle, lib: State<'_, LibraryState>) -> R Ok(()) } -// ---- playlists ---- +// ---- playlists live in src-tauri/src/playlists.rs ---- fn now_secs() -> i64 { std::time::SystemTime::now() @@ -4375,111 +4400,6 @@ fn now_secs() -> i64 { .unwrap_or(0) } -#[tauri::command] -pub async fn list_playlists( - lib: State<'_, LibraryState>, -) -> Result, String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.list_playlists().map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn create_playlist( - app: tauri::AppHandle, - lib: State<'_, LibraryState>, - name: String, -) -> Result { - let l = lib.library.clone(); - let id = tauri::async_runtime::spawn_blocking(move || l.create_playlist(&name, now_secs())) - .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string())?; - let _ = app.emit("soulseek:playlists-changed", ()); - Ok(id) -} - -#[tauri::command] -pub async fn delete_playlist( - app: tauri::AppHandle, - lib: State<'_, LibraryState>, - id: i64, -) -> Result<(), String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id)) - .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string())?; - let _ = app.emit("soulseek:playlists-changed", ()); - Ok(()) -} - -#[tauri::command] -pub async fn playlist_tracks( - lib: State<'_, LibraryState>, - id: i64, -) -> Result, String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.playlist_tracks(id).map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())? -} - -#[derive(serde::Deserialize)] -pub struct TrackInput { - pub title: String, - pub artist: String, - pub duration_secs: Option, - pub path: String, - pub art_path: Option, -} - -#[tauri::command] -pub async fn add_to_playlist( - app: tauri::AppHandle, - lib: State<'_, LibraryState>, - playlist_id: i64, - tracks: Vec, -) -> Result<(), String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || -> library::Result<()> { - for t in tracks { - l.add_to_playlist( - playlist_id, - &library::PlaylistTrack { - position: 0, // assigned by insert - title: t.title, - artist: t.artist, - duration_secs: t.duration_secs, - path: t.path, - art_path: t.art_path, - }, - )?; - } - Ok(()) - }) - .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string())?; - let _ = app.emit("soulseek:playlists-changed", ()); - Ok(()) -} - -#[tauri::command] -pub async fn remove_from_playlist( - lib: State<'_, LibraryState>, - playlist_id: i64, - position: i64, -) -> Result<(), String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.remove_from_playlist(playlist_id, position)) - .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string())?; - Ok(()) -} - /// Compute (or fetch cached) waveform peaks for a local audio file. /// Returns `buckets` normalized 0..1 values, or an empty array on failure. #[tauri::command] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index e1936eb..c74a104 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -258,6 +258,148 @@ pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf { music.join("SoulStream") } +/// The folder holding everything the app knows ABOUT this library — the +/// database, the review session, the artwork it has chosen and cached. +/// +/// It lives inside the library folder because the library has to be portable: +/// carry the drive to another machine and the records, the decisions and the +/// covers arrive together. State kept in AppData does not travel, and an +/// import saved at one machine and opened at another lost every choice it had +/// made while the audio arrived intact. +/// +/// AppData keeps only what is about the APP on THIS machine: credentials, the +/// audio device, window state, and caches of REMOTE data (`net-cache-v1`, +/// `stream-cache`, the GPU render caches) which are regenerable and would +/// otherwise bloat the drive. +/// +/// The name starts with a dot so it sorts out of the way, and the share index +/// skips it by name as well as by only ever indexing audio. +pub const LIBRARY_DATA_DIRNAME: &str = ".soulstream"; + +pub fn library_data_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf { + download_dir(app, cfg).join(LIBRARY_DATA_DIRNAME) +} + +/// `library_data_dir`, created if missing. Errors are the caller's to handle: +/// with the drive unplugged this cannot succeed, and inventing a fallback in +/// AppData is exactly the split-brain this whole change removes. +pub fn ensure_library_data_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> std::io::Result { + let dir = library_data_dir(app, cfg); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +/// Everything that used to live in AppData and describes the library, moved +/// once into the library folder. Ordered by how much it hurts to lose. +/// +/// `(appdata_relative_name, is_dir)` — the name is kept identical on the far +/// side so a migrated folder is recognisable, and so a half-finished migration +/// can be resumed by simply running again. +const MIGRATED_LIBRARY_STATE: &[(&str, bool)] = &[ + ("library.db", false), + ("import-session.json", false), + ("import-art", true), + ("art", true), + ("artist-identity-v1", true), + ("artist-heroes", true), + ("loudness-cache.json", false), +]; + +/// Move library state out of AppData and into the library folder, once. +/// +/// Rules, in order of importance: +/// - A destination that already exists WINS and the source is left alone. +/// The library folder is the truth; a stale AppData copy must never +/// overwrite it, and running this twice must be a no-op. +/// - Nothing is deleted until the copy is verified in place. A rename across +/// volumes fails on Windows, so this is copy-then-remove, and the remove is +/// skipped if anything went wrong — a leftover AppData copy is harmless, +/// a half-moved database is not. +/// - The SQLite sidecars (`-wal`, `-shm`) travel WITH the database. Moving +/// `library.db` alone can discard committed transactions still in the WAL. +/// +/// Returns the names actually moved, for the log. +pub fn migrate_library_state_into_library(app: &tauri::AppHandle, cfg: &AppConfig) -> Vec { + let Ok(dest_root) = ensure_library_data_dir(app, cfg) else { + return Vec::new(); + }; + let Ok(src_root) = app.path().app_data_dir() else { + return Vec::new(); + }; + let mut moved = Vec::new(); + for (name, is_dir) in MIGRATED_LIBRARY_STATE { + let src = src_root.join(name); + let dest = dest_root.join(name); + if dest.exists() || !src.exists() { + continue; + } + let ok = if *is_dir { + copy_dir_all(&src, &dest).is_ok() + } else { + // The database's write-ahead log is part of the database. + let mut all = std::fs::copy(&src, &dest).is_ok(); + for suffix in ["-wal", "-shm"] { + let extra = src_root.join(format!("{name}{suffix}")); + if extra.exists() { + all &= std::fs::copy(&extra, dest_root.join(format!("{name}{suffix}"))).is_ok(); + } + } + all + }; + if !ok { + log::warn!("library migration: could not move {name} — leaving it in AppData"); + continue; + } + // Verified across: retire the original rather than delete it outright, + // so a mistake here is recoverable by hand. + let retired = src_root.join(format!("{name}.moved-to-library")); + let _ = std::fs::remove_dir_all(&retired); + let _ = std::fs::remove_file(&retired); + if std::fs::rename(&src, &retired).is_ok() { + for suffix in ["-wal", "-shm"] { + let extra = src_root.join(format!("{name}{suffix}")); + let _ = std::fs::rename(&extra, src_root.join(format!("{name}{suffix}.moved-to-library"))); + } + } + moved.push((*name).to_string()); + } + moved +} + +fn copy_dir_all(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dest)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let to = dest.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_all(&entry.path(), &to)?; + } else { + std::fs::copy(entry.path(), to)?; + } + } + Ok(()) +} + +/// Where an import writes. The chosen destination, or — when none has been +/// chosen — the library folder itself. +/// +/// These were two separately-nullable settings that both mean "the library", +/// and `library_dir` being null silently disabled importing: apply refused +/// with "no library folder chosen yet" and the picker's save-and-bank returned +/// without doing anything or saying so. There is no sensible third answer — +/// music imported into the library goes into the library folder — so an unset +/// destination now means exactly that instead of meaning "refuse". +/// +/// A destination OUTSIDE the library folder is still honoured if explicitly +/// set, but it is a strange thing to want: `prune_outside_root` will drop +/// those rows again at the next launch, because they are not in the library. +pub fn import_dest_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf { + match cfg.library_dir.as_deref().map(str::trim) { + Some(d) if !d.is_empty() => PathBuf::from(d), + _ => download_dir(app, cfg), + } +} + pub const DEFAULT_LIBRARY_LAYOUT: &str = r"{artist}\{year} - {album}"; pub fn library_layout(cfg: &AppConfig) -> &str { diff --git a/src-tauri/src/import.rs b/src-tauri/src/import.rs index d183309..055df07 100644 --- a/src-tauri/src/import.rs +++ b/src-tauri/src/import.rs @@ -20,7 +20,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use tauri::{Emitter, Manager, State}; +use tauri::{Emitter, State}; use crate::config; use crate::pipeline::{self, LibraryState}; @@ -368,9 +368,82 @@ pub fn import_session_clear(app: tauri::AppHandle) -> Result<(), String> { } } +/// Where the source folder sits RELATIVE to the library folder, when that can +/// be said at all. Saved beside the absolute path so a session can find its +/// source again after the drive is carried somewhere else. +/// +/// "I moved the whole folder" is the case this serves: source and library keep +/// their arrangement, only the prefix changes. A source on a different drive +/// from the library has no relative form and gets `None` — its absolute path +/// is the only thing that could ever have identified it. +#[tauri::command] +pub fn import_portable_root(app: tauri::AppHandle, root: String) -> Option { + let cfg = config::load(&app); + relative_to(&config::download_dir(&app, &cfg), Path::new(&root)) +} + +fn relative_to(from: &Path, to: &Path) -> Option { + let key = |c: &std::path::Component| c.as_os_str().to_string_lossy().to_lowercase(); + let (a, b): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect()); + // Different drives (or one relative, one not) share no prefix worth having. + if a.first().map(key) != b.first().map(key) { + return None; + } + let common = a + .iter() + .zip(b.iter()) + .take_while(|(x, y)| key(x) == key(y)) + .count(); + let mut out = PathBuf::new(); + for _ in common..a.len() { + out.push(".."); + } + for c in &b[common..] { + out.push(c.as_os_str()); + } + Some(out.to_string_lossy().into_owned()) +} + +/// The source folder this session was reviewing, as it exists NOW. +/// +/// Tries the path as saved first — nothing has moved in the common case. Then +/// the relative form against the current library folder, which is what makes a +/// review started at one machine resumable at another. `None` means the folder +/// is genuinely not there and only the user can say where it went. +#[tauri::command] +pub fn import_resolve_root( + app: tauri::AppHandle, + saved: Option, + rel: Option, +) -> Option { + if let Some(s) = saved.as_deref() { + if Path::new(s).is_dir() { + return Some(s.to_string()); + } + } + let rel = rel?; + let cfg = config::load(&app); + let mut candidate = config::download_dir(&app, &cfg); + for part in rel.split(['/', '\\']) { + match part { + "" | "." => {} + ".." => { + candidate.pop(); + } + p => candidate.push(p), + } + } + candidate + .is_dir() + .then(|| candidate.to_string_lossy().into_owned()) +} + +/// In the LIBRARY folder, not AppData: an import saved at one machine and +/// carried to another used to arrive with the audio intact and every decision +/// gone, because the review's session never left the machine that made it. fn session_path(app: &tauri::AppHandle) -> Result { - let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let cfg = config::load(app); + let dir = config::ensure_library_data_dir(app, &cfg).map_err(|e| e.to_string())?; Ok(dir.join("import-session.json")) } @@ -394,20 +467,19 @@ pub struct ImportDest { #[tauri::command] pub fn import_get_dest(app: tauri::AppHandle) -> Result { let cfg = config::load(&app); - let suggested = if cfg.library_dir.is_none() { - Some(config::download_dir(&app, &cfg).to_string_lossy().into_owned()) - } else { - None - }; - let free_bytes = cfg + // `dir` is the destination that WILL be written to, never null. It used to + // report only an explicitly-chosen folder, so a null read as "no + // destination" and every import path disabled itself — the review's Import + // button greyed out and the picker's save-and-bank returned in silence. + let dir = config::import_dest_dir(&app, &cfg); + let suggested = cfg .library_dir .as_deref() - .or(suggested.as_deref()) - .and_then(|d| free_space_of(Path::new(d))); + .map_or_else(|| Some(dir.to_string_lossy().into_owned()), |_| None); Ok(ImportDest { - dir: cfg.library_dir.clone(), + free_bytes: free_space_of(&dir), + dir: Some(dir.to_string_lossy().into_owned()), suggested, - free_bytes, layout: config::library_layout(&cfg).to_string(), }) } @@ -484,13 +556,62 @@ pub async fn import_local_art( app: tauri::AppHandle, source_root: String, rel: String, + parts: Option>, ) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel)) + let parts = parts.unwrap_or_default(); + tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel, &parts)) .await .map_err(|e| e.to_string()) } -fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option { +/// One folder's audio, sorted, and NOT what is underneath it. +fn audio_in_dir(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut here: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && audio_formats::is_audio_path(p)) + .collect(); + here.sort(); + out.append(&mut here); +} + +/// The audio a record actually OWNS: its own folder, plus the part folders the +/// review named — in part order, sorted within each part. The same walk apply +/// does, and for the same reason ([`ApplyAlbum::parts`]). +/// +/// It is deliberately not recursive. A recursive walk is how the picker came to +/// show a NEIGHBOUR's cover as "yours": an artist folder holding one loose file +/// beside its albums — `Sweet\` is a single 52 MB "So Serene" rip, while +/// `Sweet\Sweet Valley - SV [2013]\` is a separate 12-track record — collected +/// both sets, and sorting the full paths put the subfolder first ('V' 0x56 +/// before 'o' 0x6F), so the record was shown the other record's artwork. That +/// picture also became the record's `art_dhash`, which is what the discography +/// pass compares against the Cover Art Archive. +fn own_audio_files(dir: &Path, parts: &[String]) -> Vec { + let mut out = Vec::new(); + if parts.is_empty() { + audio_in_dir(dir, &mut out); + } else { + for part in parts { + // An empty entry is the record's own folder — a container holding + // loose audio beside its parts. + if part.is_empty() { + audio_in_dir(dir, &mut out); + } else { + audio_in_dir(&dir.join(part), &mut out); + } + } + } + out +} + +fn local_art_of( + app: &tauri::AppHandle, + source_root: &str, + rel: &str, + parts: &[String], +) -> Option { let dir = Path::new(source_root).join(rel); if !dir.is_dir() { return None; @@ -499,14 +620,22 @@ fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option< // Embedded front cover of the first files that carry one — materialized // into app data so the webview can read it (the audio file itself can't // be handed to an ). - let mut files: Vec = Vec::new(); - pipeline::collect_audio_files(&dir, &mut files, 0); - files.sort(); + let files = own_audio_files(&dir, parts); for f in files.iter().take(5) { let Ok(Some(bytes)) = tagger::extract_art(f) else { continue }; let Ok(img) = image::load_from_memory(&bytes) else { continue }; let (w, h) = (img.width(), img.height()); - let cache_dir = app.path().app_data_dir().ok()?.join("import-art"); + // In the library folder: these are the covers the review is choosing + // BETWEEN, and a choice made at one machine has to still be there when + // the drive is opened at another. + // v2: entries under the old name were extracted by a RECURSIVE walk, so + // a record sharing a folder with another one cached the neighbour's + // cover — and `if !target.exists()` below means a stale entry is never + // rewritten. Versioning the directory retires all of them at once, + // which is what `cover-logos-v3` and `artist-identity-v1` do. + let cache_dir = config::ensure_library_data_dir(app, &config::load(app)) + .ok()? + .join("import-art-v2"); std::fs::create_dir_all(&cache_dir).ok()?; // Stable name per rel so reopening a record reuses the extraction. let mut hash: u64 = 0xcbf29ce484222325; @@ -673,6 +802,92 @@ pub(crate) fn audition_dir(download_root: &Path, rel: &str) -> PathBuf { .join(pipeline::safe_component(&flat)) } +/// Where one of a found copy's files lands in the pen. Shared by the download +/// and by the completeness check below, so the two can never disagree about +/// which file on disk is which track. +fn pen_target(base: &Path, remote_path: &str) -> PathBuf { + let leaf = remote_path.rsplit('\\').next().unwrap_or(remote_path); + base.join(pipeline::safe_component(leaf)) +} + +/// Which of a found copy's files the pen does not (fully) hold, by index into +/// `files`. +/// +/// Transfers do not survive the app; the files they wrote do. A restored +/// audition therefore LOOKS live and is not, and its arrived-count is a +/// running total kept by events that stopped when the process did — after a +/// crash, a power cut or a restart it is a number about a session that no +/// longer exists. The pen is the only witness left, so it is what gets asked. +/// +/// A SHORT file counts as missing: a transfer killed mid-write leaves a +/// partial behind, and an existence check would call that track arrived and +/// go on to import a truncated one. +#[tauri::command] +pub async fn import_audition_missing( + app: tauri::AppHandle, + rel: String, + files: Vec, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let cfg = config::load(&app); + let base = audition_dir(&config::download_dir(&app, &cfg), &rel); + missing_in_pen(&base, &files) + }) + .await + .map_err(|e| e.to_string()) +} + +/// Empty one record's pen, and report the bytes it freed. +/// +/// Called when the review ABANDONS a peer. Switching cancels that peer's +/// transfers, but it cannot un-write the files they already delivered, and the +/// pen is keyed by record rather than by peer — so an abandoned copy sat there +/// until the whole session was cleared, 189 MB of a stranger's Centipede Hz +/// among them. Apply no longer files those (it takes the chosen copy's own +/// files by name), so this is about disk, not correctness. +/// +/// The path is DERIVED from `rel` here, exactly as the download and the +/// apply-time move derive it. Nothing outside the pen can be named, whatever +/// the caller passes. +#[tauri::command] +pub async fn import_audition_clear(app: tauri::AppHandle, rel: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let cfg = config::load(&app); + let dir = audition_dir(&config::download_dir(&app, &cfg), &rel); + if !dir.is_dir() { + return Ok(0); + } + let freed: u64 = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| e.metadata().ok()) + .filter(|m| m.is_file()) + .map(|m| m.len()) + .sum(); + std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(freed) + }) + .await + .map_err(|e| e.to_string())? +} + +/// The rule itself, without an app handle so it can be tested. A file the pen +/// does not hold is missing; so is one it holds only part of. A size the peer +/// never stated (0) cannot be checked against, and a file that is there is +/// taken at its word rather than called missing forever. +fn missing_in_pen(base: &Path, files: &[AuditionFile]) -> Vec { + files + .iter() + .enumerate() + .filter(|(_, f)| match std::fs::metadata(pen_target(base, &f.path)) { + Ok(m) => f.size > 0 && m.len() < f.size, + Err(_) => true, + }) + .map(|(i, _)| i) + .collect() +} + /// Download a found album copy into `(Cache)\_audition\\` for the /// version comparison — the WHOLE album (his call: keeping it later is a /// move, not a re-download, and one search's worth of requests). @@ -701,8 +916,7 @@ pub async fn import_audition( std::fs::create_dir_all(&base).map_err(|e| e.to_string())?; let mut ids = Vec::with_capacity(files.len()); for (i, f) in files.iter().enumerate() { - let leaf = f.path.rsplit('\\').next().unwrap_or(&f.path); - let target = base.join(pipeline::safe_component(leaf)); + let target = pen_target(&base, &f.path); // Claimed BEFORE the transfer task is spawned: it emits Connecting // immediately, and an event whose id is not yet registered is one the // UI throws away — and worse, one commands.rs routes down the ordinary @@ -755,13 +969,67 @@ pub async fn import_probe_file(path: String) -> Result { /// opened — never a batch (decision 12). Deezer down the cleaned try-list, /// MusicBrainz when Deezer's answer is absent or wrong; the crate holds the /// whole flow (metadata::identify_album). Read-only. +/// +/// `title` is what the record calls ITSELF (the tag majority); `folder` is the +/// folder it sits in, asked after it as the second witness for a record whose +/// tags are wrong (metadata::identify_titles). +/// +/// The rest is the record's SHAPE, which is what the discography pass compares +/// when no phrase can find it: how many files it has, what they are called, the +/// year, and — read here rather than shipped over IPC, because a master cover +/// can be 8 MB — the perceptual hash of its own embedded artwork. #[tauri::command] pub async fn import_identify( + app: tauri::AppHandle, state: State<'_, LibraryState>, artist: String, title: String, + folder: Option, + tracks: Option, + track_titles: Option>, + year: Option, + source_root: Option, + rel: Option, + parts: Option>, ) -> Result { - Ok(state.metadata.identify_album(&artist, &title).await) + // The record's OWN parts, so the hash is its own cover — a record sharing a + // folder with another one used to hash the neighbour's. + let parts = parts.unwrap_or_default(); + let art_dhash = match (source_root.as_deref(), rel.as_deref()) { + (Some(root), Some(rel)) => local_art_dhash(&app, root, rel, &parts).await, + _ => None, + }; + let shape = metadata::RecordShape { + tracks, + track_titles: track_titles.unwrap_or_default(), + year, + art_dhash, + }; + Ok(state + .metadata + .identify_album(&artist, &title, folder.as_deref(), &shape) + .await) +} + +/// The perceptual hash of a record's OWN cover, for comparison against an +/// artist's discography. Reuses `local_art_of`, so it reads the extraction that +/// the artwork panel already cached rather than decoding the audio again. +async fn local_art_dhash( + app: &tauri::AppHandle, + source_root: &str, + rel: &str, + parts: &[String], +) -> Option { + let art = { + let app = app.clone(); + let (root, rel) = (source_root.to_string(), rel.to_string()); + let parts = parts.to_vec(); + tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &root, &rel, &parts)) + .await + .ok()?? + }; + let bytes = tokio::fs::read(&art.path).await.ok()?; + metadata::cover_dhash(&bytes) } /// The artwork panel's data: reference + candidates with decoded sizes and @@ -835,6 +1103,15 @@ pub struct ApplyChoices { /// fewer than this means the transfer never finished, and importing it /// would file a truncated album — apply refuses instead. pub audio_tracks: Option, + /// The chosen copy's own files, as the peer named them. The pen is keyed + /// by RECORD, not by peer, so a record that changed peers mid-audition has + /// TWO copies in one folder: switching cancels the old transfers but + /// cannot un-write the files they already delivered. Taking "every audio + /// file in the pen" then files both — Centipede Hz landed as 17 tracks, + /// deletia's 11 plus the 6 smash2 got through before it was dropped. + /// + /// None (an older session, or "yours") keeps the previous behaviour. + pub audio_files: Option>, } #[derive(Debug, Clone, Serialize)] @@ -880,9 +1157,9 @@ pub async fn import_apply( albums: Vec, ) -> Result { let cfg = config::load(&app); - let Some(dest_root) = cfg.library_dir.clone().map(PathBuf::from) else { - return Err("no library folder chosen yet".into()); - }; + // An unset destination means the library folder, not a refusal — see + // `config::import_dest_dir`. + let dest_root = config::import_dest_dir(&app, &cfg); let layout = config::library_layout(&cfg).to_string(); let embed_max_edge = config::embed_art_max_edge(&cfg); let replace_embedded = config::replace_embedded_art(&cfg); @@ -1023,16 +1300,43 @@ fn apply_one( // match the one the review displayed, because `track_titles` is // positional: disagree here and every title past the first part lands on // the wrong file. A found copy overrides this — the pen is flat. + // Which disc each file belongs to, aligned with `files`. Empty (all None) + // for anything that is not a folded release — there, the disc is whatever + // the file's own tag says, read back after the copy. + let mut discs: Vec> = Vec::new(); let mut files: Vec = if take_theirs || a.parts.is_empty() { let mut f = audio_files_in(&src_dir); + // A found copy is the files the REVIEW chose, not whatever the folder + // happens to hold. Same derivation as the download used, so a name the + // peer sent maps to exactly the file it wrote. Sorting stays where it + // was: `track_titles` is positional against this order. + if take_theirs { + if let Some(names) = a.choices.as_ref().and_then(|c| c.audio_files.as_ref()) { + if !names.is_empty() { + let chosen: std::collections::HashSet = + names.iter().map(|n| pen_target(&pen_dir, n)).collect(); + f.retain(|p| chosen.contains(p)); + } + } + } f.sort(); f } else { let mut out = Vec::new(); - for part in &a.parts { + for (n, part) in a.parts.iter().enumerate() { let dir = if part.is_empty() { src_dir.clone() } else { src_dir.join(part) }; let mut f = audio_files_in(&dir); f.sort(); + // The parts ARE the discs, in the order the review joined them. + // Every part numbers its own tracks from 1, so without this the + // flattened album reads as eleven track-1s in alphabetical order — + // which is exactly what Analord's eleven volumes did. + // + // ONE part is not a multi-disc release: it is a container folder + // whose tracks live a level down, and numbering it "disc 1" would + // put a disc heading on an ordinary album. + let disc = if a.parts.len() > 1 { Some(n as u32 + 1) } else { None }; + discs.extend(std::iter::repeat(disc).take(f.len())); out.append(&mut f); } out @@ -1217,16 +1521,24 @@ fn apply_one( // chosen cover embeds at the same bounded size downloads use, and // replaces an existing picture when config says so — an explicit // pick in the review is the strongest consent that config models. - if let Some(ch) = &a.choices { + // The disc is STRUCTURE, not a review choice: it is what the record is + // made OF, and a record nobody opened still has to come out readable. + // So it is written even when there are no choices at all — and into + // the FILE, because the folder that carried the answer is gone the + // moment the parts are filed flat. + let disc = discs.get(i).copied().flatten(); + if a.choices.is_some() || disc.is_some() { + let ch = a.choices.as_ref(); let write = tagger::TagWrite { - artist: ch.artist.clone(), + artist: ch.and_then(|c| c.artist.clone()), album_artist: None, - album: ch.album.clone(), + album: ch.and_then(|c| c.album.clone()), title: track_titles.and_then(|t| t.get(i).cloned().flatten()), track_no: None, - year: ch.year, - label: ch.label.clone(), - genre: ch.genre.clone(), + disc_no: disc, + year: ch.and_then(|c| c.year), + label: ch.and_then(|c| c.label.clone()), + genre: ch.and_then(|c| c.genre.clone()), }; let embed = embed_art.as_deref(); if let Err(e) = tagger::write_tags( @@ -1249,6 +1561,9 @@ fn apply_one( library .upsert_track( album_id, + // The fold's answer first: it is the structure we just imposed + // on this record. Everything else takes the file at its word. + disc.or(s.disc_no), s.track_no, &title, &artist, @@ -1328,6 +1643,39 @@ fn apply_one( mod tests { use super::*; + /// What the pen holds is what an audition actually got — and a PARTIAL + /// file is not an arrival. A transfer killed mid-write (the app closing, a + /// power cut) leaves a short file behind, and an existence check would + /// call that track arrived, stop asking for it, and go on to import a + /// truncated one. This is the check that unsticks a record whose + /// transfers died with the process that was watching them. + #[test] + fn the_pen_counts_a_short_file_as_missing_not_arrived() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + let f = |name: &str, size: u64| AuditionFile { + path: format!("@peer\\Music\\Album\\{name}"), + size, + }; + let files = vec![ + f("01 whole.flac", 10), + f("02 partial.flac", 10), + f("03 never came.flac", 10), + f("04 size unstated.flac", 0), + ]; + std::fs::write(base.join("01 whole.flac"), b"0123456789").unwrap(); + std::fs::write(base.join("02 partial.flac"), b"012").unwrap(); + // 03 never arrived at all. + std::fs::write(base.join("04 size unstated.flac"), b"anything").unwrap(); + + assert_eq!( + missing_in_pen(base, &files), + vec![1, 2], + "the partial and the absent are owed; the whole one and the \ + unstated-size one are not" + ); + } + /// A record laid out the way apply sees one: the user's folder, the /// audition pen beside the download root, and a library root to file /// into. Files carry distinct bytes so "which version landed" is a @@ -1378,6 +1726,7 @@ mod tests { track_titles: None, audio: Some(audio.into()), audio_tracks: want, + audio_files: None, } } @@ -1422,6 +1771,123 @@ mod tests { out } + /// A folded release is ONE album made of disc folders, and every folder + /// numbers its tracks from 1. The disc has to survive the flattening or + /// the album becomes unreadable: eleven volumes of Analord filed as one + /// record put eleven "track 1"s at the top of the list in alphabetical + /// order, with nothing anywhere to say which volume any of them was from. + #[test] + fn a_folded_release_keeps_its_discs() { + let b = bed(0); + for (part, names) in [("CD1", ["01 one.mp3", "02 two.mp3"]), ("CD2", ["01 three.mp3", "02 four.mp3"])] { + let dir = b.src_root.join(&b.rel).join(part); + std::fs::create_dir_all(&dir).unwrap(); + for n in names { + std::fs::write(dir.join(n), format!("{part} {n}")).unwrap(); + } + } + let mut a = album(&b, None); + a.parts = vec!["CD1".into(), "CD2".into()]; + + let (tracks, _, _) = run(&b, &a).unwrap(); + assert_eq!(tracks, 4); + + let album_id = b + .library + .upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0) + .unwrap(); + let rows = b.library.album_tracks(album_id).unwrap(); + assert_eq!( + rows.iter().map(|t| t.disc_no).collect::>(), + vec![Some(1), Some(1), Some(2), Some(2)], + "disc-major order, both discs numbered in the order the fold joined them" + ); + // A single part is a container folder, not a two-disc set — numbering + // it would put a disc heading on an ordinary album. + let mut solo = album(&b, None); + solo.parts = vec!["CD1".into()]; + let b2 = bed(0); + let dir = b2.src_root.join(&b2.rel).join("CD1"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("01 one.mp3"), "only").unwrap(); + run(&b2, &solo).unwrap(); + let solo_id = b2 + .library + .upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0) + .unwrap(); + assert!( + b2.library.album_tracks(solo_id).unwrap().iter().all(|t| t.disc_no.is_none()), + "one part is not a disc" + ); + } + + /// The pen path is DERIVED from the record, never passed in — and the + /// record is a folder name off somebody's disk, so it can contain + /// anything. `import_audition_clear` deletes this directory, so "it always + /// lands inside the cache, and is always exactly one folder deep" is a + /// property worth holding on to rather than assuming. + #[test] + fn a_records_pen_can_never_name_anything_outside_the_cache() { + let root = Path::new("D:\\Music\\Library"); + let pen = root.join("(Cache)").join("_audition"); + for rel in [ + "..\\..\\Windows\\System32", + "../../../etc/passwd", + "C:\\Windows", + "Artist\\Album", + ".", + "", + ] { + let dir = audition_dir(root, rel); + assert!(dir.starts_with(&pen), "{rel:?} escaped the pen: {dir:?}"); + assert_eq!( + dir.components().count(), + pen.components().count() + 1, + "{rel:?} is not exactly one folder inside the pen: {dir:?}" + ); + } + } + + /// A record that changed peers mid-audition has TWO copies in one pen. The + /// switch cancels the abandoned peer's transfers, but it cannot un-write + /// the files they already delivered, and the pen is keyed by RECORD rather + /// than by peer. Taking "every audio file in the folder" filed both: + /// Centipede Hz landed as 17 tracks — deletia's 11 plus the 6 smash2 got + /// through before it was dropped — each duplicate under its own peer's + /// naming. The copy the REVIEW chose is the one that imports. + #[test] + fn the_dropped_peers_files_do_not_import_beside_the_copy_that_was_kept() { + let b = bed(2); // the chosen copy: "01 track.flac", "02 track.flac" + let pen = audition_dir(&b.download_root, &b.rel); + // What the abandoned peer left behind: the same music, its own naming. + for n in 1..=2 { + std::fs::write( + pen.join(format!("Weezer-Pinkerton-{n:02}-track-[FLAC 16bit].flac")), + format!("dropped peer {n}"), + ) + .unwrap(); + } + let mut ch = choices("theirs", Some(2)); + ch.audio_files = Some(vec![ + "@peer\\Music\\Pinkerton\\01 track.flac".into(), + "@peer\\Music\\Pinkerton\\02 track.flac".into(), + ]); + + let (tracks, _, _) = run(&b, &album(&b, Some(ch))).unwrap(); + + assert_eq!(tracks, 2, "only the chosen copy's tracks are filed"); + let names: Vec = landed(&b).into_iter().map(|(n, _)| n).collect(); + assert!( + names.iter().all(|n| !n.contains("[FLAC 16bit]")), + "the dropped peer's files must not reach the library: {names:?}" + ); + assert_eq!( + names.iter().filter(|n| n.ends_with(".flac")).count(), + 2, + "eleven-plus-six is what this test exists to prevent: {names:?}" + ); + } + /// Re-importing the same record must not copy it again — even though the /// imported files no longer match their sources byte for byte. /// @@ -1659,7 +2125,7 @@ mod tests { .upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0) .unwrap(); b.library - .upsert_track(album_id, None, "Stray", "Weezer", None, None, None, None, Some(5), + .upsert_track(album_id, None, None, "Stray", "Weezer", None, None, None, None, Some(5), &stray.to_string_lossy(), 0) .unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e150265..ff15b39 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,8 +6,11 @@ mod hero_crop; mod import; mod hero_detector; mod loudness; +mod lyrics; mod pipeline; mod playback; +mod playlist_formats; +mod playlists; mod preview; mod smtc; mod state; @@ -93,14 +96,41 @@ pub fn run() { commands::unfollow_artist, commands::home_artist_news, commands::home_warm, - commands::list_playlists, - commands::create_playlist, - commands::delete_playlist, - commands::playlist_tracks, - commands::add_to_playlist, - commands::remove_from_playlist, + playlists::list_playlists, + playlists::create_playlist, + playlists::rename_playlist, + playlists::set_playlist_description, + playlists::set_playlist_pinned, + playlists::duplicate_playlist, + playlists::delete_playlist, + playlists::playlist_items, + playlists::playlist_add_items, + playlists::playlist_remove_items, + playlists::playlist_set_order, + playlists::playlist_relink_item, + playlists::playlist_cover, + playlists::playlist_cover_pick, + playlists::playlist_cover_clear, + playlists::playlist_import_probe, + playlists::playlist_import_folder, + playlists::playlist_import_text, + playlists::playlist_import_commit, + playlists::playlist_export, + playlists::library_track_search, + lyrics::lyrics_get, + lyrics::lyrics_search, + lyrics::lyrics_select, + lyrics::lyrics_set_offset, + lyrics::lyrics_set_auto_sync, + lyrics::lyrics_prepare_instrumental, + lyrics::lyrics_vocal_activity, + lyrics::lyrics_save_lines, + lyrics::lyrics_save_text, + lyrics::lyrics_clear, commands::catalog_search, import::import_scan, + import::import_portable_root, + import::import_resolve_root, import::import_session_get, import::import_session_set, import::import_session_clear, @@ -111,6 +141,8 @@ pub fn run() { import::import_cover_report, import::import_local_art, import::import_audition, + import::import_audition_missing, + import::import_audition_clear, import::import_probe_file, pipeline::rescan_library, commands::catalog_artist, diff --git a/src-tauri/src/loudness.rs b/src-tauri/src/loudness.rs index dbbe2e8..7389205 100644 --- a/src-tauri/src/loudness.rs +++ b/src-tauri/src/loudness.rs @@ -101,8 +101,14 @@ pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option) { ) } +/// Keyed by track path, so it describes THIS library's files and belongs +/// beside them. Measuring a whole library again on another machine is hours +/// of work that the drive was already carrying the answer to. fn cache_file(app: &tauri::AppHandle) -> Option { - app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE)) + let cfg = crate::config::load(app); + crate::config::ensure_library_data_dir(app, &cfg) + .ok() + .map(|d| d.join(CACHE_FILE)) } pub fn init(app: &tauri::AppHandle) { @@ -947,8 +953,10 @@ mod tests { .and_then(|e| e.to_str()) .unwrap_or_default() .to_ascii_lowercase(); - if matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff") - { + // Measuring means decoding, so this is the PLAYABLE table, not + // the recognised one — an `.opus` or `.wv` here only produces a + // failed decode (or, for WavPack, NaN). + if audio_formats::is_playable_ext(&ext) { files.push(path); } } @@ -1092,8 +1100,7 @@ mod tests { .and_then(|e| e.to_str()) .unwrap_or_default() .to_ascii_lowercase(); - if !matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff") - { + if !audio_formats::is_playable_ext(&ext) { continue; } let artist = path diff --git a/src-tauri/src/lyrics.rs b/src-tauri/src/lyrics.rs new file mode 100644 index 0000000..35dac59 --- /dev/null +++ b/src-tauri/src/lyrics.rs @@ -0,0 +1,2298 @@ +//! Lyrics: sidecar files, embedded tags, and LRCLIB — resolved through one +//! waterfall, cached in the library folder, offset-adjustable, and never in +//! the way of playback. +//! +//! Source order (recording confidence first, timing detail second, per the +//! lyrics research): a user paste or picked candidate wins outright; then a +//! same-basename timed sidecar (.ttml before .lrc); then an embedded SYLT +//! frame; then the word-level network catalog (the AMLL DB, by ISRC or a +//! duration-validated name match); then LRCLIB's exact-signature lookup +//! (which validates duration); +//! then local plain text; then LRCLIB plain. Network results and user +//! choices persist in `/.soulstream/lyrics/`, so they travel with +//! the library. +//! +//! Nothing here runs on the audio thread; commands do file work in +//! spawn_blocking and network work on the async runtime with timeouts. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +const LRCLIB_BASE: &str = "https://lrclib.net/api"; +/// How long a genuine LRCLIB not-found is believed before asking again. +const NOT_FOUND_TTL_SECS: i64 = 3 * 24 * 60 * 60; +const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +// ---- payloads -------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LyricLine { + pub time_ms: u32, + pub text: String, + /// Word-level timing, when the SOURCE really carries it (enhanced-LRC + /// word marks, word/syllable SYLT). Empty for line-level sources — the + /// renderer then highlights whole lines and shows NO word sweep; word + /// times are carried, never invented. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub words: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LyricWord { + pub time_ms: u32, + pub text: String, +} + +/// What the frontend renders. `status`: "synced" | "plain" | "instrumental" | +/// "none". `source` names where the winning document came from. +#[derive(Debug, Clone, Serialize)] +pub struct LyricsPayload { + pub status: String, + pub source: String, + pub synced: Vec, + pub plain: Option, + pub offset_ms: i64, + /// Automatic timing correction, milliseconds — nonzero only when the + /// duration mismatch between the lyric source's recording and the local + /// file is CORROBORATED by the file's own measured leading silence. + /// Additive with the user offset; the UI shows it as its own chip. + pub auto_offset_ms: i64, + /// The user turned auto-sync off for this track. + pub auto_sync_off: bool, +} + +impl LyricsPayload { + fn none() -> Self { + LyricsPayload { + status: "none".into(), + source: String::new(), + synced: Vec::new(), + plain: None, + offset_ms: 0, + auto_offset_ms: 0, + auto_sync_off: false, + } + } +} + +/// One cached document (network result or user override). +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredDoc { + source: String, + #[serde(default)] + synced: Vec, + #[serde(default)] + plain: Option, + #[serde(default)] + instrumental: bool, + /// The duration of the RECORDING the source document was made against + /// (LRCLIB reports it) — the anchor auto-sync compares the local file to. + #[serde(default)] + source_duration_secs: Option, +} + +/// The per-track cache file. Local sources (sidecars, embedded tags) are +/// deliberately NOT cached — they are fast to re-read and a changed sidecar +/// should win immediately. The file holds what is expensive (network) or +/// irreplaceable (user choice, offset). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct CacheEntry { + #[serde(default)] + offset_ms: i64, + #[serde(default)] + user: Option, + #[serde(default)] + lrclib: Option, + /// Word-level network doc (AMLL DB or NetEase yrc), with its own + /// not-found clock — the word catalogs and LRCLIB miss independently. + #[serde(default)] + word: Option, + #[serde(default)] + word_not_found_at: Option, + #[serde(default)] + not_found_at: Option, + #[serde(default)] + auto_sync_off: bool, +} + +// ---- cache ----------------------------------------------------------------- + +fn lyrics_dir(app: &tauri::AppHandle) -> Option { + let cfg = crate::config::load(app); + let dir = crate::config::ensure_library_data_dir(app, &cfg) + .ok()? + .join("lyrics"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +/// Cache key: artist + title + duration bucket. Duration is part of identity +/// because a live take shares its name with the studio recording while +/// needing different timing; the 10-second bucket absorbs container rounding. +fn cache_key(artist: &str, title: &str, duration_secs: Option) -> String { + let clean = |s: &str| -> String { + s.to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .take(80) + .collect() + }; + let bucket = duration_secs.map(|d| (d / 10).to_string()).unwrap_or_else(|| "x".into()); + format!("{}-{}-{}", clean(artist), clean(title), bucket) +} + +fn cache_path(app: &tauri::AppHandle, key: &str) -> Option { + Some(lyrics_dir(app)?.join(format!("{key}.json"))) +} + +fn load_entry(app: &tauri::AppHandle, key: &str) -> CacheEntry { + cache_path(app, key) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_entry(app: &tauri::AppHandle, key: &str, entry: &CacheEntry) { + let Some(path) = cache_path(app, key) else { return }; + if let Ok(json) = serde_json::to_string(entry) { + let temp = path.with_extension("json.writing"); + if std::fs::write(&temp, json).is_ok() { + let _ = std::fs::remove_file(&path); + let _ = std::fs::rename(&temp, &path); + } + } +} + +// ---- LRC parsing ----------------------------------------------------------- + +/// Parse LRC text into timed lines. Handles `[offset:±N]` (positive shifts +/// lyrics earlier, the common convention), several timestamps on one line, +/// two- and three-decimal fractions, bare `[mm:ss]`, metadata tags, BOM, and +/// enhanced-LRC word marks (``), which are flattened to line text. +/// Returns lines sorted by time; empty when nothing timed was found. +pub fn parse_lrc(text: &str) -> Vec { + let text = text.trim_start_matches('\u{feff}'); + let mut offset_ms: i64 = 0; + let mut lines: Vec = Vec::new(); + for raw in text.lines() { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + // Collect every leading [..] block. + let mut rest = raw; + let mut stamps: Vec = Vec::new(); + let mut is_meta = false; + while rest.starts_with('[') { + let Some(end) = rest.find(']') else { break }; + let inner = &rest[1..end]; + if let Some(ms) = parse_lrc_timestamp(inner) { + stamps.push(ms); + } else if let Some(value) = inner.strip_prefix("offset:") { + offset_ms = value.trim().replace('+', "").parse().unwrap_or(0); + is_meta = true; + } else { + // [ar:…], [ti:…], [al:…] and friends. + is_meta = true; + } + rest = &rest[end + 1..]; + } + if stamps.is_empty() { + let _ = is_meta; + continue; + } + let (line_text, words) = extract_words(rest.trim()); + // Word marks are ABSOLUTE times, so they belong to exactly one + // occurrence of a multi-stamped (repeated-chorus) line — the stamp + // NEAREST the first word's time, not whichever was written first. + let word_stamp = words.first().map(|w| { + stamps + .iter() + .copied() + .min_by_key(|s| (s - w.time_ms as i64).abs()) + .unwrap_or(stamps[0]) + }); + for ms in &stamps { + lines.push(LyricLine { + time_ms: (*ms).max(0) as u32, + text: line_text.clone(), + words: if word_stamp == Some(*ms) { words.clone() } else { Vec::new() }, + }); + } + } + if offset_ms != 0 { + for line in &mut lines { + let shifted = line.time_ms as i64 - offset_ms; + line.time_ms = shifted.max(0) as u32; + for word in &mut line.words { + let shifted = word.time_ms as i64 - offset_ms; + word.time_ms = shifted.max(0) as u32; + } + } + } + lines.sort_by_key(|l| l.time_ms); + // A file of timestamps with no words at all is not synced lyrics. + if lines.iter().all(|l| l.text.is_empty()) { + return Vec::new(); + } + lines +} + +/// `mm:ss`, `mm:ss.xx`, `mm:ss.xxx` (minutes may exceed 59). None for +/// anything else — which is how metadata tags are told apart. +fn parse_lrc_timestamp(s: &str) -> Option { + let (mins, rest) = s.split_once(':')?; + if mins.is_empty() || mins.len() > 4 || !mins.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let (secs, frac) = match rest.split_once(['.', ':']) { + Some((s, f)) => (s, Some(f)), + None => (rest, None), + }; + if secs.len() != 2 || !secs.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let mins: i64 = mins.parse().ok()?; + let secs: i64 = secs.parse().ok()?; + if secs > 59 { + return None; + } + let frac_ms: i64 = match frac { + None => 0, + Some(f) if f.len() == 2 && f.chars().all(|c| c.is_ascii_digit()) => { + f.parse::().ok()? * 10 + } + Some(f) if f.len() == 3 && f.chars().all(|c| c.is_ascii_digit()) => f.parse().ok()?, + Some(_) => return None, + }; + Some((mins * 60 + secs) * 1000 + frac_ms) +} + +/// Enhanced-LRC word markers `<00:12.34>` — extracted as REAL word timing +/// when the line carries a coherent set (two or more marked words, times +/// monotonic), flattened away otherwise. The display text is identical in +/// both cases: the marks removed and spacing collapsed. +fn extract_words(s: &str) -> (String, Vec) { + // Pass 1: split into (mark, following-raw-text) chunks. + let mut chunks: Vec<(Option, String)> = vec![(None, String::new())]; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '<' { + let mut probe = chars.clone(); + let mut inner = String::new(); + let mut closed = false; + for n in probe.by_ref() { + if n == '>' { + closed = true; + break; + } + inner.push(n); + if inner.len() > 12 { + break; + } + } + if closed { + if let Some(ms) = parse_lrc_timestamp(&inner) { + chars = probe; + chunks.push((Some(ms), String::new())); + continue; + } + } + } + chunks.last_mut().unwrap().1.push(c); + } + + // Display: the raw text with the marks removed, spaces collapsed. + let mut display = String::new(); + for (_, raw) in &chunks { + display.push_str(raw); + } + let mut collapsed = String::with_capacity(display.len()); + let mut last_space = false; + for c in display.chars() { + if c == ' ' { + if !last_space { + collapsed.push(c); + } + last_space = true; + } else { + collapsed.push(c); + last_space = false; + } + } + let display = collapsed.trim().to_string(); + + // Untimed text before the first mark = partial coverage → display only. + if !chunks[0].1.trim().is_empty() { + return (display, Vec::new()); + } + // Pass 2: timed chunks become words, syllable fragments merging into the + // previous word when neither side shows a whitespace boundary — the same + // contract the SYLT path keeps, so "Karaoke" renders "Karaoke". + let mut words: Vec = Vec::new(); + let mut previous_raw = ""; + for (mark, raw) in chunks.iter().skip(1) { + let trimmed = raw.trim(); + if trimmed.is_empty() { + previous_raw = raw; + continue; + } + let boundary = words.is_empty() + || raw.starts_with(char::is_whitespace) + || previous_raw.ends_with(char::is_whitespace); + if boundary { + words.push(LyricWord { + time_ms: mark.unwrap_or(0).max(0) as u32, + text: trimmed.to_string(), + }); + } else if let Some(last) = words.last_mut() { + last.text.push_str(trimmed); + } + previous_raw = raw; + } + + let monotonic = words.windows(2).all(|w| w[0].time_ms <= w[1].time_ms); + if words.len() < 2 || !monotonic { + return (display, Vec::new()); + } + (display, words) +} + +/// ID3 SYLT content units arrive at whatever granularity the tagger chose — +/// whole lines, words, or syllables — and the frame's declared unit is +/// unreliable in practice. Shape them into display lines: +/// - a unit beginning with a newline starts a new line (the de-facto +/// convention for word/syllable SYLT); +/// - without newlines, LONG units are already lines; +/// - without newlines, short dense units split into lines on time gaps. +/// Sub-line units become word timing; pieces merge into words on whitespace +/// boundaries so syllabic content does not read "Ka ra o ke". +fn lines_from_sylt(entries: &[(u32, String)]) -> Vec { + if entries.is_empty() { + return Vec::new(); + } + let has_newlines = entries + .iter() + .any(|(_, t)| t.starts_with('\n') || t.starts_with("\r\n")); + let avg_len = entries + .iter() + .map(|(_, t)| t.trim().chars().count()) + .sum::() + / entries.len(); + if !has_newlines && avg_len > 12 { + return entries + .iter() + .map(|(ms, t)| LyricLine { + time_ms: *ms, + text: t.trim().to_string(), + words: Vec::new(), + }) + .collect(); + } + let mut lines: Vec = Vec::new(); + let mut words: Vec = Vec::new(); + let mut previous_raw = String::new(); + let flush_line = |words: &mut Vec, lines: &mut Vec| { + if words.is_empty() { + return; + } + let text = words + .iter() + .map(|w| w.text.as_str()) + .collect::>() + .join(" "); + lines.push(LyricLine { + time_ms: words[0].time_ms, + text, + words: if words.len() >= 2 { std::mem::take(words) } else { { words.clear(); Vec::new() } }, + }); + }; + let mut last_ms = entries[0].0; + for (ms, raw) in entries { + let starts_line = raw.starts_with('\n') + || raw.starts_with("\r\n") + || (!has_newlines && *ms > last_ms + 1500 && !words.is_empty()); + if starts_line { + flush_line(&mut words, &mut lines); + } + let piece = raw.trim_start_matches(['\r', '\n']); + let trimmed = piece.trim(); + if !trimmed.is_empty() { + // A piece with no whitespace boundary continues the previous + // word (syllabic SYLT); a boundary starts a new one. + let boundary = starts_line + || piece.starts_with(char::is_whitespace) + || previous_raw.ends_with(char::is_whitespace) + || words.is_empty(); + if boundary { + words.push(LyricWord { + time_ms: *ms, + text: trimmed.to_string(), + }); + } else if let Some(last) = words.last_mut() { + last.text.push_str(trimmed); + } + } + previous_raw = piece.to_string(); + last_ms = *ms; + } + flush_line(&mut words, &mut lines); + lines +} + +// ---- TTML (Apple-style timed text) ----------------------------------------- + +/// Parse a TTML lyric document (the Apple Music / AMLL export format) into +/// timed lines. `

` is a line; `` inside it is a +/// word, and adjacent spans with NO whitespace between them are syllables of +/// one word and merge (the SYLT rule, so "Coun|try" never reads "Coun try"). +/// Word timing is kept only when every visible token carries it and times +/// are monotonic — the renderer draws `words`, not `text`, in word mode, so +/// a partial set would silently drop the tokens it skipped. +/// `ttm:role="x-bg"` subtrees (background/adlib vocals) are dropped whole: +/// there is no background layer to put them on, and folded inline their +/// overlapping times would corrupt the main line. Line-timed documents +/// (`

` text with no timed spans) come through as plain synced lines. +/// Returns lines sorted by time; empty when nothing timed was found. +pub fn parse_ttml(text: &str) -> Vec { + use quick_xml::events::Event; + let text = text.trim_start_matches('\u{feff}'); + if !text.contains(", + text: String, + } + + let mut lines: Vec = Vec::new(); + let mut in_body = false; + let mut in_p = false; + let mut p_begin: Option = None; + let mut tokens: Vec = Vec::new(); + let mut boundary = true; // the next visible text starts a new token + // Open begins, innermost last — text takes the nearest timed one. + let mut span_stack: Vec> = Vec::new(); + + loop { + let event = match reader.read_event() { + Ok(event) => event, + // Malformed XML: refuse the document, never guess at timing. + Err(_) => return Vec::new(), + }; + match event { + Event::Eof => break, + Event::Start(e) => match e.local_name().as_ref() { + b"body" => in_body = true, + b"p" if in_body => { + in_p = true; + p_begin = attr_local(&e, b"begin").as_deref().and_then(parse_ttml_clock); + tokens = Vec::new(); + boundary = true; + span_stack.clear(); + } + b"span" if in_p => { + if attr_local(&e, b"role").is_some() { + // A role names an auxiliary layer — x-bg backing + // vocals, x-translation, x-roman romanization — and + // none of them belong inline in the main line (they + // sit adjacent to the last word with no whitespace, + // so kept they GLUE onto it: "close别靠近"). Main + // word spans carry no role. Consume the whole + // subtree, its own matching included. + let mut depth = 1usize; + loop { + match reader.read_event() { + Ok(Event::Start(s)) if s.local_name().as_ref() == b"span" => { + depth += 1; + } + Ok(Event::End(s)) if s.local_name().as_ref() == b"span" => { + depth -= 1; + if depth == 0 { + break; + } + } + Ok(Event::Eof) | Err(_) => return Vec::new(), + _ => {} + } + } + } else { + span_stack + .push(attr_local(&e, b"begin").as_deref().and_then(parse_ttml_clock)); + } + } + _ => {} + }, + Event::End(e) => match e.local_name().as_ref() { + b"body" => in_body = false, + b"p" if in_p => { + in_p = false; + let tokens = std::mem::take(&mut tokens); + let text = tokens + .iter() + .map(|t| t.text.as_str()) + .collect::>() + .join(" "); + // A p with no begin anywhere belongs to an untimed doc. + let time = p_begin.or_else(|| tokens.iter().find_map(|t| t.time)); + if let (false, Some(time)) = (text.is_empty(), time) { + let timed: Vec = tokens.iter().filter_map(|t| t.time).collect(); + let monotonic = timed.windows(2).all(|w| w[0] <= w[1]); + let words = if timed.len() == tokens.len() && tokens.len() >= 2 && monotonic + { + tokens + .iter() + .map(|t| LyricWord { + time_ms: t.time.unwrap_or(0).max(0) as u32, + text: t.text.clone(), + }) + .collect() + } else { + Vec::new() + }; + lines.push(LyricLine { + time_ms: time.max(0) as u32, + text, + words, + }); + } + } + b"span" if in_p => { + span_stack.pop(); + } + _ => {} + }, + Event::Empty(e) => { + //
is a boundary; an empty timed span has nothing to show. + if in_p && e.local_name().as_ref() == b"br" { + boundary = true; + } + } + Event::Text(t) => { + if !in_p { + continue; + } + let Ok(raw) = t.unescape() else { continue }; + let span_time = span_stack.iter().rev().find_map(|t| *t); + let mut rest = raw.as_ref(); + while !rest.is_empty() { + if rest.starts_with(char::is_whitespace) { + boundary = true; + rest = rest.trim_start(); + } else { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let piece = &rest[..end]; + if boundary || tokens.is_empty() { + tokens.push(Token { + time: span_time, + text: piece.to_string(), + }); + } else if let Some(last) = tokens.last_mut() { + last.text.push_str(piece); + // An untimed lead-in (stray punctuation before the + // first timed syllable) adopts the syllable's time. + if last.time.is_none() { + last.time = span_time; + } + } + boundary = false; + rest = &rest[end..]; + } + } + } + _ => {} + } + } + lines.sort_by_key(|l| l.time_ms); + lines +} + +/// Attribute by LOCAL name — Apple prefixes freely (`ttm:role`, `itunes:key`), +/// and the prefix spelling is the document's choice, not part of the name. +fn attr_local(e: &quick_xml::events::BytesStart, name: &[u8]) -> Option { + e.attributes().flatten().find_map(|a| { + (a.key.local_name().as_ref() == name) + .then(|| a.unescape_value().ok().map(|v| v.into_owned())) + .flatten() + }) +} + +/// TTML time expressions as lyric files actually write them: bare seconds +/// ("7.759"), offset-time with a unit ("7.759s", "1500ms"), and clock time +/// ("0:07.759", "00:01:15.500"). Frame-based clock values (a fourth +/// component) are refused rather than misread. +fn parse_ttml_clock(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + let scaled = |v: &str, scale: f64| -> Option { + let n: f64 = v.trim().parse().ok()?; + (n.is_finite() && n >= 0.0).then(|| (n * scale).round() as i64) + }; + // "ms" before "s" — every millisecond value also ends in 's'. + if let Some(v) = s.strip_suffix("ms") { + return scaled(v, 1.0); + } + if let Some(v) = s.strip_suffix('s') { + return scaled(v, 1000.0); + } + if let Some(v) = s.strip_suffix('h') { + return scaled(v, 3_600_000.0); + } + if let Some(v) = s.strip_suffix('m') { + return scaled(v, 60_000.0); + } + let int = |v: &str| -> Option { + (!v.is_empty() && v.len() <= 4 && v.chars().all(|c| c.is_ascii_digit())) + .then(|| v.parse().ok()) + .flatten() + }; + let parts: Vec<&str> = s.split(':').collect(); + match parts.as_slice() { + [secs] => scaled(secs, 1000.0), + [mins, secs] => Some(int(mins)? * 60_000 + scaled(secs, 1000.0)?), + [hours, mins, secs] => { + Some(int(hours)? * 3_600_000 + int(mins)? * 60_000 + scaled(secs, 1000.0)?) + } + _ => None, + } +} + +/// Timed lines are trusted only when they behave like a song: monotonic +/// (post-sort they are), inside the track when its duration is known, and +/// not all bunched at zero. +fn plausible_timing(lines: &[LyricLine], duration_secs: Option) -> bool { + if lines.len() < 2 { + return !lines.is_empty(); + } + if let Some(duration) = duration_secs { + let limit_ms = (duration as u64 + 15) * 1000; + let inside = lines + .iter() + .filter(|l| (l.time_ms as u64) < limit_ms) + .count(); + // A handful of trailing credits past the end is tolerable; a document + // mostly outside the track belongs to another recording. + if inside * 2 < lines.len() { + return false; + } + } + lines.last().map(|l| l.time_ms).unwrap_or(0) > 0 +} + +// ---- sidecars -------------------------------------------------------------- + +/// `Track.flac` → first existing of `Track.ttml` (word-level Apple-style +/// timed text outranks line-level LRC), `Track.lrc`, `Track.en.lrc` (any +/// language suffix), then `Track.txt` for plain. +fn sidecar_lyrics(audio_path: &str) -> (Option>, Option) { + let path = Path::new(audio_path); + let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else { + return (None, None); + }; + let Some(dir) = path.parent() else { return (None, None) }; + let mut timed = None; + let ttml = dir.join(format!("{stem}.ttml")); + if let Ok(text) = std::fs::read_to_string(&ttml) { + let lines = parse_ttml(&text); + if !lines.is_empty() { + timed = Some(lines); + } + } + if timed.is_none() { + let exact = dir.join(format!("{stem}.lrc")); + if let Ok(text) = std::fs::read_to_string(&exact) { + let lines = parse_lrc(&text); + if !lines.is_empty() { + timed = Some(lines); + } + } + } + if timed.is_none() { + // Language-suffixed variants: Track.en.lrc, Track.ja.lrc… The suffix + // must LOOK like a language tag (2–3 letters): "Song.Live.lrc" beside + // "Song.Live.flac" is a DIFFERENT recording's lyrics, not a variant. + if let Ok(entries) = std::fs::read_dir(dir) { + let prefix = format!("{}.", stem.to_lowercase()); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + let suffix = name + .strip_prefix(&prefix) + .and_then(|rest| rest.strip_suffix(".lrc")); + let is_lang = suffix.is_some_and(|s| { + (2..=3).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphabetic()) + }); + if is_lang { + if let Ok(text) = std::fs::read_to_string(entry.path()) { + let lines = parse_lrc(&text); + if !lines.is_empty() { + timed = Some(lines); + break; + } + } + } + } + } + } + let mut plain = None; + let txt = dir.join(format!("{stem}.txt")); + if let Ok(text) = std::fs::read_to_string(&txt) { + let trimmed = text.trim(); + // A .txt beside the audio can be anything; only take it when it looks + // like lyrics — several lines, no path-ish or tracker-log content. + let line_count = trimmed.lines().count(); + if line_count >= 4 && trimmed.len() < 64 * 1024 { + plain = Some(trimmed.to_string()); + } + } + (timed, plain) +} + +// ---- LRCLIB ---------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LrclibRecord { + pub id: i64, + #[serde(default)] + pub track_name: String, + #[serde(default)] + pub artist_name: String, + #[serde(default)] + pub album_name: String, + #[serde(default)] + pub duration: f64, + #[serde(default)] + pub instrumental: bool, + #[serde(default)] + pub plain_lyrics: Option, + #[serde(default)] + pub synced_lyrics: Option, +} + +fn http_client() -> Result { + reqwest::Client::builder() + .user_agent("SoulStream/0.1 (https://soulstream.local)") + .timeout(FETCH_TIMEOUT) + .build() + .map_err(|e| e.to_string()) +} + +async fn lrclib_get( + artist: &str, + title: &str, + album: Option<&str>, + duration_secs: Option, +) -> Result, String> { + let client = http_client()?; + let mut query: Vec<(&str, String)> = vec![ + ("artist_name", artist.to_string()), + ("track_name", title.to_string()), + ]; + if let Some(album) = album { + query.push(("album_name", album.to_string())); + } + if let Some(duration) = duration_secs { + query.push(("duration", duration.to_string())); + } + let response = client + .get(format!("{LRCLIB_BASE}/get")) + .query(&query) + .send() + .await + .map_err(|e| e.to_string())?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!("lrclib get: HTTP {}", response.status())); + } + response.json().await.map(Some).map_err(|e| e.to_string()) +} + +async fn lrclib_search(artist: &str, title: &str) -> Result, String> { + let client = http_client()?; + let response = client + .get(format!("{LRCLIB_BASE}/search")) + .query(&[("track_name", title), ("artist_name", artist)]) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("lrclib search: HTTP {}", response.status())); + } + response.json().await.map_err(|e| e.to_string()) +} + +async fn lrclib_by_id(id: i64) -> Result { + let client = http_client()?; + let response = client + .get(format!("{LRCLIB_BASE}/get/{id}")) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("lrclib get/{id}: HTTP {}", response.status())); + } + response.json().await.map_err(|e| e.to_string()) +} + +fn norm(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + +fn record_to_doc(record: &LrclibRecord) -> StoredDoc { + let synced = record + .synced_lyrics + .as_deref() + .map(parse_lrc) + .unwrap_or_default(); + StoredDoc { + source: "lrclib".into(), + synced, + plain: record + .plain_lyrics + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + instrumental: record.instrumental, + source_duration_secs: (record.duration > 0.0).then_some(record.duration), + } +} + +/// Auto-select a search candidate only when it is unmistakably this +/// recording: normalized title and artist agree and any known duration is +/// within three seconds. Ambiguity is the user's call, never a guess. +fn confident_candidate<'a>( + records: &'a [LrclibRecord], + artist: &str, + title: &str, + duration_secs: Option, +) -> Option<&'a LrclibRecord> { + let (nt, na) = (norm(title), norm(artist)); + records.iter().find(|r| { + norm(&r.track_name) == nt + && (na.is_empty() || norm(&r.artist_name) == na) + && match duration_secs { + Some(d) if r.duration > 0.0 => (r.duration - d as f64).abs() <= 3.0, + _ => true, + } + && (r.synced_lyrics.is_some() || r.plain_lyrics.is_some() || r.instrumental) + }) +} + +// ---- the word-level network catalog ---------------------------------------- +// +// The AMLL community TTML database (github: amll-dev/amll-ttml-db), feeding +// the cached `word` slot. Two ways in, strongest first: +// - the file's own ISRC tag — exact recording identity, any hosted format; +// - normalized title+artist equality (LRCLIB's own confidence rule), accepted +// ONLY when the recording the document names (its appleMusicId, resolved +// through the free iTunes lookup) has the local track's duration — a name +// alone never places a record. The document's own `` is NOT that +// anchor: it marks the last lyric line's end, so every song with an +// instrumental outro would fail against the real track length. +// (NetEase yrc was tried and removed: its public endpoints geo-block this +// side of the world with encrypted `"abroad"` responses.) + +const AMLL_BASE: &str = "https://raw.githubusercontent.com/amll-dev/amll-ttml-db/refs/heads/main"; +/// The DB's four ID namespaces, most-populated first. +const AMLL_PLATFORMS: [&str; 4] = ["ncm", "am", "spotify", "qq"]; +const AMLL_INDEX_TTL_SECS: u64 = 24 * 60 * 60; +/// Acceptance gate for name-matched documents, seconds — the same tolerance +/// `confident_candidate` holds LRCLIB to. +const AMLL_DURATION_TOLERANCE_SECS: f64 = 3.0; + +/// One platform's `index.jsonl`, from the daily disk cache when fresh, the +/// network otherwise — falling back to a stale cache copy when the fetch +/// fails, so an offline day keeps yesterday's answers. +async fn amll_index(app: &tauri::AppHandle, platform: &str) -> Result { + let cache = lyrics_dir(app).map(|d| d.join("amll-index").join(format!("{platform}.jsonl"))); + let read_cache = |path: PathBuf, max_age: Option| async move { + tauri::async_runtime::spawn_blocking(move || { + let meta = std::fs::metadata(&path).ok()?; + if let Some(max_age) = max_age { + let age = meta.modified().ok()?.elapsed().ok()?; + if age.as_secs() >= max_age { + return None; + } + } + std::fs::read_to_string(&path).ok() + }) + .await + .ok() + .flatten() + }; + if let Some(path) = cache.clone() { + if let Some(text) = read_cache(path, Some(AMLL_INDEX_TTL_SECS)).await { + return Ok(text); + } + } + let fetch = async { + let client = reqwest::Client::builder() + .user_agent("SoulStream/0.1 (https://soulstream.local)") + // The indexes run to megabytes — the 10 s document timeout is + // too tight for them on a slow link. + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + let response = client + .get(format!("{AMLL_BASE}/{platform}-lyrics/index.jsonl")) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("amll index {platform}: HTTP {}", response.status())); + } + response.text().await.map_err(|e| e.to_string()) + }; + match fetch.await { + Ok(text) => { + if let Some(path) = cache { + let stored = text.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, stored); + }) + .await; + } + Ok(text) + } + Err(e) => { + if let Some(path) = cache { + if let Some(stale) = read_cache(path, None).await { + log::debug!("amll index {platform}: fetch failed ({e}), using stale cache"); + return Ok(stale); + } + } + Err(e) + } + } +} + +fn canon_id(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) + .collect() +} + +/// One nominated index record. `apple_id` is the record's own appleMusicId — +/// the handle a name match is duration-validated through. +#[derive(Debug, PartialEq)] +struct AmllCandidate { + id: String, + exact: bool, + apple_id: Option, +} + +/// Candidates from one platform's index. Records look like +/// `{"id":"…","metadata":[["isrc",["USUM71703861"]],["musicName",[…]], +/// ["artists",[…]],["appleMusicId",[…]]…]}`. An exact ISRC hit (compared the +/// way `tagger::read_isrc` normalizes) wins alone and immediately. Otherwise, +/// when name matching is allowed (the caller knows the local duration, so a +/// candidate CAN be validated), normalized title+artist equality — the LRCLIB +/// confidence rule — nominates up to three; every one still has to survive +/// the iTunes duration gate in `amll_lookup` before it counts. +fn amll_index_candidates( + index_jsonl: &str, + isrc: Option<&str>, + title: &str, + artist: &str, + allow_name_matches: bool, +) -> Vec { + let want_isrc = isrc.map(canon_id).filter(|s| !s.is_empty()); + let (nt, na) = (norm(title), norm(artist)); + let title_probe = canon_id(title); + let name_matching = allow_name_matches && !nt.is_empty(); + let mut named: Vec = Vec::new(); + for line in index_jsonl.lines() { + // Cheap literal pre-filter before any JSON work — on the CANONICAL + // form of the line, so a stored "gb-ahs-16-00001" still pre-passes. + let line_canon: String = line + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) + .collect(); + let isrc_possible = want_isrc + .as_ref() + .is_some_and(|want| line_canon.contains(want.as_str())); + let name_possible = name_matching && line_canon.contains(title_probe.as_str()); + if !isrc_possible && !name_possible { + continue; + } + let Ok(record) = serde_json::from_str::(line) else { + continue; + }; + let Some(id) = record.get("id").and_then(|i| i.as_str()) else { + continue; + }; + let metadata = record.get("metadata").and_then(|m| m.as_array()); + let values = |key: &str| -> Vec<&str> { + metadata + .into_iter() + .flatten() + .filter_map(|pair| { + (pair.get(0).and_then(|k| k.as_str()) == Some(key)) + .then(|| pair.get(1).and_then(|v| v.as_array())) + .flatten() + }) + .flatten() + .filter_map(|v| v.as_str()) + .collect() + }; + if isrc_possible { + let want = want_isrc.as_deref().unwrap_or_default(); + if values("isrc").iter().any(|v| canon_id(v) == want) { + return vec![AmllCandidate { + id: id.to_string(), + exact: true, + apple_id: values("appleMusicId").first().map(|s| s.to_string()), + }]; + } + } + if name_possible && named.len() < 3 { + let title_hit = values("musicName").iter().any(|v| norm(v) == nt); + let artist_hit = + na.is_empty() || values("artists").iter().any(|a| norm(a) == na); + if title_hit && artist_hit { + named.push(AmllCandidate { + id: id.to_string(), + exact: false, + apple_id: values("appleMusicId").first().map(|s| s.to_string()), + }); + } + } + } + named +} + +/// The exact recording's duration via the free, auth-less iTunes lookup — +/// the anchor that validates a name-matched AMLL document. `Ok(None)` means +/// iTunes has no verdict for this id (unknown, or no trackTimeMillis) — the +/// candidate is unverifiable, not wrong. +async fn itunes_track_duration_secs(apple_id: &str) -> Result, String> { + let client = http_client()?; + let response = client + .get("https://itunes.apple.com/lookup") + .query(&[("id", apple_id)]) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("itunes lookup {apple_id}: HTTP {}", response.status())); + } + let body: serde_json::Value = response.json().await.map_err(|e| e.to_string())?; + Ok(body + .get("results") + .and_then(|r| r.as_array()) + .and_then(|r| r.first()) + .and_then(|track| track.get("trackTimeMillis")) + .and_then(|ms| ms.as_i64()) + .map(|ms| ms as f64 / 1000.0)) +} + +/// The AMLL DB end to end. An ISRC hit accepts any hosted format that +/// carries word timing (.ttml, then .yrc, then .lrc with enhanced marks). A +/// name hit is TTML-only and stands or falls on the iTunes-resolved duration +/// of the recording its appleMusicId names agreeing with the local track — +/// validated BEFORE the document is even fetched. Only documents with at +/// least one word-timed line land in the word slot — a line-level document +/// here would add nothing over LRCLIB. +async fn amll_lookup( + app: &tauri::AppHandle, + isrc: Option<&str>, + artist: &str, + title: &str, + duration_secs: Option, +) -> Result, String> { + let has_words = |lines: &[LyricLine]| lines.iter().any(|l| !l.words.is_empty()); + for platform in AMLL_PLATFORMS { + let index = amll_index(app, platform).await?; + let candidates = + amll_index_candidates(&index, isrc, title, artist, duration_secs.is_some()); + for candidate in candidates { + let mut source_duration_secs = None; + if !candidate.exact { + // A name match stands only on duration agreement with the + // recording the document itself names. + let (Some(local), Some(apple_id)) = + (duration_secs, candidate.apple_id.as_deref()) + else { + continue; + }; + let Some(doc_secs) = itunes_track_duration_secs(apple_id).await? else { + continue; + }; + if (doc_secs - local as f64).abs() > AMLL_DURATION_TOLERANCE_SECS { + continue; + } + source_duration_secs = Some(doc_secs); + } + let formats: &[&str] = if candidate.exact { + &["ttml", "yrc", "lrc"] + } else { + &["ttml"] + }; + for format in formats.iter().copied() { + let client = http_client()?; + let response = client + .get(format!( + "{AMLL_BASE}/{platform}-lyrics/{}.{format}", + candidate.id + )) + .send() + .await + .map_err(|e| e.to_string())?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + // Index/file drift, or a format this entry doesn't ship. + continue; + } + if !response.status().is_success() { + return Err(format!( + "amll {platform}/{}.{format}: HTTP {}", + candidate.id, + response.status() + )); + } + let text = response.text().await.map_err(|e| e.to_string())?; + let lines = match format { + "ttml" => parse_ttml(&text), + "yrc" => parse_yrc(&text), + _ => parse_lrc(&text), + }; + if lines.is_empty() || !has_words(&lines) { + continue; + } + return Ok(Some(StoredDoc { + source: "amll".into(), + synced: lines, + plain: None, + instrumental: false, + source_duration_secs, + })); + } + } + } + Ok(None) +} + +/// The yrc word-timing format (NetEase's, but reaching us as the `.yrc` +/// files the AMLL DB hosts): `[lineStart,lineDur](wordStart,wordDur,0)text +/// (…)text…` per line, with JSON credit lines (`{"t":…}`) interleaved. +/// Pieces merge into words on whitespace boundaries exactly like the SYLT +/// path — which also means CJK yrc (per-character pieces, no spaces) +/// deliberately collapses to line-level highlight rather than gluing margins +/// between every character. Word timing is kept only when the words +/// reconstruct the visible line and run monotonic; anything else degrades to +/// the line-level display. +pub fn parse_yrc(text: &str) -> Vec { + let mut lines: Vec = Vec::new(); + for raw in text.lines() { + let raw = raw.trim(); + // Credit/metadata lines are JSON objects; lyric lines start with `[`. + if raw.is_empty() || !raw.starts_with('[') { + continue; + } + let Some((head, body)) = raw[1..].split_once(']') else { + continue; + }; + let line_start: Option = head.split(',').next().and_then(|v| v.trim().parse().ok()); + let mut pieces = body.split('('); + let mut display = String::from(pieces.next().unwrap_or("")); + let mut words: Vec = Vec::new(); + let mut previous_raw = String::new(); + let mut clean = true; // every piece parsed and carried a time + for piece in pieces { + let Some((meta, txt)) = piece.split_once(')') else { + // A stray `(` inside lyric text — keep the text, drop word mode. + display.push('('); + display.push_str(piece); + clean = false; + continue; + }; + let start: Option = meta.split(',').next().and_then(|v| v.trim().parse().ok()); + display.push_str(txt); + let trimmed = txt.trim(); + if trimmed.is_empty() { + continue; + } + let Some(ms) = start else { + clean = false; + continue; + }; + let boundary = words.is_empty() + || previous_raw.ends_with(char::is_whitespace) + || txt.starts_with(char::is_whitespace); + if boundary { + words.push(LyricWord { + time_ms: ms.max(0) as u32, + text: trimmed.to_string(), + }); + } else if let Some(last) = words.last_mut() { + last.text.push_str(trimmed); + } + previous_raw = txt.to_string(); + } + let display = display.split_whitespace().collect::>().join(" "); + if display.is_empty() { + continue; + } + let Some(time) = line_start.or_else(|| words.first().map(|w| w.time_ms as i64)) else { + continue; + }; + let monotonic = words.windows(2).all(|w| w[0].time_ms <= w[1].time_ms); + let reconstructs = words + .iter() + .map(|w| w.text.as_str()) + .collect::>() + .join(" ") + == display; + let words = if clean && words.len() >= 2 && monotonic && reconstructs { + words + } else { + Vec::new() + }; + lines.push(LyricLine { + time_ms: time.max(0) as u32, + text: display, + words, + }); + } + lines.sort_by_key(|l| l.time_ms); + lines +} + +// ---- the waterfall --------------------------------------------------------- + +/// Corroborated automatic offset between a source document's recording and +/// the local file, milliseconds (positive = lyrics later). +/// +/// The only evidence strong enough to act on automatically is AGREEMENT +/// between two independent measurements: +/// - the duration delta (local file vs the recording LRCLIB timed against); +/// - the local file's own measured leading silence. +/// A local file that is ~4 s longer AND carries ~4 s of leading silence has +/// a padded intro — shift the lyrics by it. A shorter local file (trimmed +/// lead-in) is accepted only when the local lead-in is essentially zero and +/// the first cue leaves room. Anything else stays at zero: a wrong automatic +/// shift is worse than the nudge buttons. +async fn compute_auto_offset( + path: Option<&str>, + local_duration_secs: Option, + source_duration_secs: Option, + first_line_ms: Option, +) -> i64 { + let (Some(path), Some(local), Some(source)) = + (path, local_duration_secs, source_duration_secs) + else { + return 0; + }; + let delta_ms = ((local as f64 - source) * 1000.0).round() as i64; + if delta_ms.abs() < 1200 || delta_ms.abs() > 15_000 { + return 0; + } + // Only the POSITIVE case is decidable from local evidence: extra length + // + matching measured lead-in silence = a padded intro, shift by it. A + // SHORTER local file proves nothing about the start — near-zero lead-in + // is the norm for every file, and a radio edit loses its length at the + // END, where a start-shift would wreck the timing. That case stays 0. + if delta_ms < 0 { + let _ = first_line_ms; + return 0; + } + let path = path.to_string(); + let silence_ms = tauri::async_runtime::spawn_blocking(move || { + player::vocals::leading_silence_ms(Path::new(&path)) + }) + .await + .ok() + .flatten(); + match silence_ms { + Some(silence) if (silence as i64 - delta_ms).abs() <= 1500 => delta_ms, + _ => 0, + } +} + +fn payload_from(doc: &StoredDoc, offset_ms: i64, duration_secs: Option) -> LyricsPayload { + if doc.instrumental { + return LyricsPayload { + status: "instrumental".into(), + source: doc.source.clone(), + synced: Vec::new(), + plain: None, + offset_ms, + ..LyricsPayload::none() + }; + } + // The plausibility gate protects against WRONG automatic candidates. A + // document the user pasted or picked themselves is their explicit call — + // second-guessing it would silently discard their own work. + let trusted = matches!(doc.source.as_str(), "user" | "picked"); + if !doc.synced.is_empty() && (trusted || plausible_timing(&doc.synced, duration_secs)) { + return LyricsPayload { + status: "synced".into(), + source: doc.source.clone(), + synced: doc.synced.clone(), + plain: doc.plain.clone(), + offset_ms, + ..LyricsPayload::none() + }; + } + if let Some(plain) = &doc.plain { + return LyricsPayload { + status: "plain".into(), + source: doc.source.clone(), + synced: Vec::new(), + plain: Some(plain.clone()), + offset_ms, + ..LyricsPayload::none() + }; + } + LyricsPayload::none() +} + +/// Resolve lyrics for one playing track. Pure lookup — never blocks playback, +/// never writes anything except the network cache. `refresh` bypasses the +/// cached network verdict (the pane's retry action). +#[tauri::command] +pub async fn lyrics_get( + app: tauri::AppHandle, + title: String, + artist: String, + album: Option, + duration_secs: Option, + path: Option, + refresh: Option, +) -> Result { + let title = title.trim().to_string(); + let artist = artist.trim().to_string(); + if title.is_empty() { + return Ok(LyricsPayload::none()); + } + let key = cache_key(&artist, &title, duration_secs); + let entry = { + let app = app.clone(); + let key = key.clone(); + tauri::async_runtime::spawn_blocking(move || load_entry(&app, &key)) + .await + .map_err(|e| e.to_string())? + }; + let offset = entry.offset_ms; + + // 1. The user's own document (paste or picked candidate) wins outright. + if let Some(user) = &entry.user { + let payload = payload_from(user, offset, duration_secs); + if payload.status != "none" { + return Ok(finish_synced( + payload, + entry.auto_sync_off, + path.as_deref(), + duration_secs, + user.source_duration_secs, + ) + .await); + } + } + + // 2. Local files: timed sidecar, then embedded SYLT. + let local = match path.clone() { + Some(p) if !p.is_empty() => { + tauri::async_runtime::spawn_blocking(move || { + let (sidecar_timed, sidecar_plain) = sidecar_lyrics(&p); + let embedded = tagger::read_lyrics(Path::new(&p)).ok(); + (sidecar_timed, sidecar_plain, embedded) + }) + .await + .map_err(|e| e.to_string())? + } + _ => (None, None, None), + }; + let (sidecar_timed, sidecar_plain, embedded) = local; + if let Some(lines) = sidecar_timed { + if plausible_timing(&lines, duration_secs) { + return Ok(LyricsPayload { + status: "synced".into(), + source: "sidecar".into(), + synced: lines, + plain: None, + offset_ms: offset, + ..LyricsPayload::none() + }); + } + } + if let Some(embedded) = &embedded { + if !embedded.synced.is_empty() { + let lines = lines_from_sylt(&embedded.synced); + if plausible_timing(&lines, duration_secs) { + return Ok(LyricsPayload { + status: "synced".into(), + source: "embedded".into(), + synced: lines, + plain: None, + offset_ms: offset, + ..LyricsPayload::none() + }); + } + } + } + + // 3. The word-level network catalog: the AMLL DB, by the file's own ISRC + // tag when it has one, else by duration-validated name match. Word timing + // outranks LRCLIB's line timing and loses to everything local above. + // Cached beside the LRCLIB verdict with its own not-found clock. + let refresh = refresh.unwrap_or(false); + let mut word_doc = entry.word.clone(); + let word_miss_fresh = entry + .word_not_found_at + .is_some_and(|at| now_secs() - at < NOT_FOUND_TTL_SECS); + if refresh || (word_doc.is_none() && !word_miss_fresh) { + let isrc = match path.clone() { + Some(p) if !p.is_empty() => { + tauri::async_runtime::spawn_blocking(move || { + tagger::read_isrc(Path::new(&p)).ok().flatten() + }) + .await + .map_err(|e| e.to_string())? + } + _ => None, + }; + match amll_lookup(&app, isrc.as_deref(), &artist, &title, duration_secs).await { + Ok(fetched) => { + word_doc = fetched.clone(); + let app2 = app.clone(); + let key2 = key.clone(); + // Merge into a FRESH read, for the same reason the LRCLIB + // write below does: user actions during the round-trip live. + tauri::async_runtime::spawn_blocking(move || { + let mut current = load_entry(&app2, &key2); + match fetched { + Some(doc) => { + current.word = Some(doc); + current.word_not_found_at = None; + } + None => current.word_not_found_at = Some(now_secs()), + } + save_entry(&app2, &key2, ¤t); + }) + .await + .map_err(|e| e.to_string())?; + } + Err(e) => { + // Transient: LRCLIB below still gets its turn, and the next + // resolve simply tries the word catalogs again. + log::debug!("word-level lyrics failed for {artist} — {title}: {e}"); + } + } + } + if let Some(doc) = &word_doc { + let payload = payload_from(doc, offset, duration_secs); + if payload.status == "synced" { + return Ok(finish_synced( + payload, + entry.auto_sync_off, + path.as_deref(), + duration_secs, + doc.source_duration_secs, + ) + .await); + } + } + + // 4. LRCLIB — cached verdict first, network on a miss. `refresh` bypasses + // BOTH cached states, hit and not-found — "look again" must actually look. + let mut lrclib_doc = entry.lrclib.clone(); + let not_found_fresh = entry + .not_found_at + .is_some_and(|at| now_secs() - at < NOT_FOUND_TTL_SECS); + if refresh || (lrclib_doc.is_none() && !not_found_fresh) { + let fetched = match lrclib_get(&artist, &title, album.as_deref(), duration_secs).await { + Ok(Some(record)) => Some(record), + Ok(None) => match lrclib_search(&artist, &title).await { + Ok(records) => { + confident_candidate(&records, &artist, &title, duration_secs).cloned() + } + Err(e) => { + // A transport failure is NOT a not-found — caching it for + // three days would hide lyrics that exist. Fall back like + // the /get error path and try again next time. + log::debug!("lyrics search failed for {artist} — {title}: {e}"); + let mut fallback = LyricsPayload::none(); + if let Some(plain) = local_plain(&sidecar_plain, &embedded) { + fallback = LyricsPayload { + status: "plain".into(), + source: plain.1.into(), + synced: Vec::new(), + plain: Some(plain.0), + offset_ms: offset, + ..LyricsPayload::none() + }; + } + return Ok(fallback); + } + }, + Err(e) => { + // Transient failure: report none WITHOUT caching a not-found, + // so the next track change simply tries again. + log::debug!("lyrics get failed for {artist} — {title}: {e}"); + let mut fallback = LyricsPayload::none(); + if let Some(plain) = local_plain(&sidecar_plain, &embedded) { + fallback = LyricsPayload { + status: "plain".into(), + source: plain.1.into(), + synced: Vec::new(), + plain: Some(plain.0), + offset_ms: offset, + ..LyricsPayload::none() + }; + } + return Ok(fallback); + } + }; + let fetched_doc = fetched.as_ref().map(record_to_doc); + lrclib_doc = fetched_doc.clone(); + let app2 = app.clone(); + let key2 = key.clone(); + // Merge into a FRESH read of the entry, not the snapshot from before + // the network round-trip — a pick, paste or offset the user made + // during those seconds must survive this write. + tauri::async_runtime::spawn_blocking(move || { + let mut current = load_entry(&app2, &key2); + match fetched_doc { + Some(doc) => { + current.lrclib = Some(doc); + current.not_found_at = None; + } + None => current.not_found_at = Some(now_secs()), + } + save_entry(&app2, &key2, ¤t); + }) + .await + .map_err(|e| e.to_string())?; + } + + if let Some(doc) = &lrclib_doc { + let payload = payload_from(doc, offset, duration_secs); + // A synced or instrumental verdict is authoritative; a plain-only one + // still yields to a local plain source (it is the same words, and the + // local copy may be the corrected one). + if payload.status == "synced" || payload.status == "instrumental" { + return Ok(finish_synced( + payload, + entry.auto_sync_off, + path.as_deref(), + duration_secs, + doc.source_duration_secs, + ) + .await); + } + if payload.status == "plain" && local_plain(&sidecar_plain, &embedded).is_none() { + return Ok(payload); + } + } + + // 5. Local plain text. + if let Some((text, source)) = local_plain(&sidecar_plain, &embedded) { + return Ok(LyricsPayload { + status: "plain".into(), + source: source.into(), + synced: Vec::new(), + plain: Some(text), + offset_ms: offset, + ..LyricsPayload::none() + }); + } + if let Some(doc) = &lrclib_doc { + let payload = payload_from(doc, offset, duration_secs); + if payload.status != "none" { + return Ok(payload); + } + } + Ok(LyricsPayload::none()) +} + +/// Stamp the auto-sync fields onto a winning synced payload. +async fn finish_synced( + mut payload: LyricsPayload, + auto_sync_off: bool, + path: Option<&str>, + duration_secs: Option, + source_duration_secs: Option, +) -> LyricsPayload { + payload.auto_sync_off = auto_sync_off; + if payload.status == "synced" && !auto_sync_off { + let first = payload.synced.first().map(|l| l.time_ms); + payload.auto_offset_ms = + compute_auto_offset(path, duration_secs, source_duration_secs, first).await; + } + payload +} + +fn local_plain( + sidecar_plain: &Option, + embedded: &Option, +) -> Option<(String, &'static str)> { + if let Some(text) = sidecar_plain { + return Some((text.clone(), "sidecar")); + } + if let Some(embedded) = embedded { + if let Some(text) = &embedded.plain { + return Some((text.clone(), "embedded")); + } + } + None +} + +/// Candidate list for the picker: LRCLIB search results, synced-first. +#[tauri::command] +pub async fn lyrics_search( + title: String, + artist: String, +) -> Result, String> { + let mut records = lrclib_search(&artist, &title).await?; + records.sort_by_key(|r| { + ( + r.synced_lyrics.is_none(), + r.plain_lyrics.is_none() && !r.instrumental, + ) + }); + records.truncate(20); + Ok(records + .iter() + .map(|r| { + serde_json::json!({ + "id": r.id, + "title": r.track_name, + "artist": r.artist_name, + "album": r.album_name, + "durationSecs": r.duration.max(0.0) as u32, + "synced": r.synced_lyrics.is_some(), + "instrumental": r.instrumental, + }) + }) + .collect()) +} + +/// Pin a picked candidate for this track — persisted as the user's choice. +#[tauri::command] +pub async fn lyrics_select( + app: tauri::AppHandle, + title: String, + artist: String, + duration_secs: Option, + candidate_id: i64, +) -> Result { + let record = lrclib_by_id(candidate_id).await?; + let mut doc = record_to_doc(&record); + doc.source = "picked".into(); + let key = cache_key(artist.trim(), title.trim(), duration_secs); + let payload = { + let app = app.clone(); + let doc2 = doc.clone(); + tauri::async_runtime::spawn_blocking(move || { + let mut entry = load_entry(&app, &key); + entry.user = Some(doc2); + entry.not_found_at = None; + save_entry(&app, &key, &entry); + entry.offset_ms + }) + .await + .map_err(|e| e.to_string())? + }; + Ok(payload_from(&doc, payload, duration_secs)) +} + +/// Persist the user's timing offset for this track. +#[tauri::command] +pub async fn lyrics_set_offset( + app: tauri::AppHandle, + title: String, + artist: String, + duration_secs: Option, + offset_ms: i64, +) -> Result<(), String> { + let key = cache_key(artist.trim(), title.trim(), duration_secs); + tauri::async_runtime::spawn_blocking(move || { + let mut entry = load_entry(&app, &key); + entry.offset_ms = offset_ms.clamp(-30_000, 30_000); + save_entry(&app, &key, &entry); + }) + .await + .map_err(|e| e.to_string()) +} + +/// Paste-your-own: timed text (LRC) becomes a synced user document, anything +/// else plain. The user's document outranks every automatic source. +#[tauri::command] +pub async fn lyrics_save_text( + app: tauri::AppHandle, + title: String, + artist: String, + duration_secs: Option, + text: String, +) -> Result { + if text.len() > 256 * 1024 { + return Err("Pasted lyrics are too large".into()); + } + // Treat the paste as LRC only when timestamps carry MOST of it — one + // stray [0:30] inside otherwise-plain lyrics must not throw the rest away. + let content_lines = text.lines().filter(|l| !l.trim().is_empty()).count().max(1); + let lines = parse_lrc(&text); + let timed_enough = !lines.is_empty() && lines.len() * 2 >= content_lines; + let doc = if timed_enough { + StoredDoc { + source: "user".into(), + synced: lines, + plain: None, + instrumental: false, + source_duration_secs: None, + } + } else { + let trimmed = text.trim().to_string(); + if trimmed.is_empty() { + return Err("Nothing to save".into()); + } + StoredDoc { + source: "user".into(), + synced: Vec::new(), + plain: Some(trimmed), + instrumental: false, + source_duration_secs: None, + } + }; + let key = cache_key(artist.trim(), title.trim(), duration_secs); + let offset = { + let app = app.clone(); + let doc2 = doc.clone(); + tauri::async_runtime::spawn_blocking(move || { + let mut entry = load_entry(&app, &key); + entry.user = Some(doc2); + save_entry(&app, &key, &entry); + entry.offset_ms + }) + .await + .map_err(|e| e.to_string())? + }; + Ok(payload_from(&doc, offset, duration_secs)) +} + +/// The vocal-band activity envelope for the sync editor's backdrop and the +/// rough-timing generator. Coarse by design — see player::vocals. +#[tauri::command] +pub async fn lyrics_vocal_activity(path: String) -> Result { + let activity = tauri::async_runtime::spawn_blocking(move || { + player::vocals::vocal_activity(Path::new(&path), 100) + }) + .await + .map_err(|e| e.to_string())??; + Ok(serde_json::json!({ + "frameMs": activity.frame_ms, + "frames": activity.frames, + })) +} + +/// Persist hand-synced (or editor-corrected) lines as this track's own +/// document — the top of the waterfall, like a paste. +#[tauri::command] +pub async fn lyrics_save_lines( + app: tauri::AppHandle, + title: String, + artist: String, + duration_secs: Option, + lines: Vec, +) -> Result { + let mut lines: Vec = lines + .into_iter() + .filter(|l| !l.text.trim().is_empty()) + .collect(); + lines.sort_by_key(|l| l.time_ms); + if lines.is_empty() { + return Err("Nothing to save".into()); + } + if lines.len() > 2_000 { + return Err("Too many lines".into()); + } + let doc = StoredDoc { + source: "user".into(), + synced: lines, + plain: None, + instrumental: false, + source_duration_secs: None, + }; + let key = cache_key(artist.trim(), title.trim(), duration_secs); + let offset = { + let app = app.clone(); + let doc2 = doc.clone(); + tauri::async_runtime::spawn_blocking(move || { + let mut entry = load_entry(&app, &key); + entry.user = Some(doc2); + entry.not_found_at = None; + save_entry(&app, &key, &entry); + entry.offset_ms + }) + .await + .map_err(|e| e.to_string())? + }; + Ok(payload_from(&doc, offset, duration_secs)) +} + +/// Turn the automatic timing correction on or off for one track. +#[tauri::command] +pub async fn lyrics_set_auto_sync( + app: tauri::AppHandle, + title: String, + artist: String, + duration_secs: Option, + enabled: bool, +) -> Result<(), String> { + let key = cache_key(artist.trim(), title.trim(), duration_secs); + tauri::async_runtime::spawn_blocking(move || { + let mut entry = load_entry(&app, &key); + entry.auto_sync_off = !enabled; + save_entry(&app, &key, &entry); + }) + .await + .map_err(|e| e.to_string()) +} + +/// How many rendered instrumentals the cache keeps (they are WAVs — roughly +/// 10 MB per minute — so the cap matters). +const INSTRUMENTAL_CACHE_KEEP: usize = 10; + +/// Render (or fetch from cache) a vocal-reduced copy of a local file and +/// return its path plus honest labels. Offline DSP — the classic +/// band-limited center cancel, not a learned separator; the UI wording +/// ("reduce vocals") must stay honest about that. +#[tauri::command] +pub async fn lyrics_prepare_instrumental( + app: tauri::AppHandle, + path: String, +) -> Result { + use tauri::Manager as _; + let source = PathBuf::from(&path); + if !source.is_file() { + return Err("The original file is not reachable right now".into()); + } + let dir = app + .path() + .app_cache_dir() + .map_err(|e| e.to_string())? + .join("instrumentals"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let stamp = std::fs::metadata(&source) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + let key = { + use std::hash::{Hash as _, Hasher as _}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + "center-cancel-v1".hash(&mut h); + path.hash(&mut h); + stamp.hash(&mut h); + format!("{:016x}", h.finish()) + }; + let dest = dir.join(format!("{key}.wav")); + if dest.is_file() { + // A cache hit refreshes the entry's touch file, so a WAV that keeps + // being listened to never ages to the bottom of the LRU while ten + // newer renders pile up above it. + let _ = std::fs::write(dir.join(format!("{key}.touch")), b"1"); + return Ok(serde_json::json!({ "path": dest.to_string_lossy(), "cached": true })); + } + let render_src = source.clone(); + let render_dest = dest.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + // Stage beside the destination so a killed render never leaves a + // half-written file answering to the cache key — and uniquely, so + // two concurrent renders of the same track cannot collide mid-write. + static STAGING_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let seq = STAGING_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let staging = render_dest.with_extension(format!("wav.rendering{seq}")); + let rendered = match player::vocals::render_vocal_reduced(&render_src, &staging) { + Ok(r) => r, + Err(e) => { + let _ = std::fs::remove_file(&staging); + return Err(e); + } + }; + std::fs::rename(&staging, &render_dest).map_err(|e| e.to_string())?; + // Bounded cache: newest N stay by LAST USE (render or touch), not + // render time alone. + let parent = render_dest.parent().unwrap(); + if let Ok(entries) = std::fs::read_dir(parent) { + let mut files: Vec<(std::time::SystemTime, PathBuf)> = entries + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "wav")) + .filter_map(|e| { + let path = e.path(); + let rendered_at = e.metadata().ok()?.modified().ok()?; + let touched_at = std::fs::metadata(path.with_extension("touch")) + .and_then(|m| m.modified()) + .ok(); + Some((touched_at.map_or(rendered_at, |t| t.max(rendered_at)), path)) + }) + .collect(); + files.sort_by(|a, b| b.0.cmp(&a.0)); + for (_, old) in files.into_iter().skip(INSTRUMENTAL_CACHE_KEEP) { + let _ = std::fs::remove_file(old.with_extension("touch")); + let _ = std::fs::remove_file(old); + } + } + Ok::<_, String>(rendered) + }) + .await + .map_err(|e| e.to_string())??; + Ok(serde_json::json!({ + "path": dest.to_string_lossy(), + "cached": false, + "durationSecs": result.duration_secs, + })) +} + +/// Forget everything stored for this track (user doc, network verdict, +/// offset). The next lookup starts the waterfall from scratch. +#[tauri::command] +pub async fn lyrics_clear( + app: tauri::AppHandle, + title: String, + artist: String, + duration_secs: Option, +) -> Result<(), String> { + let key = cache_key(artist.trim(), title.trim(), duration_secs); + tauri::async_runtime::spawn_blocking(move || { + if let Some(path) = cache_path(&app, &key) { + let _ = std::fs::remove_file(path); + } + }) + .await + .map_err(|e| e.to_string()) +} + +// ---- tests ----------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lrc_basics_offsets_and_repeats() { + let src = "\u{feff}[ar:Radiohead]\n[ti:Airbag]\n[offset:+500]\n[00:12.00]In the next world war\n[00:20.50][01:40.50]In a jackknifed juggernaut\n[00:30]I am born again\n\n[99:01.123]Way out\n"; + let lines = parse_lrc(src); + assert_eq!(lines.len(), 5); + // offset +500 shifts everything 500ms earlier. + assert_eq!(lines[0].time_ms, 11_500); + assert_eq!(lines[0].text, "In the next world war"); + assert_eq!(lines[1].time_ms, 20_000); + assert_eq!(lines[2].time_ms, 29_500, "bare [mm:ss] works"); + assert_eq!(lines[3].time_ms, 100_000, "repeated chorus timestamp"); + assert_eq!(lines[4].time_ms, 99 * 60_000 + 1_000 + 123 - 500, "3-decimal + >59 minutes"); + } + + #[test] + fn enhanced_lrc_word_marks_become_word_timing() { + let src = "[00:10.00]<00:10.00>Take <00:10.40>me <00:10.90>home"; + let lines = parse_lrc(src); + assert_eq!(lines[0].text, "Take me home"); + let words = &lines[0].words; + assert_eq!(words.len(), 3); + assert_eq!(words[0].text, "Take"); + assert_eq!(words[0].time_ms, 10_000); + assert_eq!(words[2].text, "home"); + assert_eq!(words[2].time_ms, 10_900); + + // A single stray mark is not word timing — display only. + let one = parse_lrc("[00:10.00]Take <00:10.40>me home"); + assert_eq!(one[0].text, "Take me home"); + assert!(one[0].words.is_empty(), "partial coverage carries no word timing"); + + // Non-monotonic marks are refused. + let bad = parse_lrc("[00:10.00]<00:11.00>Take <00:10.40>me <00:12.00>home"); + assert!(bad[0].words.is_empty()); + + // A repeated-chorus line keeps words only on the occurrence the + // absolute marks BELONG to — nearest stamp, not first-written. + let dup = parse_lrc("[01:10.00][00:10.00]<00:10.00>La <00:10.40>la"); + assert_eq!(dup.len(), 2); + assert_eq!(dup[0].time_ms, 10_000, "sorted first"); + assert_eq!(dup[0].words.len(), 2, "words ride the 10s occurrence"); + assert!(dup[1].words.is_empty()); + + // Syllabic marks merge at whitespace boundaries, like the SYLT path. + let syl = parse_lrc("[00:10.00]<00:10.00>Coun<00:10.20>try <00:10.50>roads"); + assert_eq!(syl[0].text, "Country roads"); + assert_eq!(syl[0].words.len(), 2); + assert_eq!(syl[0].words[0].text, "Country"); + assert_eq!(syl[0].words[1].text, "roads"); + } + + #[test] + fn sylt_units_group_into_lines_and_words() { + // Word-level SYLT, newline convention: entries prefixed with \n + // start a new display line. + let entries = vec![ + (1_000u32, "Take".to_string()), + (1_400, " me".to_string()), + (1_900, " home".to_string()), + (5_000, "\nCountry".to_string()), + (5_600, " roads".to_string()), + ]; + let lines = lines_from_sylt(&entries); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].text, "Take me home"); + assert_eq!(lines[0].time_ms, 1_000); + assert_eq!(lines[0].words.len(), 3); + assert_eq!(lines[1].text, "Country roads"); + assert_eq!(lines[1].words[1].time_ms, 5_600); + + // Syllabic content merges into words at whitespace boundaries. + let syllables = vec![ + (1_000u32, "Coun".to_string()), + (1_200, "try".to_string()), + (1_500, " roads".to_string()), + ]; + let lines = lines_from_sylt(&syllables); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, "Country roads"); + assert_eq!(lines[0].words.len(), 2); + assert_eq!(lines[0].words[0].text, "Country"); + + // Long entries with no newlines are already lines. + let linelevel = vec![ + (1_000u32, "Take me home, country roads".to_string()), + (5_000, "To the place I belong".to_string()), + ]; + let lines = lines_from_sylt(&linelevel); + assert_eq!(lines.len(), 2); + assert!(lines[0].words.is_empty()); + } + + #[test] + fn metadata_only_is_not_synced() { + assert!(parse_lrc("[ar:X]\n[ti:Y]\nJust text\n").is_empty()); + assert!(parse_lrc("[00:10.00]\n[00:20.00]\n").is_empty(), "stamps with no words"); + } + + #[test] + fn yrc_words_credits_and_degradations() { + // Latin lyrics: pieces carry their own trailing spaces. + let src = "{\"t\":0,\"c\":[{\"tx\":\"credits line\"}]}\n[1000,3000](1000,400,0)Take (1400,500,0)me (1900,600,0)home\n[5000,2000](5000,300,0)Coun(5300,300,0)try (5600,400,0)roads\n"; + let lines = parse_yrc(src); + assert_eq!(lines.len(), 2, "the JSON credits line is skipped"); + assert_eq!(lines[0].time_ms, 1_000); + assert_eq!(lines[0].text, "Take me home"); + assert_eq!(lines[0].words.len(), 3); + assert_eq!(lines[0].words[2].text, "home"); + assert_eq!(lines[0].words[2].time_ms, 1_900); + // Syllable pieces with no whitespace between them merge, SYLT-style. + assert_eq!(lines[1].text, "Country roads"); + assert_eq!(lines[1].words.len(), 2); + assert_eq!(lines[1].words[0].text, "Country"); + assert_eq!(lines[1].words[0].time_ms, 5_000); + + // CJK: per-character pieces, no whitespace → one merged run → falls + // below the two-word floor → line-level display, by design. + let cjk = parse_yrc("[1000,2000](1000,200,0)你(1200,200,0)好(1400,200,0)世(1600,200,0)界\n"); + assert_eq!(cjk[0].text, "你好世界"); + assert!(cjk[0].words.is_empty()); + + // Non-monotonic word times are refused; the line survives. + let bad = parse_yrc("[1000,2000](2000,200,0)late (1000,200,0)early\n"); + assert_eq!(bad[0].text, "late early"); + assert!(bad[0].words.is_empty()); + + // A stray '(' inside the text keeps the words visible, drops word mode. + let stray = parse_yrc("[1000,2000](1000,200,0)Oh (no\n"); + assert_eq!(stray[0].text, "Oh (no"); + assert!(stray[0].words.is_empty()); + } + + #[test] + fn amll_index_candidate_matching() { + let by_isrc = |index: &str, isrc: &str| -> Option { + amll_index_candidates(index, Some(isrc), "", "", false) + .first() + .map(|c| c.id.clone()) + }; + let index = concat!( + "{\"id\":\"111\",\"metadata\":[[\"album\",[\"X\"]],[\"isrc\",[\"USUM71703861\"]]],\"rawLyricFile\":\"a.ttml\"}\n", + "not json at all\n", + "{\"id\":\"222\",\"metadata\":[[\"isrc\",[\"gb-ahs-16-00001\",\"USRC17607839\"]],[\"artists\",[\"Y\"]]]}\n", + "{\"id\":\"333\",\"metadata\":[[\"musicName\",[\"USUM79999999\"]]]}\n", + "{\"id\":\"444\",\"metadata\":[[\"musicName\",[\"Take Me Home\",\"帯我回家\"]],[\"artists\",[\"The Band\",\"Guest\"]],[\"appleMusicId\",[\"1122773680\"]]]}\n", + "{\"id\":\"555\",\"metadata\":[[\"musicName\",[\"Take Me Home\"]],[\"artists\",[\"Somebody Else\"]]]}\n", + ); + assert_eq!(by_isrc(index, "USUM71703861"), Some("111".into())); + // Dashes/case in either side never matter, and multi-value ISRC lists work. + assert_eq!(by_isrc(index, "GBAHS1600001"), Some("222".into())); + assert_eq!(by_isrc(index, "usrc-17607839"), Some("222".into())); + // An ISRC-shaped string under a DIFFERENT key is not a match. + assert_eq!(by_isrc(index, "USUM79999999"), None); + assert_eq!(by_isrc(index, ""), None); + + // Name matching: normalized title+artist equality on multi-value + // name lists, non-exact flag, appleMusicId carried for the duration + // gate — and only when the caller can validate (duration known). + let named = amll_index_candidates(index, None, "take me home", "the band", true); + assert_eq!( + named, + vec![AmllCandidate { + id: "444".into(), + exact: false, + apple_id: Some("1122773680".into()), + }] + ); + assert!( + amll_index_candidates(index, None, "take me home", "the band", false).is_empty(), + "no name candidates without a validatable duration" + ); + assert!( + amll_index_candidates(index, None, "take me home", "nobody", true).is_empty(), + "artist must agree" + ); + // An exact ISRC hit preempts name candidates entirely. + let both = + amll_index_candidates(index, Some("USUM71703861"), "take me home", "the band", true); + assert_eq!(both.len(), 1); + assert_eq!(both[0].id, "111"); + assert!(both[0].exact); + } + + #[test] + fn ttml_word_timing_syllables_and_background() { + let src = r#" + +

+

Take me home

+

Country roads乡村路 (oh oh)

+
"#; + let lines = parse_ttml(src); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].time_ms, 7_759); + assert_eq!(lines[0].text, "Take me home"); + assert_eq!(lines[0].words.len(), 3); + assert_eq!(lines[0].words[1].text, "me"); + assert_eq!(lines[0].words[1].time_ms, 8_147); + // Adjacent spans with no whitespace between them are syllables of one + // word: merged text, first syllable's time. + assert_eq!(lines[1].text, "Country roads"); + assert_eq!(lines[1].words.len(), 2); + assert_eq!(lines[1].words[0].text, "Country"); + assert_eq!(lines[1].words[0].time_ms, 12_000); + // Auxiliary layers are dropped whole: backing vocals, and the + // translation span that sits flush against the last word (kept, it + // would glue on: "roads乡村路"). + assert!(!lines[1].text.contains("oh"), "background vocals stay off the main line"); + assert_eq!(lines[1].words[1].text, "roads", "translations stay off the main words"); + } + + #[test] + fn ttml_line_timing_clock_forms_and_refusals() { + // Line-timed document: p text with no timed spans → plain synced + // lines, and every clock form Apple/AMLL files actually write. + let src = r#" +

Take me home

+

Country roads

+

To the place

+
"#; + let lines = parse_ttml(src); + assert_eq!(lines.len(), 3); + assert!(lines.iter().all(|l| l.words.is_empty()), "no word marks, no word timing"); + assert_eq!(lines[0].time_ms, 7_759); + assert_eq!(lines[1].time_ms, 75_500); + assert_eq!(lines[2].time_ms, 90_250); + assert_eq!(lines[2].text, "To the place"); + + // Not TTML, malformed XML, or DTD-carrying: refused, never guessed. + assert!(parse_ttml("[00:10.00]Take me home").is_empty()); + assert!(parse_ttml("

dangling").is_empty()); + assert!(parse_ttml("

x

").is_empty()); + + // A word line where one token lacks timing degrades to line-level — + // word mode renders `words`, so a partial set would drop text. + let partial = r#"

Take me

"#; + let lines = parse_ttml(partial); + assert_eq!(lines[0].text, "Take me"); + assert!(lines[0].words.is_empty(), "partial coverage carries no word timing"); + } + + #[test] + fn timing_plausibility() { + let lines = vec![ + LyricLine { time_ms: 10_000, text: "a".into(), words: Vec::new() }, + LyricLine { time_ms: 500_000, text: "b".into(), words: Vec::new() }, + LyricLine { time_ms: 600_000, text: "c".into(), words: Vec::new() }, + ]; + assert!(!plausible_timing(&lines, Some(120)), "mostly past the end of a 2min track"); + assert!(plausible_timing(&lines, Some(700))); + assert!(plausible_timing(&lines, None), "no duration, nothing to contradict"); + } + + #[test] + fn sidecars_prefer_timed_and_respect_language_suffixes() { + let dir = std::env::temp_dir().join("ssplus-lyrics-sidecar"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let audio = dir.join("01 Airbag.flac"); + std::fs::write(&audio, b"x").unwrap(); + + // Only a plain .txt: needs several lines to count as lyrics. + std::fs::write(dir.join("01 Airbag.txt"), "one\ntwo\nthree\nfour\n").unwrap(); + let (timed, plain) = sidecar_lyrics(audio.to_str().unwrap()); + assert!(timed.is_none()); + assert_eq!(plain.as_deref(), Some("one\ntwo\nthree\nfour")); + + // A language-suffixed LRC variant is found without the exact name. + std::fs::write(dir.join("01 Airbag.en.lrc"), "[00:12.00]In the next world war\n").unwrap(); + let (timed, _) = sidecar_lyrics(audio.to_str().unwrap()); + assert_eq!(timed.as_ref().map(|l| l.len()), Some(1)); + + // The exact-basename LRC wins over the variant. + std::fs::write(dir.join("01 Airbag.lrc"), "[00:01.00]a\n[00:02.00]b\n").unwrap(); + let (timed, _) = sidecar_lyrics(audio.to_str().unwrap()); + assert_eq!(timed.as_ref().map(|l| l.len()), Some(2)); + + // A .txt that does not look like lyrics (too few lines) is ignored. + std::fs::write(dir.join("01 Airbag.txt"), "tracker rip notes").unwrap(); + let (_, plain) = sidecar_lyrics(audio.to_str().unwrap()); + assert!(plain.is_none()); + } + + #[test] + fn cache_keys_bucket_duration() { + assert_eq!( + cache_key("Radiohead", "Airbag", Some(287)), + cache_key("radiohead", "AIRBAG", Some(284)), + ); + assert_ne!( + cache_key("Radiohead", "Airbag", Some(287)), + cache_key("Radiohead", "Airbag (Live)", Some(287)), + ); + } +} diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index 9b47248..3c64a2f 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -11,11 +11,9 @@ use library::Library; use metadata::{AlbumMeta, MetadataClient}; use tagger::{TagSummary, TagWrite}; -// aiff/aif joined for the import tool: the reference library carries 13 AIFF -// tracks that a scan would otherwise silently drop, and "silently" is the -// problem — the album imports incomplete with no warning. -pub(crate) const AUDIO_EXTS: [&str; 10] = - ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "aiff", "aif"]; +// The one table, in `audio-formats`. This used to be a local list that +// disagreed with the share index and the row pruner — the scanner indexed +// `.aiff` and the pruner deleted it on the next startup. pub struct LibraryState { pub library: Arc, @@ -140,12 +138,53 @@ fn spawn_identity_warmer(app: tauri::AppHandle) { } pub fn init(app: &tauri::AppHandle) -> Result<(), String> { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?; + // AppData keeps only what is about the APP on THIS machine — the cache of + // remote provider responses. Everything describing the LIBRARY lives in + // the library folder so it travels with the music. + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?; + + let cfg = crate::config::load(app); + let library_root = crate::config::download_dir(app, &cfg); + let moved = crate::config::migrate_library_state_into_library(app, &cfg); + if !moved.is_empty() { + log::info!("library state moved into the library folder: {}", moved.join(", ")); + } + + // With the drive unplugged there is no library to open. Fall back to a + // machine-local database rather than refusing to start — the real one + // returns with the drive, and nothing here writes into the wrong library + // because every path is compared against the configured root. + let (data_dir, portable) = match crate::config::ensure_library_data_dir(app, &cfg) { + Ok(dir) => (dir, true), + Err(e) => { + log::warn!( + "library folder {} is unavailable ({e}) — running with a local database until it returns", + library_root.display() + ); + (app_dir.clone(), false) + } + }; let art_dir = data_dir.join("art"); std::fs::create_dir_all(&art_dir).map_err(|e| e.to_string())?; let library = Arc::new(Library::open(&data_dir.join("library.db")).map_err(|e| e.to_string())?); + if portable { + // The art cache moved with the database, so rows written before the + // move still point into AppData. Re-point them before anything paints. + match library.rewrite_path_prefix(&app_dir.join("art"), &art_dir) { + Ok(n) if n > 0 => log::info!("library: {n} artwork paths follow the art cache into the library folder"), + Err(e) => log::warn!("library: could not re-point artwork paths: {e}"), + _ => {} + } + // And the library itself may have arrived at a new drive letter or on + // a different machine entirely. + match library.rebase_to_root(&library_root) { + Ok(n) if n > 0 => log::info!("library moved: re-based {n} stored paths onto {}", library_root.display()), + Err(e) => log::warn!("library: could not re-base stored paths: {e}"), + _ => {} + } + } let repair_library = library.clone(); let repair_art_dir = art_dir.clone(); @@ -156,10 +195,13 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> { // description warmth instead of re-serializing MusicBrainz at 1 req/s. metadata: Arc::new({ let client = MetadataClient::new() - .with_disk_cache(data_dir.join("net-cache-v1")) + // Provider responses are about the WORLD, not this library, and + // they are cheap to refetch — they stay on the machine. + .with_disk_cache(app_dir.join("net-cache-v1")) // Its own directory, NOT inside the cache above: that one is // pruned by mtime, and these are verdicts rather than cached - // responses. + // responses. Verdicts about the library's artists are library + // state, so they travel with it. .with_identity_store(data_dir.join("artist-identity-v1")); // Hero candidate/scoring diagnostics on demand, so an in-APP hero // resolution can be compared against `hero_source_probe` — the two @@ -193,8 +235,33 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> { } } let state = handle.state::(); + // Before anything asks the disk: the library folder decides what is IN + // the library. Rows from a folder that used to hold it are not "still + // there because their files exist" — they belong to another library. + let lib = state.library.clone(); + let root = downloads_dir(&handle); + if let Ok(Ok(dropped)) = + tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await + { + if dropped > 0 { + log::info!("library: dropped {dropped} tracks outside the library folder"); + } + } let lib = state.library.clone(); let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await; + // After the prune, every surviving track has a file — so "the folder + // this album lives in" is answerable, and any cover pointing somewhere + // else is another album's. Files move (a re-import into a new layout, a + // drive back at a different letter) and nothing else re-asks. + let lib = state.library.clone(); + let art = state.art_dir.clone(); + if let Ok(Ok(moved)) = + tauri::async_runtime::spawn_blocking(move || lib.reanchor_album_art(&art)).await + { + if moved > 0 { + log::info!("library repair: re-anchored artwork for {moved} albums"); + } + } let lib = state.library.clone(); if let Ok(Ok(removed)) = tauri::async_runtime::spawn_blocking(move || lib.prune_non_audio()).await @@ -459,16 +526,15 @@ pub(crate) fn collect_audio_files(dir: &Path, out: &mut Vec, depth: u8) // "(Cache)" is the download staging pen — a completed file // sitting there is seconds from being organized out, and // indexing it would pin a library row to a staging path. - if path.file_name().is_some_and(|n| n == "(Cache)") { + // ".soulstream" is the library's own state, not music. + if path + .file_name() + .is_some_and(|n| n == "(Cache)" || n == crate::config::LIBRARY_DATA_DIRNAME) + { continue; } collect_audio_files(&path, out, depth + 1); - } else if path - .extension() - .and_then(|e| e.to_str()) - .map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str())) - .unwrap_or(false) - { + } else if audio_formats::is_audio_path(&path) { out.push(path); } } @@ -659,6 +725,9 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path album: summary.album.is_none().then_some(final_album.clone()), title: summary.title.is_none().then_some(title.clone()), track_no: summary.track_no.or(parsed.track_no), + // Nothing to add: a downloaded file either carries its disc or has no + // disc to carry, and there is nowhere here to learn one from. + disc_no: None, year: summary.year.is_none().then(|| final_year).flatten(), label: None, genre: None, @@ -701,6 +770,9 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path let p = library_path.to_string_lossy().into_owned(); let art = art_path.as_ref().map(|a| a.to_string_lossy().into_owned()); let track_no = summary.track_no.or(parsed.track_no); + // Only what the file itself says. There is no filename convention for + // a disc worth guessing from, and a wrong disc reorders an album. + let disc_no = summary.disc_no; let (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps); let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth); let size = std::fs::metadata(&library_path).ok().map(|m| m.len()); @@ -724,6 +796,7 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path } lib.upsert_track( album_id, + disc_no, track_no, &title2, &artist, @@ -853,10 +926,7 @@ pub async fn refresh_all_artist_art(app: &tauri::AppHandle) -> Result bool { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str())) - .unwrap_or(false) + audio_formats::is_audio_path(path) } /// Move a newly completed download into one album directory under the managed diff --git a/src-tauri/src/playlist_formats.rs b/src-tauri/src/playlist_formats.rs new file mode 100644 index 0000000..91a2fce --- /dev/null +++ b/src-tauri/src/playlist_formats.rs @@ -0,0 +1,1197 @@ +//! Playlist file parsing and writing — pure functions, no Tauri, no database, +//! no network. Every parser reads user-selected but still untrusted bytes, so +//! everything here is bounded: source size, entry count, field length. A +//! parser returns entries and warnings; it never touches the filesystem, and +//! resolving a locator against the library is the command layer's job. +//! +//! Formats, per the playlist feature research (TODO-Feature-Docs/ +//! playlist-management-and-setlist-import): M3U/M3U8 (Extended M3U, HLS +//! rejected), PLS, XSPF v1, a versioned SoulStream CSV profile, a versioned +//! SoulStream JSON profile, and pasted text tracklists with optional setlist +//! timestamps. + +use std::fmt::Write as _; + +/// Source files past this size are refused outright — the largest legitimate +/// playlist is a few hundred KB; an 8 MB "playlist" is a mistake or an attack. +pub const MAX_SOURCE_BYTES: usize = 8 * 1024 * 1024; +/// Entries beyond this are dropped with a warning. +pub const MAX_ENTRIES: usize = 10_000; +/// Any single field (title, artist, locator…) is clipped to this many chars. +pub const MAX_FIELD_CHARS: usize = 1024; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ParsedPlaylist { + /// "m3u" | "pls" | "xspf" | "csv" | "soulstream" | "text" + pub format: String, + pub title: Option, + pub description: Option, + pub entries: Vec, + pub warnings: Vec, +} + +/// One ordered occurrence as the source file stated it. `location` is the raw +/// locator exactly as decoded (relative paths not yet resolved); `raw` is the +/// sanitized provenance line kept for repair tools. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct ParsedEntry { + pub ordinal: u32, + pub title: Option, + pub artist: Option, + pub album: Option, + pub duration_secs: Option, + pub location: Option, + /// Setlist timestamp (where in a continuous source this entry starts). + pub start_ms: Option, + pub raw: String, +} + +// ---- decoding -------------------------------------------------------------- + +/// Decode playlist bytes: BOM-tolerant strict UTF-8 first, then a Windows-1252 +/// reading with a warning. Never silently replaces undecodable bytes — the +/// 1252 mapping is total, so the fallback is deterministic and reversible. +fn decode(bytes: &[u8], warnings: &mut Vec) -> String { + // UTF-16 with a BOM (Windows Notepad's old default "Unicode") decodes + // properly instead of arriving as cp1252 mojibake. + if bytes.len() >= 2 && (bytes[..2] == [0xFF, 0xFE] || bytes[..2] == [0xFE, 0xFF]) { + let le = bytes[0] == 0xFF; + let units: Vec = bytes[2..] + .chunks_exact(2) + .map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }) + .collect(); + return String::from_utf16_lossy(&units); + } + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + Err(_) => { + warnings.push("Not valid UTF-8 — read with the Windows-1252 legacy encoding".into()); + bytes.iter().map(|&b| cp1252_char(b)).collect() + } + } +} + +/// Windows-1252: identical to Latin-1 except the 0x80–0x9F block. +fn cp1252_char(b: u8) -> char { + const HIGH: [char; 32] = [ + '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', + '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9d}', + 'ž', 'Ÿ', + ]; + match b { + 0x80..=0x9F => HIGH[(b - 0x80) as usize], + other => other as char, + } +} + +fn clip(s: &str) -> String { + let s = s.trim(); + if s.chars().count() > MAX_FIELD_CHARS { + s.chars().take(MAX_FIELD_CHARS).collect() + } else { + s.to_string() + } +} + +fn non_empty(s: &str) -> Option { + let t = clip(s); + (!t.is_empty()).then_some(t) +} + +// ---- format detection ------------------------------------------------------ + +/// Decide the parser from the filename extension first, content second. A +/// `.m3u8` holding HLS tags is refused later by the M3U parser itself. +pub fn detect_format(name: &str, bytes: &[u8]) -> &'static str { + let ext = name + .rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + match ext.as_str() { + "m3u" | "m3u8" => return "m3u", + "pls" => return "pls", + "xspf" => return "xspf", + "csv" => return "csv", + "json" => return "soulstream", + "txt" => {} // fall through to sniffing: a .txt can hold pasted M3U + _ => {} + } + let mut warnings = Vec::new(); + let head: String = decode(&bytes[..bytes.len().min(4096)], &mut warnings) + .trim_start() + .chars() + .take(512) + .collect(); + if head.starts_with("#EXTM3U") { + "m3u" + } else if head.to_ascii_lowercase().starts_with("[playlist]") { + "pls" + } else if head.starts_with(" Result { + if bytes.len() > MAX_SOURCE_BYTES { + return Err(format!( + "{name} is {} MB — too large for a playlist file", + bytes.len() / (1024 * 1024) + )); + } + let format = detect_format(name, bytes); + let mut parsed = match format { + "m3u" => parse_m3u(bytes)?, + "pls" => parse_pls(bytes)?, + "xspf" => parse_xspf(bytes)?, + "csv" => parse_csv(bytes)?, + "soulstream" => parse_soulstream_json(bytes)?, + _ => parse_text(bytes)?, + }; + if parsed.title.is_none() { + let stem = name + .rsplit(['\\', '/']) + .next() + .unwrap_or(name) + .rsplit_once('.') + .map(|(s, _)| s) + .unwrap_or(name); + parsed.title = non_empty(stem); + } + Ok(parsed) +} + +// ---- M3U / Extended M3U ---------------------------------------------------- + +fn parse_m3u(bytes: &[u8]) -> Result { + let mut warnings = Vec::new(); + let text = decode(bytes, &mut warnings); + // HLS shares the extension and the header; its media/master tags mean + // this is a streaming manifest, not a list of songs. + if text.lines().any(|l| l.trim_start().starts_with("#EXT-X-")) { + return Err("This is an HLS streaming manifest, not a playlist of tracks".into()); + } + let mut entries = Vec::new(); + let mut pending: Option<(Option, Option, Option)> = None; // (dur, artist, title) + let mut title = None; + for line in text.lines() { + let line = line.trim_end_matches('\r').trim(); + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix("#EXTINF:") { + // "#EXTINF:245,Artist - Title" — duration may be float or -1. + let (dur, display) = rest.split_once(',').unwrap_or((rest, "")); + let duration = dur + .trim() + .parse::() + .ok() + .filter(|d| *d > 0.0) + .map(|d| d.round() as u32); + let (artist, dtitle) = split_artist_title(display); + pending = Some((duration, artist, dtitle)); + continue; + } + if let Some(rest) = line.strip_prefix("#PLAYLIST:") { + title = non_empty(rest); + continue; + } + if line.starts_with('#') { + continue; // unknown directive — ignored for playback, harmless + } + if entries.len() >= MAX_ENTRIES { + warnings.push(format!("Stopped after {MAX_ENTRIES} entries")); + break; + } + let (duration_secs, artist, entry_title) = pending.take().unwrap_or((None, None, None)); + entries.push(ParsedEntry { + ordinal: entries.len() as u32, + title: entry_title.or_else(|| file_stem_title(line)), + artist, + album: None, + duration_secs, + location: non_empty(line), + start_ms: None, + raw: clip(line), + }); + } + Ok(ParsedPlaylist { + format: "m3u".into(), + title, + description: None, + entries, + warnings, + }) +} + +/// "Artist - Title" (or the em/en-dash forms) split. Returns (artist, title); +/// no separator means the whole display string is the title. +fn split_artist_title(display: &str) -> (Option, Option) { + let display = display.trim(); + if display.is_empty() { + return (None, None); + } + for sep in [" — ", " – ", " - "] { + if let Some((a, t)) = display.split_once(sep) { + return (non_empty(a), non_empty(t)); + } + } + (None, non_empty(display)) +} + +/// A filename's stem as a last-resort display title, with a leading track +/// number stripped: `03 - Subterranean.flac` → `Subterranean`. +fn file_stem_title(locator: &str) -> Option { + let file = locator.rsplit(['\\', '/']).next()?; + let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file); + let stem = stem.trim(); + let digits: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect(); + let rest = stem[digits.len()..].trim_start_matches(['-', '.', ' ', '_']); + let cleaned = if !digits.is_empty() && digits.len() <= 3 && !rest.is_empty() { + rest + } else { + stem + }; + non_empty(cleaned) +} + +// ---- PLS ------------------------------------------------------------------- + +fn parse_pls(bytes: &[u8]) -> Result { + let mut warnings = Vec::new(); + let text = decode(bytes, &mut warnings); + // Indexed keys arrive in any order; collect by index, emit by index order. + let mut by_index: std::collections::BTreeMap = Default::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('[') || line.starts_with(';') { + continue; + } + let Some((key, value)) = line.split_once('=') else { continue }; + let key = key.trim(); + let value = value.trim(); + let (field, index) = match key.to_ascii_lowercase() { + k if k.starts_with("file") => ("file", k[4..].parse::().ok()), + k if k.starts_with("title") => ("title", k[5..].parse::().ok()), + k if k.starts_with("length") => ("length", k[6..].parse::().ok()), + _ => continue, + }; + let Some(index) = index else { continue }; + if index == 0 || index as usize > MAX_ENTRIES { + warnings.push(format!("Ignored out-of-range PLS index {index}")); + continue; + } + let entry = by_index.entry(index).or_default(); + match field { + "file" => { + entry.location = non_empty(value); + entry.raw = clip(value); + if entry.title.is_none() { + entry.title = file_stem_title(value); + } + } + "title" => { + let (artist, title) = split_artist_title(value); + if artist.is_some() { + entry.artist = artist; + } + if title.is_some() { + entry.title = title; + } + } + "length" => { + entry.duration_secs = value.parse::().ok().filter(|v| *v > 0).map(|v| v as u32); + } + _ => {} + } + } + let entries: Vec = by_index + .into_values() + .filter(|e| e.location.is_some() || e.title.is_some()) + .enumerate() + .map(|(ordinal, mut e)| { + e.ordinal = ordinal as u32; + e + }) + .collect(); + Ok(ParsedPlaylist { + format: "pls".into(), + title: None, + description: None, + entries, + warnings, + }) +} + +// ---- XSPF v1 --------------------------------------------------------------- + +fn parse_xspf(bytes: &[u8]) -> Result { + use quick_xml::events::Event; + let mut warnings = Vec::new(); + let text = decode(bytes, &mut warnings); + if text.contains(" = Vec::new(); + let mut playlist_title = None; + let mut description = None; + let mut in_track = false; + let mut depth = 0usize; + let mut current = ParsedEntry::default(); + // Which simple element we are inside (title/creator/album/location/duration/annotation). + let mut field: Option<&'static str> = None; + loop { + match reader.read_event() { + Ok(Event::Start(e)) => { + depth += 1; + if depth > 32 { + return Err("XSPF nesting too deep".into()); + } + match e.local_name().as_ref() { + b"track" => { + in_track = true; + current = ParsedEntry::default(); + } + b"title" => field = Some("title"), + b"creator" => field = Some("creator"), + b"album" => field = Some("album"), + b"location" => field = Some("location"), + b"duration" => field = Some("duration"), + b"annotation" => field = Some("annotation"), + _ => field = None, + } + } + Ok(Event::CData(t)) => { + // Some exporters wrap titles/locations in CDATA; the content + // is the same, just unescaped by definition. + let value = String::from_utf8_lossy(t.as_ref()).into_owned(); + apply_xspf_field(in_track, field, &value, &mut current, &mut playlist_title, &mut description); + } + Ok(Event::Text(t)) => { + let value = t.unescape().map_err(|e| e.to_string())?.into_owned(); + apply_xspf_field(in_track, field, &value, &mut current, &mut playlist_title, &mut description); + } + Ok(Event::End(e)) => { + depth = depth.saturating_sub(1); + field = None; + if e.local_name().as_ref() == b"track" && in_track { + in_track = false; + if entries.len() < MAX_ENTRIES { + let mut entry = std::mem::take(&mut current); + if entry.raw.is_empty() { + entry.raw = entry.title.clone().unwrap_or_default(); + } + entry.ordinal = entries.len() as u32; + entries.push(entry); + } else { + warnings.push(format!("Stopped after {MAX_ENTRIES} entries")); + } + } + } + Ok(Event::Eof) => break, + Ok(_) => {} + Err(e) => return Err(format!("XSPF parse error: {e}")), + } + } + Ok(ParsedPlaylist { + format: "xspf".into(), + title: playlist_title, + description, + entries, + warnings, + }) +} + +/// One XSPF simple-element value applied to the right slot — shared by the +/// Text and CDATA arms so the two encodings cannot drift apart. +fn apply_xspf_field( + in_track: bool, + field: Option<&'static str>, + value: &str, + current: &mut ParsedEntry, + playlist_title: &mut Option, + description: &mut Option, +) { + match (in_track, field) { + (true, Some("title")) => current.title = non_empty(value), + (true, Some("creator")) => current.artist = non_empty(value), + (true, Some("album")) => current.album = non_empty(value), + (true, Some("location")) => { + current.location = non_empty(&decode_file_uri(value)); + current.raw = clip(value); + } + (true, Some("duration")) => { + // XSPF duration is milliseconds. + current.duration_secs = value + .trim() + .parse::() + .ok() + .map(|ms| (ms / 1000) as u32) + .filter(|s| *s > 0); + } + (false, Some("title")) => *playlist_title = non_empty(value), + (false, Some("annotation")) => *description = non_empty(value), + _ => {} + } +} + +/// `file:///C:/Music/x.flac` → `C:\Music\x.flac`; percent-decoding included. +/// A schemeless RELATIVE reference is also a URI, so its percent-escapes +/// decode too (`My%20Song.flac` names "My Song.flac"). Other schemes pass +/// through untouched (they stay unresolved locators). +fn decode_file_uri(value: &str) -> String { + let Some(rest) = value.strip_prefix("file://") else { + // No scheme at all → a relative URI reference: percent-decode it so + // resolution against the playlist directory sees the real filename. + if !value.contains("://") && value.contains('%') { + return percent_decode(value); + } + return value.to_string(); + }; + let rest = rest.strip_prefix('/').unwrap_or(rest); + let decoded = percent_decode(rest); + // A Windows drive path ("C:/…") wants backslashes; a UNC-ish or POSIX + // path is left with its slashes. + if decoded.get(1..2) == Some(":") { + decoded.replace('/', "\\") + } else { + format!("/{decoded}") + } +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()); + if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(v); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +// ---- SoulStream CSV v1 ----------------------------------------------------- + +/// RFC 4180 record reader: quotes, escaped quotes, embedded newlines. Total +/// decoded size is already bounded by MAX_SOURCE_BYTES. +fn csv_records(text: &str) -> Vec> { + let mut records = Vec::new(); + let mut record = Vec::new(); + let mut fieldbuf = String::new(); + let mut in_quotes = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if in_quotes { + match c { + '"' if chars.peek() == Some(&'"') => { + fieldbuf.push('"'); + chars.next(); + } + '"' => in_quotes = false, + other => fieldbuf.push(other), + } + continue; + } + match c { + '"' => in_quotes = true, + ',' => record.push(std::mem::take(&mut fieldbuf)), + '\r' => {} + '\n' => { + record.push(std::mem::take(&mut fieldbuf)); + if record.iter().any(|f| !f.trim().is_empty()) { + records.push(std::mem::take(&mut record)); + } else { + record.clear(); + } + if records.len() > MAX_ENTRIES + 8 { + break; + } + } + other => fieldbuf.push(other), + } + } + if !fieldbuf.is_empty() || !record.is_empty() { + record.push(fieldbuf); + if record.iter().any(|f| !f.trim().is_empty()) { + records.push(record); + } + } + records +} + +/// Strip the formula-injection guard the CSV writer adds (a leading `'` +/// before `=`, `+`, `-`, `@`). Documented, reversible. +fn unescape_csv_cell(s: &str) -> String { + let t = s.trim(); + if let Some(rest) = t.strip_prefix('\'') { + if rest.starts_with(['=', '+', '-', '@']) { + return rest.to_string(); + } + } + t.to_string() +} + +fn parse_csv(bytes: &[u8]) -> Result { + let mut warnings = Vec::new(); + let text = decode(bytes, &mut warnings); + let records = csv_records(&text); + let Some(header) = records.first() else { + return Err("Empty CSV file".into()); + }; + let idx = |names: &[&str]| -> Option { + header.iter().position(|h| { + let h = h.trim().to_ascii_lowercase(); + names.iter().any(|n| h == *n) + }) + }; + let col_title = idx(&["title", "track", "song", "name"]); + let col_artist = idx(&["artist", "artists", "artist name(s)", "creator"]); + let col_album = idx(&["album", "release"]); + let col_dur_ms = idx(&["duration_ms", "duration (ms)"]); + let col_dur_s = idx(&["duration_secs", "duration", "length"]); + let col_path = idx(&["path", "location", "file", "locator"]); + let col_start = idx(&["start_ms", "start"]); + if col_title.is_none() && col_path.is_none() { + return Err("CSV has no recognizable title or path column".into()); + } + let get = |record: &[String], col: Option| -> Option { + col.and_then(|c| record.get(c)).and_then(|v| non_empty(&unescape_csv_cell(v))) + }; + let mut entries = Vec::new(); + for record in records.iter().skip(1) { + if entries.len() >= MAX_ENTRIES { + warnings.push(format!("Stopped after {MAX_ENTRIES} entries")); + break; + } + // Our own export writes a playlist metadata row; skip it on re-import. + if record.first().map(|f| f.trim()) == Some("playlist") { + continue; + } + let duration_secs = get(record, col_dur_ms) + .and_then(|v| v.parse::().ok()) + .map(|ms| (ms / 1000) as u32) + .or_else(|| get(record, col_dur_s).and_then(|v| v.parse::().ok())) + .filter(|s| *s > 0); + let entry = ParsedEntry { + ordinal: entries.len() as u32, + title: get(record, col_title), + artist: get(record, col_artist), + album: get(record, col_album), + duration_secs, + location: get(record, col_path), + start_ms: get(record, col_start).and_then(|v| v.parse::().ok()), + raw: clip(&record.join(", ")), + }; + if entry.title.is_none() && entry.location.is_none() { + continue; + } + entries.push(entry); + } + Ok(ParsedPlaylist { + format: "csv".into(), + title: None, + description: None, + entries, + warnings, + }) +} + +// ---- SoulStream JSON v1 ---------------------------------------------------- + +const SOULSTREAM_JSON_FORMAT: &str = "soulstream-playlist"; + +fn parse_soulstream_json(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| "JSON must be UTF-8".to_string())?; + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("Invalid JSON: {e}"))?; + let format = value.get("format").and_then(|v| v.as_str()).unwrap_or(""); + if format != SOULSTREAM_JSON_FORMAT { + return Err("Not a SoulStream playlist JSON file (missing format marker)".into()); + } + let version = value.get("version").and_then(|v| v.as_i64()).unwrap_or(0); + if version != 1 { + return Err(format!("Unsupported SoulStream playlist version {version}")); + } + let mut warnings = Vec::new(); + let s = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_str()).and_then(non_empty); + let mut entries = Vec::new(); + for item in value + .get("items") + .and_then(|v| v.as_array()) + .map(|a| a.as_slice()) + .unwrap_or_default() + { + if entries.len() >= MAX_ENTRIES { + warnings.push(format!("Stopped after {MAX_ENTRIES} entries")); + break; + } + entries.push(ParsedEntry { + ordinal: entries.len() as u32, + title: s(item.get("title")), + artist: s(item.get("artist")), + album: s(item.get("album")), + duration_secs: item + .get("durationSecs") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + location: s(item.get("path")), + start_ms: item.get("startMs").and_then(|v| v.as_i64()), + raw: s(item.get("sourceLine")).unwrap_or_default(), + }); + } + Ok(ParsedPlaylist { + format: "soulstream".into(), + title: s(value.get("name")), + description: s(value.get("description")), + entries, + warnings, + }) +} + +// ---- pasted text tracklists ------------------------------------------------ + +/// Parse a pasted tracklist. Recognized line shapes, in the order tried: +/// +/// ```text +/// 12. 1:23:45 Artist - Title (index + timestamp + split) +/// [01:23] Artist — Title (Remix) +/// 01:23 Artist - Title +/// Artist - Title 01:23 (trailing timestamp, YouTube style) +/// Artist - Title +/// 07. Title +/// ID - ID (unknown-track placeholder) +/// Some words (title only) +/// ``` +/// +/// A timestamp anywhere makes the whole list a SETLIST: order plus where each +/// entry begins in the source recording. +pub fn parse_text(bytes: &[u8]) -> Result { + let mut warnings = Vec::new(); + let text = decode(bytes, &mut warnings); + let mut entries = Vec::new(); + for line in text.lines() { + let raw = line.trim(); + if raw.is_empty() || raw.len() > 4096 { + continue; + } + // Section headers people paste along ("Tracklist:", "--- Set 2 ---"). + let bare = raw.trim_matches(['-', '=', '*', '#', ' ']); + if bare.is_empty() || bare.eq_ignore_ascii_case("tracklist") || bare.eq_ignore_ascii_case("tracklist:") { + continue; + } + if entries.len() >= MAX_ENTRIES { + warnings.push(format!("Stopped after {MAX_ENTRIES} entries")); + break; + } + let mut rest = raw; + // Leading index: "12." / "12)" / "12 -" (max 4 digits). + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !digits.is_empty() && digits.len() <= 4 { + let after = &rest[digits.len()..]; + if let Some(stripped) = after + .strip_prefix('.') + .or_else(|| after.strip_prefix(')')) + { + rest = stripped.trim_start(); + } else if after.starts_with(" - ") && parse_timestamp(&digits).is_none() { + // "12 - Title" is an index only when 12 can't be a timestamp + // (bare minutes are never written without seconds). + rest = after[3..].trim_start(); + } + } + // Timestamp at the start: "[1:23:45]" "(01:23)" "01:23" "1:23:45". + let mut start_ms = None; + let unbracketed = rest + .strip_prefix('[') + .and_then(|r| r.split_once(']')) + .or_else(|| rest.strip_prefix('(').and_then(|r| r.split_once(')'))); + if let Some((ts, after)) = unbracketed { + if let Some(ms) = parse_timestamp(ts.trim()) { + start_ms = Some(ms); + rest = after.trim_start(); + } + } else { + let token = rest.split_whitespace().next().unwrap_or_default(); + if let Some(ms) = parse_timestamp(token) { + start_ms = Some(ms); + rest = rest[token.len()..].trim_start(); + } + } + // Trailing timestamp: "Artist - Title 1:23:45" / "… (01:23)". + if start_ms.is_none() { + let trimmed = rest.trim_end(); + if let Some(pos) = trimmed.rfind(char::is_whitespace) { + let tail = trimmed[pos..].trim().trim_matches(['(', ')', '[', ']']); + if let Some(ms) = parse_timestamp(tail) { + start_ms = Some(ms); + rest = trimmed[..pos].trim_end(); + } + } + } + rest = rest.trim_start_matches(['-', '–', '—', ' ']).trim(); + if rest.is_empty() { + continue; + } + let (artist, title) = split_artist_title(rest); + let is_id = rest.eq_ignore_ascii_case("id - id") || rest.eq_ignore_ascii_case("id — id"); + entries.push(ParsedEntry { + ordinal: entries.len() as u32, + title: if is_id { Some("ID".into()) } else { title.or_else(|| non_empty(rest)) }, + artist: if is_id { None } else { artist }, + album: None, + duration_secs: None, + location: None, + start_ms, + raw: clip(raw), + }); + } + Ok(ParsedPlaylist { + format: "text".into(), + title: None, + description: None, + entries, + warnings, + }) +} + +/// "1:23" → 83_000 ms; "1:23:45" → 5_025_000; rejects anything else. +fn parse_timestamp(s: &str) -> Option { + let parts: Vec<&str> = s.split(':').collect(); + if !(2..=3).contains(&parts.len()) { + return None; + } + let mut total: i64 = 0; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() || part.len() > 3 || !part.chars().all(|c| c.is_ascii_digit()) { + return None; + } + // Only the leading field may exceed 59. + let v: i64 = part.parse().ok()?; + if i > 0 && v > 59 { + return None; + } + total = total * 60 + v; + } + Some(total * 1000) +} + +// ---- writers --------------------------------------------------------------- + +/// What the writers consume: the export view of one playlist row. +pub struct ExportItem<'a> { + pub title: &'a str, + pub artist: &'a str, + pub album: Option<&'a str>, + pub duration_secs: Option, + pub path: Option<&'a str>, + pub start_ms: Option, + pub source_line: Option<&'a str>, + pub kind: &'a str, + pub note: Option<&'a str>, +} + +/// Line-oriented formats cannot hold a newline inside a value: a title +/// containing one would inject phantom lines (a crafted import could smuggle +/// a `File2=` into a later PLS export). Control characters flatten to spaces. +fn line_safe(s: &str) -> String { + s.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect() +} + +pub fn write_m3u8(items: &[ExportItem<'_>]) -> String { + let mut out = String::from("#EXTM3U\n"); + for item in items { + let display = line_safe(&if item.artist.is_empty() { + item.title.to_string() + } else { + format!("{} - {}", item.artist, item.title) + }); + let dur = item.duration_secs.map(|d| d as i64).unwrap_or(-1); + let _ = writeln!(out, "#EXTINF:{dur},{display}"); + match item.path { + Some(path) => { + let _ = writeln!(out, "{}", line_safe(path)); + } + None => { + // An unresolved row has no locator; a comment keeps its place + // visible instead of silently shrinking the list. + let _ = writeln!(out, "# unresolved: {display}"); + } + } + } + out +} + +/// PLS carries locators only, so unresolved rows cannot be represented — +/// they are skipped and the caller's UI owes the user that count. +pub fn write_pls(items: &[ExportItem<'_>]) -> String { + let mut out = String::from("[playlist]\n"); + let mut n = 0u32; + for item in items { + let Some(path) = item.path else { continue }; + n += 1; + let display = line_safe(&if item.artist.is_empty() { + item.title.to_string() + } else { + format!("{} - {}", item.artist, item.title) + }); + let _ = writeln!(out, "File{n}={}", line_safe(path)); + let _ = writeln!(out, "Title{n}={display}"); + let _ = writeln!( + out, + "Length{n}={}", + item.duration_secs.map(i64::from).unwrap_or(-1) + ); + } + let _ = writeln!(out, "NumberOfEntries={n}"); + out.push_str("Version=2\n"); + out +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Windows path → file URI for XSPF `location`. +fn path_to_file_uri(path: &str) -> String { + let slashes = path.replace('\\', "/"); + let mut encoded = String::with_capacity(slashes.len() + 8); + for c in slashes.chars() { + match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '/' | ':' | '.' | '-' | '_' | '~' | '(' | ')' + | '\'' | '!' | '&' | ',' | '=' | '+' | '$' | '@' | ';' => encoded.push(c), + ' ' => encoded.push_str("%20"), + other => { + let mut buf = [0u8; 4]; + for b in other.encode_utf8(&mut buf).as_bytes() { + let _ = write!(encoded, "%{b:02X}"); + } + } + } + } + format!("file:///{encoded}") +} + +pub fn write_xspf(title: &str, description: Option<&str>, items: &[ExportItem<'_>]) -> String { + let mut out = String::from("\n"); + out.push_str("\n"); + let _ = writeln!(out, " {}", xml_escape(title)); + if let Some(d) = description { + let _ = writeln!(out, " {}", xml_escape(d)); + } + out.push_str(" \n"); + for item in items { + out.push_str(" \n"); + if let Some(path) = item.path { + let _ = writeln!(out, " {}", xml_escape(&path_to_file_uri(path))); + } + let _ = writeln!(out, " {}", xml_escape(item.title)); + if !item.artist.is_empty() { + let _ = writeln!(out, " {}", xml_escape(item.artist)); + } + if let Some(album) = item.album { + let _ = writeln!(out, " {}", xml_escape(album)); + } + if let Some(secs) = item.duration_secs { + let _ = writeln!(out, " {}", secs as u64 * 1000); + } + out.push_str(" \n"); + } + out.push_str(" \n\n"); + out +} + +/// Quote per RFC 4180 and guard formula-leading cells (OWASP CSV injection): +/// a leading `=`, `+`, `-` or `@` gains a `'` prefix, stripped on re-import. +fn csv_cell(s: &str) -> String { + let guarded = if s.starts_with(['=', '+', '-', '@']) { + format!("'{s}") + } else { + s.to_string() + }; + if guarded.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", guarded.replace('"', "\"\"")) + } else { + guarded + } +} + +pub fn write_csv(playlist_name: &str, items: &[ExportItem<'_>]) -> String { + let mut out = String::from( + "record_type,position,title,artist,album,duration_secs,path,start_ms,kind,note\n", + ); + let _ = writeln!(out, "playlist,,{},,,,,,,", csv_cell(playlist_name)); + for (position, item) in items.iter().enumerate() { + let _ = writeln!( + out, + "entry,{position},{},{},{},{},{},{},{},{}", + csv_cell(item.title), + csv_cell(item.artist), + csv_cell(item.album.unwrap_or("")), + item.duration_secs.map(|d| d.to_string()).unwrap_or_default(), + csv_cell(item.path.unwrap_or("")), + item.start_ms.map(|v| v.to_string()).unwrap_or_default(), + item.kind, + csv_cell(item.note.unwrap_or("")), + ); + } + out +} + +pub fn write_soulstream_json( + name: &str, + description: Option<&str>, + kind: &str, + items: &[ExportItem<'_>], +) -> String { + let items: Vec = items + .iter() + .map(|item| { + serde_json::json!({ + "title": item.title, + "artist": item.artist, + "album": item.album, + "durationSecs": item.duration_secs, + "path": item.path, + "startMs": item.start_ms, + "sourceLine": item.source_line, + "kind": item.kind, + "note": item.note, + }) + }) + .collect(); + let doc = serde_json::json!({ + "format": SOULSTREAM_JSON_FORMAT, + "version": 1, + "name": name, + "description": description, + "kind": kind, + "items": items, + }); + serde_json::to_string_pretty(&doc).unwrap_or_default() +} + +// ---- tests ----------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extended_m3u_with_bom_crlf_and_duplicates() { + let src = "\u{feff}#EXTM3U\r\n#PLAYLIST:Road Mix\r\n#EXTINF:245,Radiohead - Airbag\r\nC:\\Music\\01 Airbag.flac\r\n#EXTINF:-1,Unknown Stream\r\nrelative\\02 - Paranoid Android.mp3\r\nC:\\Music\\01 Airbag.flac\r\n#EXTSOMETHING:ignored\r\n"; + let p = parse("mix.m3u8", src.as_bytes()).unwrap(); + assert_eq!(p.format, "m3u"); + assert_eq!(p.title.as_deref(), Some("Road Mix")); + assert_eq!(p.entries.len(), 3); + assert_eq!(p.entries[0].artist.as_deref(), Some("Radiohead")); + assert_eq!(p.entries[0].title.as_deref(), Some("Airbag")); + assert_eq!(p.entries[0].duration_secs, Some(245)); + assert_eq!(p.entries[1].title.as_deref(), Some("Unknown Stream")); + assert_eq!(p.entries[1].duration_secs, None, "-1 means unknown"); + // The duplicate stays a distinct occurrence; bare line gets a stem title. + assert_eq!(p.entries[2].title.as_deref(), Some("Airbag")); + assert_eq!(p.entries[2].location.as_deref(), Some("C:\\Music\\01 Airbag.flac")); + } + + #[test] + fn hls_manifests_are_refused() { + let src = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\nseg0.ts\n"; + assert!(parse("stream.m3u8", src.as_bytes()).is_err()); + } + + #[test] + fn legacy_encoding_falls_back_with_warning() { + // "Café" in Windows-1252 — 0xE9 is not valid UTF-8. + let src = b"#EXTM3U\n#EXTINF:100,Caf\xe9 - Song\nC:\\m\\caf\xe9.mp3\n"; + let p = parse("old.m3u", src).unwrap(); + assert!(!p.warnings.is_empty()); + assert_eq!(p.entries[0].artist.as_deref(), Some("Café")); + assert_eq!(p.entries[0].location.as_deref(), Some("C:\\m\\café.mp3")); + } + + #[test] + fn pls_with_sparse_and_out_of_order_indices() { + let src = "[playlist]\nFile3=C:\\m\\three.mp3\nTitle3=C - Three\nFile1=C:\\m\\one.mp3\nLength1=200\nTitle1=A - One\nNumberOfEntries=9\nVersion=2\n"; + let p = parse("list.pls", src.as_bytes()).unwrap(); + assert_eq!(p.entries.len(), 2, "NumberOfEntries is not trusted"); + assert_eq!(p.entries[0].title.as_deref(), Some("One")); + assert_eq!(p.entries[0].artist.as_deref(), Some("A")); + assert_eq!(p.entries[0].duration_secs, Some(200)); + assert_eq!(p.entries[1].title.as_deref(), Some("Three")); + } + + #[test] + fn xspf_roundtrip_and_file_uris() { + let items = [ + ExportItem { + title: "Airbag & Co", artist: "Radiohead", album: Some("OK Computer"), + duration_secs: Some(245), path: Some("C:\\Mu sic\\01 Airbag.flac"), + start_ms: None, source_line: None, kind: "local", note: None, + }, + ExportItem { + title: "Ghost", artist: "Nobody", album: None, duration_secs: None, + path: None, start_ms: None, source_line: None, kind: "unresolved", note: None, + }, + ]; + let xml = write_xspf("My ", Some("desc"), &items); + let p = parse("list.xspf", xml.as_bytes()).unwrap(); + assert_eq!(p.title.as_deref(), Some("My ")); + assert_eq!(p.description.as_deref(), Some("desc")); + assert_eq!(p.entries.len(), 2); + assert_eq!(p.entries[0].title.as_deref(), Some("Airbag & Co")); + assert_eq!(p.entries[0].artist.as_deref(), Some("Radiohead")); + assert_eq!(p.entries[0].album.as_deref(), Some("OK Computer")); + assert_eq!(p.entries[0].duration_secs, Some(245)); + assert_eq!( + p.entries[0].location.as_deref(), + Some("C:\\Mu sic\\01 Airbag.flac"), + "file URI decodes back to the Windows path" + ); + assert_eq!(p.entries[1].location, None, "unresolved rows keep their place"); + } + + #[test] + fn xspf_with_dtd_is_refused() { + let src = "]>"; + assert!(parse("evil.xspf", src.as_bytes()).is_err()); + } + + #[test] + fn csv_roundtrip_with_quotes_and_formula_guard() { + let items = [ + ExportItem { + title: "=SUM(A1)", artist: "Quote \"Man\"", album: Some("With, comma"), + duration_secs: Some(90), path: Some("C:\\m\\a.mp3"), + start_ms: Some(61_000), source_line: None, kind: "local", note: None, + }, + ]; + let csv = write_csv("My list", &items); + let p = parse("list.csv", csv.as_bytes()).unwrap(); + assert_eq!(p.entries.len(), 1); + assert_eq!(p.entries[0].title.as_deref(), Some("=SUM(A1)"), "guard strips on import"); + assert_eq!(p.entries[0].artist.as_deref(), Some("Quote \"Man\"")); + assert_eq!(p.entries[0].album.as_deref(), Some("With, comma")); + assert_eq!(p.entries[0].duration_secs, Some(90)); + assert_eq!(p.entries[0].start_ms, Some(61_000)); + } + + #[test] + fn soulstream_json_roundtrip_is_lossless() { + let items = [ + ExportItem { + title: "Opener", artist: "DJ", album: None, duration_secs: Some(240), + path: None, start_ms: Some(0), source_line: Some("00:00 DJ - Opener"), + kind: "unresolved", note: Some("white label"), + }, + ]; + let json = write_soulstream_json("Warehouse set", Some("Rip"), "setlist", &items); + let p = parse("set.json", json.as_bytes()).unwrap(); + assert_eq!(p.format, "soulstream"); + assert_eq!(p.title.as_deref(), Some("Warehouse set")); + assert_eq!(p.entries[0].start_ms, Some(0)); + assert_eq!(p.entries[0].raw, "00:00 DJ - Opener"); + } + + #[test] + fn unversioned_json_is_refused() { + assert!(parse("x.json", b"{\"items\":[]}").is_err()); + } + + #[test] + fn text_tracklist_variants() { + let src = "Tracklist:\n00:00 Bicep - Glue\n[05:30] Overmono — So U Kno (Remix)\n3. Four Tet - Baby\nID - ID\nDJ Seinfeld - U 1:02:03\nJust A Title\n"; + let p = parse_text(src.as_bytes()).unwrap(); + assert_eq!(p.entries.len(), 6); + assert_eq!(p.entries[0].start_ms, Some(0)); + assert_eq!(p.entries[0].artist.as_deref(), Some("Bicep")); + assert_eq!(p.entries[0].title.as_deref(), Some("Glue")); + assert_eq!(p.entries[1].start_ms, Some(330_000)); + assert_eq!(p.entries[1].title.as_deref(), Some("So U Kno (Remix)")); + assert_eq!(p.entries[2].artist.as_deref(), Some("Four Tet"), "leading index stripped"); + assert_eq!(p.entries[2].start_ms, None); + assert_eq!(p.entries[3].title.as_deref(), Some("ID"), "unknown placeholder"); + assert_eq!(p.entries[4].start_ms, Some(3_723_000), "trailing timestamp"); + assert_eq!(p.entries[4].artist.as_deref(), Some("DJ Seinfeld")); + assert_eq!(p.entries[5].title.as_deref(), Some("Just A Title")); + } + + #[test] + fn m3u8_writer_roundtrips_and_keeps_unresolved_visible() { + let items = [ + ExportItem { + title: "One", artist: "A", album: None, duration_secs: Some(100), + path: Some("C:\\m\\one.mp3"), start_ms: None, source_line: None, + kind: "local", note: None, + }, + ExportItem { + title: "Ghost", artist: "B", album: None, duration_secs: None, + path: None, start_ms: None, source_line: None, kind: "unresolved", note: None, + }, + ]; + let m3u = write_m3u8(&items); + assert!(m3u.contains("# unresolved: B - Ghost")); + let p = parse("out.m3u8", m3u.as_bytes()).unwrap(); + assert_eq!(p.entries.len(), 1, "the unresolved comment is not an entry"); + assert_eq!(p.entries[0].title.as_deref(), Some("One")); + } + + #[test] + fn pls_writer_roundtrips_and_skips_unresolved() { + let items = [ + ExportItem { + title: "One", artist: "A", album: None, duration_secs: Some(100), + path: Some("C:\\m\\one.mp3"), start_ms: None, source_line: None, + kind: "local", note: None, + }, + ExportItem { + title: "Ghost", artist: "B", album: None, duration_secs: None, + path: None, start_ms: None, source_line: None, kind: "unresolved", note: None, + }, + ExportItem { + title: "Two", artist: "A", album: None, duration_secs: None, + path: Some("C:\\m\\two.mp3"), start_ms: None, source_line: None, + kind: "local", note: None, + }, + ]; + let pls = write_pls(&items); + assert!(pls.contains("NumberOfEntries=2")); + let parsed = parse("out.pls", pls.as_bytes()).unwrap(); + assert_eq!(parsed.entries.len(), 2); + assert_eq!(parsed.entries[0].title.as_deref(), Some("One")); + assert_eq!(parsed.entries[0].artist.as_deref(), Some("A")); + assert_eq!(parsed.entries[0].duration_secs, Some(100)); + assert_eq!(parsed.entries[1].location.as_deref(), Some("C:\\m\\two.mp3")); + assert_eq!(parsed.entries[1].duration_secs, None, "-1 stays unknown"); + } + + #[test] + fn oversized_sources_are_refused() { + let big = vec![b'a'; MAX_SOURCE_BYTES + 1]; + assert!(parse("big.m3u", &big).is_err()); + } +} diff --git a/src-tauri/src/playlists.rs b/src-tauri/src/playlists.rs new file mode 100644 index 0000000..559896f --- /dev/null +++ b/src-tauri/src/playlists.rs @@ -0,0 +1,1279 @@ +//! Playlist commands: CRUD over the occurrence model, covers (custom + +//! composed mosaic), file import with staged preview, and export profiles. +//! +//! Boundaries, per the architecture audit: parsing happens in +//! `playlist_formats` (pure, bounded), resolution against the library happens +//! here in memory off one index read, and every accepted mutation is a single +//! repository transaction. Nothing in this module holds the database lock +//! across a dialog, a decode, or a network call. + +use std::path::{Path, PathBuf}; + +use library::NewPlaylistItem; +use tauri::{Emitter, State}; + +use crate::pipeline::LibraryState; +use crate::playlist_formats::{self, ExportItem, ParsedEntry}; + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn changed(app: &tauri::AppHandle) { + let _ = app.emit("soulseek:playlists-changed", ()); +} + +/// The library-owned home of managed playlist covers. Lives inside the +/// library folder: a chosen cover is a decision about this library and +/// travels with it. +fn covers_dir(app: &tauri::AppHandle) -> Result { + let cfg = crate::config::load(app); + let dir = crate::config::ensure_library_data_dir(app, &cfg) + .map_err(|e| e.to_string())? + .join("playlist-covers"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +// ---- list / meta / items --------------------------------------------------- + +#[tauri::command] +pub async fn list_playlists( + lib: State<'_, LibraryState>, +) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.list_playlists().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? +} + +/// One playlist row as the page renders it. `missing` is computed at read +/// time — a row whose file has vanished keeps its kind ("local") and its +/// place; the flag is presentation, not stored state. +#[derive(serde::Serialize)] +pub struct PlaylistItemPayload { + #[serde(flatten)] + pub item: library::PlaylistItem, + pub missing: bool, +} + +#[tauri::command] +pub async fn playlist_items( + lib: State<'_, LibraryState>, + id: i64, +) -> Result, String> { + let l = lib.library.clone(); + // The existence sweep rides the same blocking task as the DB read (after + // the lock is released): a stat can block for seconds on a dead network + // path, and the async runtime must never eat that. + tauri::async_runtime::spawn_blocking(move || { + let rows = l.playlist_items(id).map_err(|e| e.to_string())?; + Ok(rows + .into_iter() + .map(|item| { + let missing = item + .path + .as_deref() + .is_some_and(|p| !Path::new(p).is_file()); + PlaylistItemPayload { item, missing } + }) + .collect()) + }) + .await + .map_err(|e| e.to_string())? +} + +// ---- create / metadata mutations ------------------------------------------- + +#[tauri::command] +pub async fn create_playlist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + name: String, + kind: Option, +) -> Result { + let l = lib.library.clone(); + let kind = kind.unwrap_or_else(|| "manual".into()); + let id = tauri::async_runtime::spawn_blocking(move || { + l.create_playlist(&name, &kind, now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(id) +} + +#[tauri::command] +pub async fn rename_playlist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, + name: String, +) -> Result<(), String> { + let trimmed = name.trim().to_string(); + if trimmed.is_empty() { + return Err("A playlist needs a name".into()); + } + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.rename_playlist(id, &trimmed, now_secs())) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(()) +} + +#[tauri::command] +pub async fn set_playlist_description( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, + description: Option, +) -> Result<(), String> { + let l = lib.library.clone(); + let desc = description.map(|d| d.trim().to_string()).filter(|d| !d.is_empty()); + tauri::async_runtime::spawn_blocking(move || { + l.set_playlist_description(id, desc.as_deref(), now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(()) +} + +#[tauri::command] +pub async fn set_playlist_pinned( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, + pinned: bool, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.set_playlist_pinned(id, pinned, now_secs())) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(()) +} + +#[tauri::command] +pub async fn duplicate_playlist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, + name: String, +) -> Result { + let l = lib.library.clone(); + let new_id = tauri::async_runtime::spawn_blocking(move || { + l.duplicate_playlist(id, &name, now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + // A custom cover is copied, not shared: cover files are keyed by playlist + // id, and deleting the original must not tear the copy's cover away. + if let Ok(dir) = covers_dir(&app) { + let src = dir.join(format!("{id}.jpg")); + if src.is_file() { + let target = dir.join(format!("{new_id}.jpg")); + if std::fs::copy(&src, &target).is_ok() { + let l = lib.library.clone(); + let stored = target.to_string_lossy().into_owned(); + let _ = tauri::async_runtime::spawn_blocking(move || { + l.set_playlist_cover(new_id, Some(&stored), now_secs()) + }) + .await; + } + } + } + changed(&app); + Ok(new_id) +} + +#[tauri::command] +pub async fn delete_playlist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + // The cover files belong to the row that just went away. + if let Ok(dir) = covers_dir(&app) { + remove_cover_files(&dir, id); + } + changed(&app); + Ok(()) +} + +fn remove_cover_files(dir: &Path, id: i64) { + if let Ok(entries) = std::fs::read_dir(dir) { + let prefix = format!("{id}-"); + let exact = format!("{id}.jpg"); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name == exact || name.starts_with(&prefix) { + let _ = std::fs::remove_file(entry.path()); + } + } + } +} + +// ---- item mutations -------------------------------------------------------- + +/// What UI surfaces send when adding rows (album page, Now Playing, repair, +/// and the page's undo — which restores setlist timing and provenance too). +#[derive(serde::Deserialize)] +pub struct AddItemInput { + pub title: String, + pub artist: Option, + pub album: Option, + pub duration_secs: Option, + pub path: Option, + pub art_path: Option, + #[serde(default)] + pub start_ms: Option, + #[serde(default)] + pub source_line: Option, + #[serde(default)] + pub note: Option, +} + +#[tauri::command] +pub async fn playlist_add_items( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + playlist_id: i64, + items: Vec, + at: Option, +) -> Result { + let l = lib.library.clone(); + let rows: Vec = items + .into_iter() + .map(|t| NewPlaylistItem { + title: t.title, + artist: t.artist.unwrap_or_default(), + album: t.album, + duration_secs: t.duration_secs, + path: t.path, + art_path: t.art_path, + start_ms: t.start_ms, + source_line: t.source_line, + note: t.note, + ..Default::default() + }) + .collect(); + let added = tauri::async_runtime::spawn_blocking(move || { + l.add_playlist_items(playlist_id, &rows, at, now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(added) +} + +#[tauri::command] +pub async fn playlist_remove_items( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + playlist_id: i64, + item_ids: Vec, +) -> Result { + let l = lib.library.clone(); + let removed = tauri::async_runtime::spawn_blocking(move || { + l.remove_playlist_items(playlist_id, &item_ids, now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(removed) +} + +#[tauri::command] +pub async fn playlist_set_order( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + playlist_id: i64, + ordered_ids: Vec, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.set_playlist_order(playlist_id, &ordered_ids, now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(()) +} + +/// Repair one row — attach a found local file or corrected metadata. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn playlist_relink_item( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + playlist_id: i64, + item_id: i64, + title: Option, + artist: Option, + album: Option, + duration_secs: Option, + path: Option, + art_path: Option, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.relink_playlist_item( + playlist_id, + item_id, + title.as_deref(), + artist.as_deref(), + album.as_deref(), + duration_secs, + path.as_deref(), + art_path.as_deref(), + now_secs(), + ) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(()) +} + +// ---- covers ---------------------------------------------------------------- + +/// Managed master size for a custom cover; big enough for the page header at +/// 2x, small enough to compose and ship as a data URL instantly. +const COVER_MASTER_EDGE: u32 = 1024; +const MOSAIC_EDGE: u32 = 512; + +/// Pick a custom cover image. Validates by decoding, center-crops square, +/// normalizes to a JPEG master under the managed covers dir, points the +/// playlist at it. Returns the new cover as a data URL (or None on cancel). +#[tauri::command] +pub async fn playlist_cover_pick( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("Image", &["jpg", "jpeg", "png", "webp"]) + .pick_file(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + let Some(source) = rx.await.unwrap_or(None) else { + return Ok(None); + }; + let dir = covers_dir(&app)?; + let target = dir.join(format!("{id}.jpg")); + let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result, String> { + let bytes = std::fs::read(&source).map_err(|e| e.to_string())?; + if bytes.len() > 64 * 1024 * 1024 { + return Err("Image is unreasonably large".into()); + } + let img = image::load_from_memory(&bytes).map_err(|e| format!("Not a usable image: {e}"))?; + Ok(square_jpeg(&img, COVER_MASTER_EDGE)) + }) + .await + .map_err(|e| e.to_string())??; + let temp = dir.join(format!("{id}.importing")); + std::fs::write(&temp, &jpeg).map_err(|e| e.to_string())?; + std::fs::rename(&temp, &target).map_err(|e| e.to_string())?; + let l = lib.library.clone(); + let stored = target.to_string_lossy().into_owned(); + tauri::async_runtime::spawn_blocking(move || { + l.set_playlist_cover(id, Some(&stored), now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(Some(data_url_jpeg(&jpeg))) +} + +#[tauri::command] +pub async fn playlist_cover_clear( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.set_playlist_cover(id, None, now_secs())) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + if let Ok(dir) = covers_dir(&app) { + let _ = std::fs::remove_file(dir.join(format!("{id}.jpg"))); + } + changed(&app); + Ok(()) +} + +/// The cover a surface should draw for one playlist, as a data URL: +/// the custom master when chosen, otherwise a mosaic composed from the first +/// four distinct album covers, otherwise None (the UI draws its gradient). +/// Mosaics are cached on disk keyed by the seed set, so a reorder that does +/// not change the four covers costs a file read. +#[tauri::command] +pub async fn playlist_cover( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, + max_edge: Option, +) -> Result, String> { + let l = lib.library.clone(); + let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let Some(meta) = meta else { return Ok(None) }; + let edge = max_edge.unwrap_or(MOSAIC_EDGE).clamp(64, COVER_MASTER_EDGE); + let dir = covers_dir(&app)?; + // Every file read, decode and compose happens off the async runtime — a + // cover on an unplugged drive can block for seconds per call. + tauri::async_runtime::spawn_blocking(move || -> Result, String> { + // Custom cover wins outright. + if let Some(cover) = meta.cover_path.as_deref() { + if let Ok(bytes) = std::fs::read(cover) { + let out = match crate::pipeline::thumbnail_jpeg(&bytes, edge) { + Ok(Some(thumb)) => thumb, + _ => bytes, + }; + return Ok(Some(data_url_jpeg(&out))); + } + } + if meta.art_seeds.is_empty() { + return Ok(None); + } + let key = mosaic_key(&meta.art_seeds); + let cached = dir.join(format!("{id}-mosaic-{key}.jpg")); + let bytes = if cached.is_file() { + std::fs::read(&cached).map_err(|e| e.to_string())? + } else { + let Some(out) = compose_mosaic(&meta.art_seeds) else { + return Ok(None); + }; + // Retire stale mosaics for this playlist, then commit the new one. + if let Ok(entries) = std::fs::read_dir(&dir) { + let prefix = format!("{id}-mosaic-"); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with(&prefix) { + let _ = std::fs::remove_file(entry.path()); + } + } + } + let temp = dir.join(format!("{id}-mosaic-{key}.importing")); + if std::fs::write(&temp, &out).is_ok() { + let _ = std::fs::rename(&temp, &cached); + } + out + }; + let out = match crate::pipeline::thumbnail_jpeg(&bytes, edge) { + Ok(Some(thumb)) => thumb, + _ => bytes, + }; + Ok(Some(data_url_jpeg(&out))) + }) + .await + .map_err(|e| e.to_string())? +} + +fn data_url_jpeg(bytes: &[u8]) -> String { + use base64::Engine as _; + format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) +} + +fn mosaic_key(seeds: &[String]) -> String { + use std::hash::{Hash as _, Hasher as _}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in seeds { + s.hash(&mut h); + } + format!("{:016x}", h.finish()) +} + +/// Center-crop square + Lanczos3 to `edge`, JPEG q92. +fn square_jpeg(img: &image::DynamicImage, edge: u32) -> Vec { + let (w, h) = (img.width(), img.height()); + let side = w.min(h); + let cropped = img.crop_imm((w - side) / 2, (h - side) / 2, side, side); + let scaled = if side > edge { + cropped.resize_exact(edge, edge, image::imageops::FilterType::Lanczos3) + } else { + cropped + }; + let mut out = std::io::Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92); + let _ = scaled.into_rgb8().write_with_encoder(encoder); + out.into_inner() +} + +/// Deterministic 2×2 mosaic from up to four covers, in playlist order. +/// One cover fills the square; two alternate diagonally; three repeat the +/// first. Unreadable seeds are skipped; zero readable seeds composes nothing. +fn compose_mosaic(seeds: &[String]) -> Option> { + let tiles: Vec = seeds + .iter() + .filter_map(|p| image::open(p).ok()) + .take(4) + .collect(); + if tiles.is_empty() { + return None; + } + if tiles.len() == 1 { + return Some(square_jpeg(&tiles[0], MOSAIC_EDGE)); + } + let half = MOSAIC_EDGE / 2; + let layout: [usize; 4] = match tiles.len() { + 2 => [0, 1, 1, 0], + 3 => [0, 1, 2, 0], + _ => [0, 1, 2, 3], + }; + let mut canvas = image::RgbImage::new(MOSAIC_EDGE, MOSAIC_EDGE); + for (slot, &tile_index) in layout.iter().enumerate() { + let tile = &tiles[tile_index]; + let (w, h) = (tile.width(), tile.height()); + let side = w.min(h); + let square = tile + .crop_imm((w - side) / 2, (h - side) / 2, side, side) + .resize_exact(half, half, image::imageops::FilterType::Lanczos3) + .into_rgb8(); + let (dx, dy) = ((slot as u32 % 2) * half, (slot as u32 / 2) * half); + image::imageops::replace(&mut canvas, &square, dx as i64, dy as i64); + } + let mut out = std::io::Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92); + image::DynamicImage::ImageRgb8(canvas) + .into_rgb8() + .write_with_encoder(encoder) + .ok()?; + Some(out.into_inner()) +} + +// ---- import ---------------------------------------------------------------- + +/// One staged import row, resolution decided, awaiting user review. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct DraftEntry { + pub ordinal: u32, + pub title: String, + pub artist: String, + pub album: Option, + pub duration_secs: Option, + pub start_ms: Option, + pub raw: String, + /// "local" (matched a library file) | "unresolved". + pub resolution: String, + /// How the local match was made: "path" | "relinked" | "tags" — evidence + /// the preview shows so an exact path match reads differently from a + /// name-based one. + pub matched_by: Option, + pub path: Option, + pub art_path: Option, +} + +#[derive(serde::Serialize)] +pub struct ImportDraft { + pub source_name: String, + pub format: String, + pub name: Option, + pub description: Option, + /// "setlist" when any entry carries a source timestamp. + pub kind: String, + pub entries: Vec, + pub warnings: Vec, + pub resolved_count: u32, + pub unresolved_count: u32, +} + +/// Open a playlist file and stage it for review. Parsing and resolution only — +/// nothing is written until `playlist_import_commit`. +#[tauri::command] +pub async fn playlist_import_probe( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter( + "Playlist", + &["m3u", "m3u8", "pls", "xspf", "csv", "json", "txt"], + ) + .pick_file(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + let Some(source) = rx.await.unwrap_or(None) else { + return Ok(None); + }; + let source_path = PathBuf::from(&source); + let name = source_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| source.clone()); + // Size-check before reading, and read off the async runtime — a playlist + // picked from a dead network share must not hang the whole backend. + let read_path = source_path.clone(); + let bytes = tauri::async_runtime::spawn_blocking(move || -> Result, String> { + let len = std::fs::metadata(&read_path).map_err(|e| e.to_string())?.len(); + if len as usize > playlist_formats::MAX_SOURCE_BYTES { + return Err(format!("{} MB is too large for a playlist file", len / (1024 * 1024))); + } + std::fs::read(&read_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + let parsed = playlist_formats::parse(&name, &bytes)?; + let base_dir = source_path.parent().map(Path::to_path_buf); + let draft = resolve_draft(&lib, parsed, name, base_dir).await?; + Ok(Some(draft)) +} + +/// Stage a picked FOLDER of audio files as a playlist draft — the product +/// doc's "directory as playlist". Order comes from the files' own tag +/// track/disc numbers when every file carries a distinct one, otherwise +/// natural filename order (digit runs compared as numbers — the import +/// review's own lesson: byte order reads 10 before 2). +#[tauri::command] +pub async fn playlist_import_folder( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog().file().pick_folder(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + let Some(root) = rx.await.unwrap_or(None) else { + return Ok(None); + }; + let root_path = PathBuf::from(&root); + let folder_name = root_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Folder".into()); + + let l = lib.library.clone(); + let index = tauri::async_runtime::spawn_blocking(move || l.track_index()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let mut by_path: std::collections::HashMap = Default::default(); + for (i, row) in index.iter().enumerate() { + by_path.insert(row.path.trim().replace('/', "\\").to_lowercase(), i); + } + + // Walk + tag-read off the runtime: a big folder is hundreds of file + // opens, and any of them can sit on slow storage. + let scan_root = root_path.clone(); + let mut files = tauri::async_runtime::spawn_blocking(move || { + let mut found: Vec = Vec::new(); + collect_folder_audio(&scan_root, &mut found, 0); + // A junction inside the folder can re-surface the same files under a + // second spelling; canonical identity collapses them to one row each. + let mut seen = std::collections::HashSet::new(); + found.retain(|p| { + let key = p + .canonicalize() + .map(|c| c.to_string_lossy().to_lowercase()) + .unwrap_or_else(|_| p.to_string_lossy().to_lowercase()); + seen.insert(key) + }); + found.sort_by(|a, b| natural_cmp(&a.to_string_lossy(), &b.to_string_lossy())); + found.truncate(2_000); + let mut rows = Vec::with_capacity(found.len()); + for path in found { + let summary = tagger::read_summary(&path).unwrap_or_default(); + rows.push((path, summary)); + } + rows + }) + .await + .map_err(|e| e.to_string())?; + if files.is_empty() { + return Err("No audio files in that folder".into()); + } + + // Tag order only when it is COHERENT: every file numbered, all + // (disc, track) pairs distinct. Otherwise the natural name order stands. + let all_numbered = files.iter().all(|(_, s)| s.track_no.is_some()); + if all_numbered { + let mut keys: Vec<(u32, u32)> = files + .iter() + .map(|(_, s)| (s.disc_no.unwrap_or(1), s.track_no.unwrap_or(0))) + .collect(); + keys.sort_unstable(); + keys.dedup(); + if keys.len() == files.len() { + files.sort_by_key(|(_, s)| (s.disc_no.unwrap_or(1), s.track_no.unwrap_or(0))); + } + } + + let entries: Vec = files + .into_iter() + .enumerate() + .map(|(ordinal, (path, summary))| { + let path_string = path.to_string_lossy().into_owned(); + let indexed = by_path + .get(&path_string.trim().replace('/', "\\").to_lowercase()) + .map(|&i| &index[i]); + let stem_title = || { + path.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Unknown".into()) + }; + DraftEntry { + ordinal: ordinal as u32, + title: summary + .title + .clone() + .or_else(|| indexed.map(|r| r.title.clone())) + .unwrap_or_else(stem_title), + artist: summary + .artist + .clone() + .or_else(|| indexed.map(|r| r.artist.clone())) + .unwrap_or_default(), + album: summary + .album + .clone() + .or_else(|| indexed.map(|r| r.album.clone())), + duration_secs: summary + .duration_secs + .or_else(|| indexed.and_then(|r| r.duration_secs)), + start_ms: None, + raw: path_string.clone(), + resolution: "local".into(), + matched_by: Some(if indexed.is_some() { "path" } else { "file" }.into()), + path: Some(path_string), + art_path: indexed.and_then(|r| r.art_path.clone()), + } + }) + .collect(); + let resolved = entries.len() as u32; + Ok(Some(ImportDraft { + source_name: folder_name.clone(), + format: "folder".into(), + name: Some(folder_name), + description: None, + kind: "manual".into(), + entries, + warnings: Vec::new(), + resolved_count: resolved, + unresolved_count: 0, + })) +} + +/// Audio files under `dir`, two levels deep — the folder itself plus disc +/// subfolders. Deeper nesting is somebody's whole collection, not one list; +/// the staging pen and the library's own state dir are skipped by name. +fn collect_folder_audio(dir: &Path, out: &mut Vec, depth: u8) { + if depth > 1 || out.len() >= 2_000 { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = entry.file_name(); + if name == "(Cache)" || name == crate::config::LIBRARY_DATA_DIRNAME { + continue; + } + collect_folder_audio(&path, out, depth + 1); + } else if audio_formats::is_audio_path(&path) { + out.push(path); + } + } +} + +/// Compare with digit runs as numbers: `2 - Song` before `10 - Song`. +/// Runs compare by (length after leading zeros, then digits) — no integer +/// conversion, so arbitrarily long runs order correctly. +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + let (a, b) = (a.to_lowercase(), b.to_lowercase()); + let take_run = |iter: &mut std::iter::Peekable| -> String { + let mut run = String::new(); + while let Some(&c) = iter.peek() { + if c.is_ascii_digit() { + run.push(c); + iter.next(); + } else { + break; + } + } + run + }; + let (mut ai, mut bi) = (a.chars().peekable(), b.chars().peekable()); + loop { + match (ai.peek().copied(), bi.peek().copied()) { + (None, None) => return std::cmp::Ordering::Equal, + (None, Some(_)) => return std::cmp::Ordering::Less, + (Some(_), None) => return std::cmp::Ordering::Greater, + (Some(x), Some(y)) => { + if x.is_ascii_digit() && y.is_ascii_digit() { + let xr = take_run(&mut ai); + let yr = take_run(&mut bi); + let xs = xr.trim_start_matches('0'); + let ys = yr.trim_start_matches('0'); + let ordering = xs + .len() + .cmp(&ys.len()) + .then_with(|| xs.cmp(ys)) + // Equal values: more leading zeros sorts first, and + // keeps the comparator total. + .then_with(|| xr.len().cmp(&yr.len())); + match ordering { + std::cmp::Ordering::Equal => {} + other => return other, + } + } else { + match x.cmp(&y) { + std::cmp::Ordering::Equal => { + ai.next(); + bi.next(); + } + other => return other, + } + } + } + } + } +} + +/// Stage a pasted tracklist (playlist or timestamped setlist) for review. +#[tauri::command] +pub async fn playlist_import_text( + lib: State<'_, LibraryState>, + text: String, +) -> Result { + if text.len() > playlist_formats::MAX_SOURCE_BYTES { + return Err("Pasted text is too large".into()); + } + let parsed = playlist_formats::parse_text(text.as_bytes())?; + resolve_draft(&lib, parsed, "Pasted tracklist".into(), None).await +} + +/// Commit a reviewed draft as a new playlist, atomically. +#[tauri::command] +pub async fn playlist_import_commit( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + name: String, + description: Option, + kind: String, + entries: Vec, +) -> Result { + let trimmed = name.trim().to_string(); + if trimmed.is_empty() { + return Err("The playlist needs a name".into()); + } + let kind = if kind == "setlist" { "setlist" } else { "manual" }; + let rows: Vec = entries + .into_iter() + .map(|e| NewPlaylistItem { + title: e.title, + artist: e.artist, + album: e.album, + duration_secs: e.duration_secs, + kind: if e.resolution == "local" && e.path.is_some() { + "local".into() + } else { + "unresolved".into() + }, + path: e.path.filter(|p| !p.is_empty()), + art_path: e.art_path, + source_line: (!e.raw.is_empty()).then_some(e.raw), + start_ms: e.start_ms, + note: None, + }) + .collect(); + let l = lib.library.clone(); + let desc = description.filter(|d| !d.trim().is_empty()); + let kind_owned = kind.to_string(); + let id = tauri::async_runtime::spawn_blocking(move || { + l.import_playlist(&trimmed, desc.as_deref(), &kind_owned, &rows, now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + changed(&app); + Ok(id) +} + +/// Resolve parsed entries against the local library, in memory. +/// +/// The ladder, strongest evidence first (identity rules: an exact locator +/// outranks names; a name match must be unique or it stays unresolved): +/// 1. the entry's own path, existing on disk; +/// 2. the same path re-rooted under the current library (drive moved); +/// 3. a unique basename match in the library index; +/// 4. a unique normalized (artist, title) tag match; +/// 5. a unique normalized title match, when the entry names no artist. +async fn resolve_draft( + lib: &State<'_, LibraryState>, + parsed: playlist_formats::ParsedPlaylist, + source_name: String, + base_dir: Option, +) -> Result { + let l = lib.library.clone(); + let index = tauri::async_runtime::spawn_blocking(move || l.track_index()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + let norm_path = |p: &str| p.trim().replace('/', "\\").to_lowercase(); + let mut by_path: std::collections::HashMap = Default::default(); + let mut by_base: std::collections::HashMap> = Default::default(); + let mut by_tags: std::collections::HashMap<(String, String), Vec> = Default::default(); + let mut by_title: std::collections::HashMap> = Default::default(); + for (i, row) in index.iter().enumerate() { + let np = norm_path(&row.path); + if let Some(base) = np.rsplit('\\').next() { + by_base.entry(base.to_string()).or_default().push(i); + } + by_path.insert(np, i); + by_tags + .entry((norm_text(&row.artist), norm_text(&row.title))) + .or_default() + .push(i); + by_title.entry(norm_text(&row.title)).or_default().push(i); + } + let unique = |hits: Option<&Vec>| -> Option { + match hits { + Some(v) if v.len() == 1 => Some(v[0]), + // Several copies of the SAME recording (re-rips, deluxe copies) + // still resolve — any of them plays this entry. Different + // recordings sharing a display name stay ambiguous. + Some(v) if v.len() > 1 => { + let first = &index[v[0]]; + v.iter() + .all(|&i| { + norm_text(&index[i].artist) == norm_text(&first.artist) + && norm_text(&index[i].title) == norm_text(&first.title) + }) + .then(|| v[0]) + } + _ => None, + } + }; + + let mut entries = Vec::new(); + let (mut resolved, mut unresolved) = (0u32, 0u32); + let mut any_start = false; + for entry in &parsed.entries { + let ParsedEntry { + ordinal, + title, + artist, + album, + duration_secs, + location, + start_ms, + raw, + } = entry; + any_start |= start_ms.is_some(); + let mut hit: Option<(usize, &'static str)> = None; + if let Some(loc) = location.as_deref() { + // Relative locators resolve from the playlist file's directory. + let absolute = if looks_relative(loc) { + base_dir + .as_deref() + .map(|d| d.join(loc).to_string_lossy().into_owned()) + } else { + Some(loc.to_string()) + }; + if let Some(abs) = absolute { + if let Some(&i) = by_path.get(&norm_path(&abs)) { + hit = Some((i, "path")); + } else if Path::new(&abs).is_file() { + // The file exists but is not in the library (outside the + // root). It still plays — treat as a resolved path-only row. + entries.push(DraftEntry { + ordinal: *ordinal, + title: title.clone().unwrap_or_else(|| "Unknown".into()), + artist: artist.clone().unwrap_or_default(), + album: album.clone(), + duration_secs: *duration_secs, + start_ms: *start_ms, + raw: raw.clone(), + resolution: "local".into(), + matched_by: Some("path".into()), + path: Some(abs), + art_path: None, + }); + resolved += 1; + continue; + } + } + if hit.is_none() { + // The library moved: match by basename when it names one file. + // But a locator is only a LOCATION — when the entry also names + // the track and the found file's tags disagree, the filename + // similarity is coincidence ("01 Track.mp3" exists everywhere) + // and the row stays unresolved rather than playing the wrong + // recording. + let base = norm_path(loc) + .rsplit('\\') + .next() + .unwrap_or_default() + .to_string(); + if !base.is_empty() { + if let Some(i) = unique(by_base.get(&base)) { + let agrees = match title.as_deref() { + Some(t) => { + let nt = norm_text(t); + let ct = norm_text(&index[i].title); + nt.is_empty() || ct.contains(&nt) || nt.contains(&ct) + } + None => true, + }; + if agrees { + hit = Some((i, "relinked")); + } + } + } + } + } + if hit.is_none() { + if let Some(t) = title.as_deref() { + let nt = norm_text(t); + if let Some(a) = artist.as_deref() { + if let Some(i) = unique(by_tags.get(&(norm_text(a), nt.clone()))) { + hit = Some((i, "tags")); + } + } else if nt != "id" && nt.chars().count() >= 3 { + // Title-only is the weakest evidence: never for "ID" (the + // DJ-culture unknown-track token — a library track that + // happens to be titled "ID" is NOT the unknown), and never + // for a name too short to mean anything. + if let Some(i) = unique(by_title.get(&nt)) { + hit = Some((i, "tags")); + } + } + } + } + match hit { + Some((i, how)) => { + let row = &index[i]; + resolved += 1; + entries.push(DraftEntry { + ordinal: *ordinal, + title: title.clone().unwrap_or_else(|| row.title.clone()), + artist: artist.clone().unwrap_or_else(|| row.artist.clone()), + album: album.clone().or_else(|| Some(row.album.clone())), + duration_secs: duration_secs.or(row.duration_secs), + start_ms: *start_ms, + raw: raw.clone(), + resolution: "local".into(), + matched_by: Some(how.into()), + path: Some(row.path.clone()), + art_path: row.art_path.clone(), + }); + } + None => { + unresolved += 1; + entries.push(DraftEntry { + ordinal: *ordinal, + title: title.clone().unwrap_or_else(|| "Unknown".into()), + artist: artist.clone().unwrap_or_default(), + album: album.clone(), + duration_secs: *duration_secs, + start_ms: *start_ms, + raw: raw.clone(), + resolution: "unresolved".into(), + matched_by: None, + path: None, + art_path: None, + }); + } + } + } + Ok(ImportDraft { + source_name, + format: parsed.format, + name: parsed.title, + description: parsed.description, + kind: if any_start { "setlist".into() } else { "manual".into() }, + entries, + warnings: parsed.warnings, + resolved_count: resolved, + unresolved_count: unresolved, + }) +} + +fn looks_relative(loc: &str) -> bool { + !(loc.contains("://") + || loc.starts_with('\\') + || loc.starts_with('/') + || loc.get(1..2) == Some(":")) +} + +/// ASCII-folded, lowercased, alphanumeric-only matching key — the same +/// normalization the frontend's local-match fallback uses. +fn norm_text(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + +/// Local-library track search for the unresolved-row repair panel ("Find +/// match…"). Word-wise normalized substring match over artist/title/album, +/// title hits first. Read-only, bounded to 30 rows. +#[tauri::command] +pub async fn library_track_search( + lib: State<'_, LibraryState>, + query: String, +) -> Result, String> { + let words: Vec = query + .split_whitespace() + .map(norm_text) + .filter(|w| !w.is_empty()) + .collect(); + if words.is_empty() { + return Ok(Vec::new()); + } + let l = lib.library.clone(); + let index = tauri::async_runtime::spawn_blocking(move || l.track_index()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let mut scored: Vec<(u32, library::TrackIndexRow)> = index + .into_iter() + .filter_map(|row| { + let title = norm_text(&row.title); + let hay = format!("{}{}{}", norm_text(&row.artist), &title, norm_text(&row.album)); + if !words.iter().all(|w| hay.contains(w.as_str())) { + return None; + } + let joined = words.concat(); + let score = if title == joined { + 0 + } else if title.starts_with(joined.as_str()) { + 1 + } else if words.iter().all(|w| title.contains(w.as_str())) { + 2 + } else { + 3 + }; + Some((score, row)) + }) + .collect(); + scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.title.cmp(&b.1.title))); + Ok(scored.into_iter().take(30).map(|(_, row)| row).collect()) +} + +// ---- export ---------------------------------------------------------------- + +/// Export a playlist through a save dialog. `format` ∈ m3u8 | xspf | csv | +/// json. Returns the written path, or None when the dialog was cancelled. +#[tauri::command] +pub async fn playlist_export( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, + format: String, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let l = lib.library.clone(); + let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())? + .ok_or("Playlist not found")?; + let l = lib.library.clone(); + let items = tauri::async_runtime::spawn_blocking(move || l.playlist_items(id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + let (ext, filter_name): (&str, &str) = match format.as_str() { + "m3u8" => ("m3u8", "M3U8 playlist"), + "xspf" => ("xspf", "XSPF playlist"), + "pls" => ("pls", "PLS playlist"), + "csv" => ("csv", "CSV"), + "json" => ("json", "SoulStream playlist JSON"), + other => return Err(format!("Unknown export format {other}")), + }; + let safe_name: String = meta + .name + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_file_name(format!("{safe_name}.{ext}")) + .add_filter(filter_name, &[ext]) + .save_file(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + let Some(dest) = rx.await.unwrap_or(None) else { + return Ok(None); + }; + + let export_items: Vec> = items + .iter() + .map(|i| ExportItem { + title: &i.title, + artist: &i.artist, + album: i.album.as_deref(), + duration_secs: i.duration_secs, + path: i.path.as_deref(), + start_ms: i.start_ms, + source_line: i.source_line.as_deref(), + kind: &i.kind, + note: i.note.as_deref(), + }) + .collect(); + let body = match format.as_str() { + "m3u8" => playlist_formats::write_m3u8(&export_items), + "xspf" => playlist_formats::write_xspf(&meta.name, meta.description.as_deref(), &export_items), + "pls" => playlist_formats::write_pls(&export_items), + "csv" => playlist_formats::write_csv(&meta.name, &export_items), + _ => playlist_formats::write_soulstream_json( + &meta.name, + meta.description.as_deref(), + &meta.kind, + &export_items, + ), + }; + // Write beside the destination, then move into place — a failed write + // never leaves a torn file under the chosen name. rename on Windows + // replaces an existing destination, so there is no unlink window. + let dest_path = PathBuf::from(&dest); + tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + let temp = dest_path.with_extension(format!("{ext}.writing")); + std::fs::write(&temp, body.as_bytes()).map_err(|e| e.to_string())?; + std::fs::rename(&temp, &dest_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(Some(dest)) +} diff --git a/src/App.svelte b/src/App.svelte index 83cd6c2..54f3b6d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,6 +4,8 @@ import PlayerBar from "./components/PlayerBar.svelte"; import NowPlaying from "./components/NowPlaying.svelte"; import ArtworkViewer from "./components/ArtworkViewer.svelte"; + import LyricsOverlay from "./components/LyricsOverlay.svelte"; + import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte"; import ImportWindow from "./components/ImportWindow.svelte"; import LoginModal from "./components/LoginModal.svelte"; import PreviewOverlay from "./components/PreviewOverlay.svelte"; @@ -126,6 +128,8 @@ + + diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index a949eb2..948fcac 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -76,6 +76,13 @@ "brand-bandcamp": '', "brand-soundcloud": '', "hard-drive": '', + pin: '', + // Drag handle for reorderable rows (six dots, reads at 14px). + grip: '', + export: '', + pencil: '', + "file-text": '', + link: '', }; diff --git a/src/components/ImportPicker.svelte b/src/components/ImportPicker.svelte index 2643229..414fd94 100644 --- a/src/components/ImportPicker.svelte +++ b/src/components/ImportPicker.svelte @@ -16,7 +16,7 @@ import { onDestroy } from "svelte"; import { getArtMaster, playLocalFile, player, togglePlayPause } from "../lib/state.svelte.js"; import { evidence as matchEvidence, normx } from "../lib/import-evidence.js"; - import { IDENTIFY_ENGINE } from "../lib/import-title.js"; + import { IDENTIFY_ENGINE, majority } from "../lib/import-title.js"; let { album: a, // derived album from ImportWindow (rel, title, …) @@ -101,14 +101,79 @@ (async () => { try { const ask = albumName ?? a.title; - const r = await api.importIdentify(artist, ask); + // The folder rides along as the second witness: `ask` is the + // tags' claim, and tags carry typos the folder does not + // ("Nonagon Infiniry" on all nine files). Asked after it, and + // deduped away when they agree. + // + // The SHAPE goes too — count, track titles, year, and where the + // files live. A name is only one fact about a record, and it is + // the one most often wrong; the discography pass compares the + // rest when no phrase can find it. + const shape = { + tracks: a.tracks?.length ?? null, + trackTitles: (a.tracks ?? []).map((t) => t.title).filter(Boolean), + year: a.year ?? null, + // `parts` bounds the artwork hash to the record's OWN audio. + // Without it the backend walked the folder recursively, and + // a record sharing a folder with another one hashed the + // neighbour's cover — see own_audio_files in import.rs. + sourceRoot, rel, parts: a.parts ?? [], + }; + let r = await api.importIdentify(artist, ask, a.title, shape); + let askedArtist = artist; + + // The FOLDER is asked first, and stays the default: it is one + // deliberate decision, where track artist tags carry featured + // guests, compilation contributors and classical performers. + // + // But a folder is sometimes not the artist at all — "Band Of + // Gypsys" holding six files that every one of them tags + // "Jimi Hendrix" — and then the ask is wrong twice over, + // because the try-list strips that "artist" off the front of + // the title and asks the providers for what is left of it. + // + // Only when the first ask came back with NOTHING, or with a + // record the judge calls wrong. A candidate that already + // stands is not this function's business. + const judge = (res, who) => (res?.candidate + ? matchEvidence(res.candidate, who, ask, a.tracks.length).verdict + : "none"); + if (["none", "bad"].includes(judge(r, artist))) { + const tagged = majority((a.tracks ?? []).map((t) => t.artist)); + if (tagged && normx(tagged) !== normx(artist)) { + const alt = await api.importIdentify(tagged, ask, a.title, shape); + // Taken only if it is actually better. A second wrong + // answer is not an improvement on the first. + if (["ok", "warn"].includes(judge(alt, tagged))) { + r = alt; + askedArtist = tagged; + } + } + } // Stamped with the engine that produced it: a cached "nothing // found" is only trustworthy while the thing that looked has // not learned anything new. See IDENTIFY_ENGINE. - found[rel] = { state: "done", result: r, engine: IDENTIFY_ENGINE }; + // `askedArtist` rides along so the judge compares against the + // name the provider was ASKED about — the §3.2 rule, which + // would otherwise call this record's own match a wrong one. + // A lookup that FAILED is not an answer about the record. MB's + // 1 req/s limiter makes a 503 routine, and filing one as "done" + // made "Murder of the Universe" a permanent deep cut — the + // record MB holds, scored 100, and gave up on the next ask. + // `persist()` only saves `done`, so an error re-asks; nothing + // else here treats the two alike. + if (r.incomplete && !r.candidate) { + found[rel] = { + state: "error", result: null, + error: "the provider did not answer — this record has not been looked up yet", + }; + return; + } + found[rel] = { state: "done", result: { ...r, askedArtist }, engine: IDENTIFY_ENGINE }; if (r.candidate?.cover) { const rep = await api.importCoverReport( - artist, r.used ?? ask, r.candidate.cover); + askedArtist, r.used ?? ask, r.candidate.cover); reports[rel] = rep; } } catch (e) { @@ -133,7 +198,10 @@ const rel = a.rel; if (localArts[rel] !== undefined) return; localArts[rel] = null; - api.importLocalArt(sourceRoot, rel).then( + // `parts` for the same reason apply takes them: the record's audio is + // its own folder and the parts it named, never a walk of what happens + // to sit underneath. + api.importLocalArt(sourceRoot, rel, a.parts ?? []).then( (v) => { localArts[rel] = v; }, () => {}); }); @@ -226,7 +294,10 @@ // an exact hit look doubtful: "Mirrored" against // "[WARP CD 156] Battles - Mirrored - 2007" is a containment, so a clean // match wore a "!" on its title row. - const ev = $derived(matchEvidence(candidate, artist, albumName ?? a.title, a.tracks.length)); + // Against the artist the provider was ASKED about — which is the folder + // unless the tags found the record where the folder could not. + const ev = $derived(matchEvidence( + candidate, idn?.result?.askedArtist ?? artist, albumName ?? a.title, a.tracks.length)); const verdictLine = $derived.by(() => { const c = candidate; @@ -285,15 +356,57 @@ const resolvedTag = (k) => choice?.tags?.[k] ?? proposal.tags[k] ?? "yours"; const resolvedTrack = (n) => choice?.tracks?.[n] ?? "yours"; + /** Row order is the RECORD's order; `n` stays the SCAN's. + * + * The scan lists a folder with a plain byte sort (import.rs `paths.sort()`), + * where '0' (0x30) beats '_' (0x5F) — so "10_LONELY WANDERER" precedes + * "1_SEQUENTIAL CIRCUITS" and a record numbered without zero-padding reads + * 10, 11, 12, 13, 1, 2 … 9. Pairing that against a provider tracklist by + * position compares track 10 with track 1 the whole way down, and the + * length differences it reports are the arithmetic of that mispairing. + * + * Ordering is therefore for DISPLAY ONLY. `n` remains each track's index + * in `a.tracks`, because that is the index apply writes by: `track_titles` + * is positional against apply's own `f.sort()` of the same folder + * (import.rs:1238). Reordering `a.tracks` itself would move the review + * without moving apply, and land every chosen title on the wrong file. + * + * A folded release is left in the order the fold built: its tracks are in + * PART order and every part numbers its own tracks from 1, so ordering the + * flattened list by track number would interleave the discs — the trap + * import.rs:1235 and import-fold.js:172 both already guard. */ + const trackRows = $derived.by(() => { + const rows = a.tracks.map((t, n) => ({ t, n })); + if ((a.parts?.length ?? 0) > 1) return rows; + // The tags decide when they ALL carry a distinct number: a rarities + // folder where every file claims track 1 orders by nothing, and a + // partial set would sort the numbered ahead of the unnumbered. + const nos = rows.map((r) => r.t.trackNo); + if (nos.every((x) => typeof x === "number" && x > 0) + && new Set(nos).size === nos.length) { + return [...rows].sort((x, y) => x.t.trackNo - y.t.trackNo); + } + // Otherwise the filename, compared with its digit runs as NUMBERS — + // which is the same comparison the fold already uses to order parts. + return [...rows].sort((x, y) => x.t.name.localeCompare( + y.t.name, undefined, { numeric: true, sensitivity: "base" })); + }); + + /** What the file calls the track. The tag first — it is the thing apply + * writes and compares — falling back to the filename for a file carrying + * no title at all. */ + const titleOfTrack = (t) => (t.title ?? "").trim() + || t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, ""); + const trackPairs = $derived.by(() => { const theirs = candidate?.tracklist ?? []; - return a.tracks.map((t, n) => { - const mine = t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, ""); - const their = theirs[n]; + return trackRows.map(({ t, n }, row) => { + const mine = titleOfTrack(t); + const their = theirs[row]; const dY = t.seconds ?? null; const dT = their?.secs ?? null; const drift = dY && dT ? Math.abs(dY - dT) : null; - return { n, mine, their: their?.title ?? null, dY, dT, drift, + return { n, row, mine, their: their?.title ?? null, dY, dT, drift, aligned: drift !== null && drift <= 2 }; }); }); @@ -542,8 +655,15 @@ const tags = {}; for (const [k, , theirs] of tagPairs) if (resolvedTag(k) === "theirs" && theirs) tags[k] = theirs; - const trackTitles = trackPairs.map((p) => - p.their && resolvedTrack(p.n) === "theirs" ? p.their : null); + // Built by SCAN index, never by row: apply matches `trackTitles` + // position-for-position against its own sorted file list + // (import.rs:1238), and the rows above are ordered for reading. Writing + // this in row order would land every chosen title on the wrong file — + // silently, and on the user's actual music. + const trackTitles = a.tracks.map(() => null); + for (const p of trackPairs) { + if (p.their && resolvedTrack(p.n) === "theirs") trackTitles[p.n] = p.their; + } onSnapshot({ artUrl, tags, trackTitles, audio, audioTracks }); }); @@ -718,7 +838,9 @@
{audition?.status === "none" - ? "No peer offered this album just now — worth asking again another day." + ? audition.silent + ? "Soulseek answered nothing at all — the server refuses some queries outright, and quiet spells happen. Worth asking again." + : "No peer offered this album just now — worth asking again another day." : audition?.status === "failed" ? "The peer stopped answering — try the search again." : "Searches Soulseek for this whole album; the best copy found downloads into the cache so you hear and compare the real thing before choosing."} @@ -864,7 +986,7 @@
{candidate.source}
{#each trackPairs as p (p.n)} -
{p.n + 1}
+
{p.row + 1}
{/each} + + {#if arrivals.length} + + {/if}
+ {#if trayOpen && arrivals.length} +
+
+ {#if !arrivalFiles} + Everything arrived. + {arrivals.length === 1 ? "This copy is" : `All ${nfmt(arrivals.length)} copies are`} + cached — importing now takes {arrivals.length === 1 ? "it" : "them"}. + {:else if arrivalStalled} + Stalled. {nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"} + still owed, and nothing is moving. + {:else} + Still arriving. {nfmt(arrivalsOwed.length)} + record{arrivalsOwed.length === 1 ? "" : "s"} · + {nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}{#if arrivalBytes} · + {nfmt(Math.round(arrivalBytes / 1e6))} MB to come{/if}. + {/if} +
+
+ {#each arrivals as a (a.rel)} + {@const w = arrivalWords(a)} +
+
{arrivalInitials(a.name)}
+
+ {a.name} +
+
0 && a.live?.phase === "transferring"}> + +
+ {nfmt(a.have)} of {nfmt(a.want)} cached + ·{arrivalFormat(a)} + {w.label} + {w.detail} +
+
+
+ + {#if a.owed} + + {/if} +
+
+ {/each} +
+
+ {#if arrivalFiles} + Nothing here needs you — they import themselves on the next run once the + files land. "Keep mine" on the record takes your own copy instead. + {:else} + + + {/if} +
+
+ {/if} {#if curPick} {#key curPick.rel} b { + display: block; font-size: 13px; font-weight: 700; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .ar-meta { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + margin-top: 3px; font-size: 11.5px; color: var(--text-muted); + font-variant-numeric: tabular-nums; + } + .ar-meta .im-bar { width: 120px; flex: none; } + .ar-state { font-weight: 700; } + .ar-state.wait { color: var(--text-secondary); } + .ar-state.move { color: color-mix(in srgb, var(--accent) 60%, #fff); } + .ar-state.stall { color: #f0cd77; } + .ar-state.ok { color: #7fe0a4; } + .ar-acts { display: flex; gap: 6px; align-items: center; } + .ar-foot { margin-top: 9px; font-size: 11.5px; color: var(--text-muted); line-height: 1.5; } + /* Left behind, not broken — amber, the picker's .im-tl-warn language. */ .im-left { margin-bottom: 16px; padding: 11px 13px; border-radius: var(--radius); @@ -2137,6 +2578,9 @@ } .im-reset b { color: #f0a0a0; } .im-reset-row { margin-top: 10px; display: flex; gap: 8px; } + /* The source folder a resumed review could not find. Amber, not red: it + reports something that happened rather than asking to destroy anything. */ + .im-drop-t small.missing { color: rgb(232, 178, 96); } .pill.danger { background: rgba(233, 90, 90, 0.9); color: #1a0d0d; } .pill.danger:hover { background: rgba(233, 90, 90, 1); } @@ -2177,5 +2621,6 @@ @media (prefers-reduced-motion: reduce) { .im-bar.live i { animation: none; } .led-pulse { animation: none; } + .ar-dot { animation: none; } } diff --git a/src/components/LyricsOverlay.svelte b/src/components/LyricsOverlay.svelte new file mode 100644 index 0000000..3d0a706 --- /dev/null +++ b/src/components/LyricsOverlay.svelte @@ -0,0 +1,104 @@ + + + + +{#if ui.lyricsFull} +
+
+
+

{player.track?.title ?? ""}

+ {#if artist}

{artist}

{/if} +
+ +
+
+ +
+
+{/if} + + diff --git a/src/components/LyricsPane.svelte b/src/components/LyricsPane.svelte new file mode 100644 index 0000000..a3a53a2 --- /dev/null +++ b/src/components/LyricsPane.svelte @@ -0,0 +1,911 @@ + + +
+ {#if lyricsStore.status === "loading" || lyricsStore.status === "idle"} + + {:else if lyricsStore.candidates !== null} +
+
+ Choose the right lyrics + +
+ {#if lyricsStore.candidatesLoading} +

Searching LRCLIB…

+ {:else if lyricsStore.candidates.length === 0} +

Nothing found for this track name.

+ {:else} +
+ {#each lyricsStore.candidates as c (c.id)} + + {/each} +
+ {/if} +
+ {:else if pasting} +
+

+ Paste lyrics — plain text, or LRC with [mm:ss] timestamps + for synced display. +

+ +
+ + +
+
+ {:else if lyricsStore.status === "synced"} +
+ {SOURCE_LABEL[lyricsStore.source] ?? ""} + {#if lyricsStore.autoOffsetMs !== 0} + + {/if} + + + + {lyricsStore.offsetMs === 0 + ? "sync" + : `${lyricsStore.offsetMs > 0 ? "+" : ""}${(lyricsStore.offsetMs / 1000).toFixed(1)}s`} + + + + {@render tools()} +
+ +
+
+ {#each lyricsStore.lines as line, index (index)} + + {/each} +
+
+ {:else if lyricsStore.status === "plain"} +
+ {SOURCE_LABEL[lyricsStore.source] ?? ""} · not synced + {@render tools()} +
+ + +
+
+ {#each lyricsStore.plain.split("\n") as pline, i (i)} +

{pline.trim() ? pline : "· · ·"}

+ {/each} +
+
+ {:else if lyricsStore.status === "instrumental"} +
+ +

Instrumental

+

Sit back — there are no words to follow.

+
+ {:else if lyricsStore.status === "error"} +
+

Couldn’t look up lyrics

+

Something failed along the way.

+ +
+ {:else} +
+

No lyrics found

+

+ Nothing local and no LRCLIB match for this exact recording. +

+
+ + +
+
+ {/if} +
+ +{#snippet tools()} + + {#if !big} + + {/if} + + + {#if menuOpen} + + + {/if} + + +{/snippet} + + (menuOpen = false) : undefined} /> + + diff --git a/src/components/LyricsSyncEditor.svelte b/src/components/LyricsSyncEditor.svelte new file mode 100644 index 0000000..6c8a8b1 --- /dev/null +++ b/src/components/LyricsSyncEditor.svelte @@ -0,0 +1,633 @@ + + + + +{#if syncEditor.open} +
+
+
+

Sync lyrics {syncEditor.estimated ? "· estimated — tap to correct" : ""}

+

{syncEditor.identity?.title ?? ""}

+

{syncEditor.identity?.artist ?? ""}

+
+
+ {stampedCount} / {syncEditor.rows.length} stamped + + +
+
+ + {#if saveError} +
+ + {saveError} +
+ {/if} + {#if wrongTrack} +
+ + Playback moved to another track — stamps follow the PLAYING clock. Play + “{syncEditor.identity?.title}” again to keep tapping. +
+ {/if} + + + +
+ + + + {fmt((player.position ?? 0) * 1000)} + + + +
+ +

+ Press Space (or click the line) as each line begins · + Backspace re-arms the previous line +

+ +
+ {#each syncEditor.rows as row, index (index)} +
+ + {row.text} + {#if row.time_ms != null} + + + + + {/if} +
+ {/each} +
+
+{/if} + + diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 120a7d0..d8f6418 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -28,6 +28,8 @@ } from "../lib/state.svelte.js"; import Icon from "./Icon.svelte"; import SaveMenu from "./SaveMenu.svelte"; + import LyricsPane from "./LyricsPane.svelte"; + import PlaylistMenu from "./PlaylistMenu.svelte"; import * as api from "../lib/api.js"; import { floatScroll } from "../lib/scrollbar.js"; import { splitDescription } from "../lib/editions.js"; @@ -1087,6 +1089,27 @@ {metaParts[1] || player.track?.title || "Nothing playing"} + {#if currentItem} + [ + { + title: currentItem.title, + artist: currentItem.artist ?? metaParts[0] ?? null, + album: currentItem.album ?? metaParts[1] ?? null, + duration_secs: currentItem.duration ?? null, + // A stream item's path names a file on the PEER's + // machine — the row goes in as a placeholder that + // streams on click, not as a fake local file. + path: currentItem.stream ? null : currentItem.path, + art_path: currentItem.stream ? null : (currentItem.art ?? null), + }, + ]} + /> + {/if} @@ -1272,9 +1295,7 @@ {/if} {:else if pane === "lyrics"} -

- Lyrics aren't wired up yet — this pane is a placeholder. -

+ {:else if pane === "artist"} {@render artistChip()} @@ -1825,6 +1846,17 @@ color: var(--text-secondary); } .np-icon:hover { background: var(--bg-highlight); color: var(--text-primary); } + /* The add-to-playlist trigger wears the header-icon look exactly. */ + .np-head :global(.plm-trigger) { + width: 28px; + height: 28px; + border-radius: 6px; + color: var(--text-secondary); + } + .np-head :global(.plm-trigger:hover) { + background: var(--bg-highlight); + color: var(--text-primary); + } .np-scroll { /* Fills the absolutely-positioned .full-view rather than sizing to its diff --git a/src/components/PlaylistImportModal.svelte b/src/components/PlaylistImportModal.svelte new file mode 100644 index 0000000..0c6a016 --- /dev/null +++ b/src/components/PlaylistImportModal.svelte @@ -0,0 +1,554 @@ + + + + +{#if open} + + +{/if} + + diff --git a/src/components/PlaylistMenu.svelte b/src/components/PlaylistMenu.svelte new file mode 100644 index 0000000..d38d092 --- /dev/null +++ b/src/components/PlaylistMenu.svelte @@ -0,0 +1,211 @@ + + + + +
+ + {#if open} + + + {/if} +
+ + diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index 037168a..df2926d 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -13,6 +13,7 @@ goToView, openRecentAlbum, getArt, + playlistCoverFor, } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; import Icon from "./Icon.svelte"; @@ -145,6 +146,15 @@ $effect(() => { if (!recentsStore.loaded) refreshRecents(); }); + + // Pinned playlists ride between the nav and the history — quick access + // to the lists the user chose to keep close, with their covers. + const pinnedPlaylists = $derived( + playlistStore.playlists.filter((p) => p.pinned).slice(0, 6), + ); + $effect(() => { + for (const pl of pinnedPlaylists) void playlistCoverFor(pl); + });