diff --git a/AGENTS.md b/AGENTS.md index 6d4759c..2ba86ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,11 @@ These instructions apply to the entire Frid workspace. ## Project role -Frid is shared networking infrastructure. `federation-net` is the generic -transport; `music-dht` is the distributed music-directory overlay used by -Furumi applications. Treat public APIs, persisted state, hash derivations, and -wire formats as compatibility-sensitive. +Frid is shared Furumi infrastructure. `federation-net` is the generic +transport; `music-dht` is the distributed music-directory overlay; and +`furumi-library` owns the client-independent local music database and import +contract. Treat public APIs, persisted state, hash derivations, and wire +formats as compatibility-sensitive. Read `ARCHITECTURE.md` before changing protocols, routing, persistence, rendezvous, tickets, identity, or record derivation. @@ -51,6 +52,9 @@ breaking wire change even when serde would accept the Rust source change. authorization logic. - `music-dht` owns Kademlia routing, requests, records, replication, search, content lookup, and its persistence abstraction. +- `furumi-library` owns the local catalog schema and migrations, metadata + import, artwork, playlists, likes, history, and similarity persistence. It + must not acquire UI, playback-engine, or client-runtime responsibilities. - Rich catalogs, audio, and device sync use dedicated ALPN stream protocols; do not push bulk data through the typed DHT event channel. - Applications own policy: knowing a network ID or discovering a peer is not diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e3e6271..f1873f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,8 +7,11 @@ queried**. ```text application + ├── UI, playback, runtime, policy │ - ├── domain API, persistence, policy +furumi-library + ├── local catalog, import, migrations + ├── playlists, likes, history, similarity persistence │ music-dht ├── Kademlia routing and iterative lookup @@ -22,7 +25,25 @@ federation-net └── optional Mainline-DHT rendezvous ``` -Neither layer assigns a permanent server role to a peer. +No layer assigns a permanent server role to a peer. + +`furumi-library` is client-independent local persistence. It may reuse stable +music-domain identifiers and device-sync DTOs from `music-dht`, but it does +not start networking, own an application runtime, or depend on a particular +UI or playback engine. Dependency direction remains one-way: +`federation-net <- music-dht <- furumi-library <- applications`. + +## Local library: furumi-library + +All Furumi clients share one platform-native `library.db` contract. The crate +owns its SQLite schema and migrations, local catalog models and queries, +metadata import, artwork resolution, playlists, likes, listening history, and +similarity index storage. Connections enable foreign keys, WAL, and a bounded +busy timeout so independently running clients can safely share the database. + +Filesystem scanning schedules, playback, federation lifecycle, and UI state +remain application responsibilities. Database and metadata work must stay off +interactive and async reactor threads. ## Transport: federation-net diff --git a/Cargo.lock b/Cargo.lock index 2a5cfe7..8b4f7ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -416,6 +422,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -660,6 +675,27 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -832,6 +868,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.12.0" @@ -864,6 +910,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "furumi-library" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "directories", + "lofty", + "music-dht", + "rusqlite", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "futures" version = "0.3.33" @@ -1816,6 +1877,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1854,6 +1924,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "log" version = "0.4.33" @@ -1937,6 +2033,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -2366,6 +2472,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2388,6 +2503,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "papaya" version = "0.2.4" @@ -2735,6 +2856,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + [[package]] name = "regex-automata" version = "0.4.16" @@ -3134,6 +3266,12 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 15e0c99..5c2f024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/federation-net", + "crates/furumi-library", "crates/music-dht", ] @@ -13,6 +14,7 @@ repository = "https://gt.hexor.cy/ab/frid" [workspace.dependencies] federation-net = { path = "crates/federation-net", version = "0.3.0" } +music-dht = { path = "crates/music-dht", version = "0.4.0" } iroh = "1" iroh-base = "1" iroh-tickets = "1" @@ -31,6 +33,8 @@ tracing = "0.1" clap = { version = "4", features = ["derive"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" +directories = "6" +lofty = "0.22" data-encoding = "2" rand = "0.9" tempfile = "3" diff --git a/README.md b/README.md index 831b532..abb2d88 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # frid -Frid is the decentralized networking core behind Furumi. It provides reusable -Rust libraries for building equal-peer applications with no application -server, central peer registry, or privileged node. +Frid is the shared infrastructure core behind Furumi. It provides reusable +Rust libraries for local music persistence and equal-peer networking without +an application server, central peer registry, or privileged node. -The workspace has two layers: +The workspace has three layers: - [`federation-net`](crates/federation-net) establishes authenticated, NAT-traversing P2P connections over iroh, isolates independent networks and @@ -14,6 +14,9 @@ The workspace has two layers: directory on top of that transport, including catalog discovery, content-id lookup, signed similarity-LSH routing, direct byte streams, and shared Furumi wire types. +- [`furumi-library`](crates/furumi-library) owns the shared local `library.db` + contract, migrations, catalog operations, metadata import, artwork, + playlists, likes, history, and similarity persistence used by Furumi clients. Each participant is a client, router, and storage peer. Automatic rendezvous has no Frid-operated bootstrap service, while self-contained peer tickets @@ -42,6 +45,7 @@ failure handling, and compatibility rules. crates/ federation-net/ generic iroh transport and rendezvous music-dht/ distributed music catalog and content discovery + furumi-library/ client-independent local music database and import ``` ## Using the libraries @@ -52,12 +56,14 @@ Until crates are published to a registry, depend on the repository directly: [dependencies] federation-net = { git = "https://gt.hexor.cy/ab/frid.git" } music-dht = { git = "https://gt.hexor.cy/ab/frid.git" } +furumi-library = { git = "https://gt.hexor.cy/ab/frid.git" } ``` Start with the crate documentation: - [`federation-net` guide](crates/federation-net/README.md) - [`music-dht` guide](crates/music-dht/README.md) +- [`furumi-library` guide](crates/furumi-library/README.md) ## Development diff --git a/crates/furumi-library/.gitignore b/crates/furumi-library/.gitignore new file mode 100644 index 0000000..5259e8c --- /dev/null +++ b/crates/furumi-library/.gitignore @@ -0,0 +1,3 @@ +/target +*.sqlite3-shm +*.sqlite3-wal diff --git a/crates/furumi-library/Cargo.toml b/crates/furumi-library/Cargo.toml new file mode 100644 index 0000000..79e3348 --- /dev/null +++ b/crates/furumi-library/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "furumi-library" +version = "0.1.0" +description = "Client-independent SQLite music library for Furumi players" +readme = "README.md" +documentation = "https://docs.rs/furumi-library" +repository.workspace = true +keywords = ["music", "sqlite", "metadata", "library", "furumi"] +categories = ["multimedia::audio", "database"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +blake3 = { workspace = true } +directories = { workspace = true } +lofty = { workspace = true } +music-dht = { workspace = true } +rusqlite = { workspace = true, features = ["functions"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } diff --git a/crates/furumi-library/LICENSE b/crates/furumi-library/LICENSE new file mode 100644 index 0000000..5c93f45 --- /dev/null +++ b/crates/furumi-library/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/crates/furumi-library/README.md b/crates/furumi-library/README.md new file mode 100644 index 0000000..4fa5454 --- /dev/null +++ b/crates/furumi-library/README.md @@ -0,0 +1,21 @@ +# furumi-library + +Client-independent persistence foundation for Furumi music players. + +The crate owns the shared `library.db` contract: SQLite schema and migrations, +catalog models and queries, local import and metadata extraction, artwork +resolution, playlists, likes, listening history, and similarity index storage. +It contains no UI, playback engine, or application runtime. + +During local development clients use a path dependency: + +```toml +furumi-library = { path = "../frid/crates/furumi-library" } +``` + +Adjust the relative path to match the position of the client repository. After +publication, clients can depend on the released crate version instead. + +`Library::open(default_db_path()?)` opens the same platform-native Furumi +library used by every client. Connections use WAL, foreign keys, and a busy +timeout so independently running clients can safely share the database. diff --git a/crates/furumi-library/src/import.rs b/crates/furumi-library/src/import.rs new file mode 100644 index 0000000..730d23e --- /dev/null +++ b/crates/furumi-library/src/import.rs @@ -0,0 +1,561 @@ +//! Importing audio files into the library: directory scanning, tag reading +//! (via lofty) and cover extraction. Importing the same file again updates +//! its metadata instead of duplicating it. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; +use lofty::file::{AudioFile as _, TaggedFileExt as _}; +use lofty::picture::MimeType; +use lofty::tag::{Accessor as _, ItemKey}; +use rusqlite::{OptionalExtension as _, params}; + +use super::{Library, audio_content_id, find_or_create_artist}; + +/// Extensions the playback engine can decode (rodio/symphonia feature set). +const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"]; + +/// Everything known about one audio file, ready to be written to the DB. +#[derive(Debug)] +pub struct TrackImport { + pub file_path: String, + pub title: String, + pub artists: Vec, + pub featured_artists: Vec, + pub album_artists: Vec, + pub release_title: String, + /// Release type ("album", "single", ...) when known from a richer + /// source than file tags (e.g. federation metadata); None = "album". + pub release_type: Option, + pub year: Option, + pub track_number: Option, + pub disc_number: Option, + pub duration_seconds: f64, + pub audio_format: Option, + pub audio_bitrate: Option, + pub audio_sample_rate: Option, + pub audio_bit_depth: Option, + pub file_size_bytes: Option, + /// Embedded cover art (bytes, file extension), if any. + pub cover: Option<(Vec, &'static str)>, +} + +#[derive(Debug, Default)] +pub struct ImportOutcome { + pub added: usize, + pub updated: usize, + pub failed: Vec<(PathBuf, String)>, +} + +impl ImportOutcome { + pub fn summary(&self) -> String { + let mut message = format!("imported {} track(s)", self.added); + if self.updated > 0 { + message.push_str(&format!(", updated {}", self.updated)); + } + if !self.failed.is_empty() { + message.push_str(&format!(", {} failed", self.failed.len())); + } + message + } +} + +/// Import a file or a directory (recursively). `progress(done, total, name)` +/// is called after every file. +pub fn import_path( + library: &Library, + path: &Path, + mut progress: impl FnMut(usize, usize, &str), +) -> Result { + let path = path + .canonicalize() + .with_context(|| format!("{} does not exist", path.display()))?; + let mut files = Vec::new(); + collect_audio_files(&path, &mut files); + anyhow::ensure!( + !files.is_empty(), + "no audio files found at {} (supported: {})", + path.display(), + AUDIO_EXTENSIONS.join(", ") + ); + files.sort(); + + let total = files.len(); + let mut outcome = ImportOutcome::default(); + for (index, file) in files.iter().enumerate() { + match read_file(file).and_then(|import| upsert_track(library, &import)) { + Ok((_, created)) => { + if created { + outcome.added += 1; + } else { + outcome.updated += 1; + } + } + Err(err) => { + tracing::warn!(file = %file.display(), %err, "import failed"); + outcome.failed.push((file.clone(), format!("{err:#}"))); + } + } + let name = file + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + progress(index + 1, total, &name); + } + Ok(outcome) +} + +fn collect_audio_files(path: &Path, files: &mut Vec) { + if path.is_dir() { + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + collect_audio_files(&entry.path(), files); + } + return; + } + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()); + if extension.is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str())) { + files.push(path.to_path_buf()); + } +} + +/// Read tags and audio properties from one file. +pub fn read_file(path: &Path) -> Result { + let tagged = lofty::read_from_path(path).context("cannot read tags")?; + let properties = tagged.properties(); + let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); + + let fallback_title = path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Unknown".to_string()); + let (mut title, artist_raw, album, year, track_number, disc_number, album_artist_raw, cover) = + match tag { + Some(tag) => ( + tag.title() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback_title), + tag.artist().map(|value| value.into_owned()), + tag.album() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + tag.year().and_then(|value| i32::try_from(value).ok()), + tag.track().and_then(|value| i32::try_from(value).ok()), + tag.disk().and_then(|value| i32::try_from(value).ok()), + tag.get_string(&ItemKey::AlbumArtist) + .map(|value| value.to_string()), + tag.pictures().first().map(|picture| { + let extension = match picture.mime_type() { + Some(MimeType::Png) => "png", + Some(MimeType::Gif) => "gif", + Some(MimeType::Bmp) => "bmp", + _ => "jpg", + }; + (picture.data().to_vec(), extension) + }), + ), + None => (fallback_title, None, None, None, None, None, None, None), + }; + + let (mut artists, mut featured) = split_artist_tag(artist_raw.as_deref().unwrap_or("")); + // "Song (feat. X)" in the title moves X into the featured list. + if let Some((clean_title, feat)) = extract_title_feat(&title) { + title = clean_title; + for name in feat { + if !featured.iter().any(|f| f.eq_ignore_ascii_case(&name)) { + featured.push(name); + } + } + } + if artists.is_empty() { + artists.push("Unknown Artist".to_string()); + } + let album_artists = match album_artist_raw.as_deref().map(split_artist_tag) { + Some((main, _)) if !main.is_empty() => main, + _ => artists.clone(), + }; + + let metadata = std::fs::metadata(path).ok(); + Ok(TrackImport { + file_path: path.to_string_lossy().into_owned(), + title, + artists, + featured_artists: featured, + album_artists, + release_title: album.unwrap_or_else(|| "Unknown Album".to_string()), + release_type: None, + year, + track_number, + disc_number, + duration_seconds: properties.duration().as_secs_f64(), + audio_format: path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()), + audio_bitrate: properties + .audio_bitrate() + .and_then(|value| i32::try_from(value).ok()), + audio_sample_rate: properties + .sample_rate() + .and_then(|value| i32::try_from(value).ok()), + audio_bit_depth: properties.bit_depth().map(i32::from), + file_size_bytes: metadata.map(|meta| meta.len() as i64), + cover, + }) +} + +/// Insert or update one track (matching by file path). Returns the track id +/// and whether a new row was created. +pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> { + let content_id = audio_content_id(&import.file_path); + let mut conn = library.lock(); + let tx = conn.transaction()?; + + // Release, keyed by (title, first album artist). + let album_artist_id = find_or_create_artist( + &tx, + import + .album_artists + .first() + .map(String::as_str) + .unwrap_or("Unknown Artist"), + )?; + let release_id: Option = tx + .query_row( + "SELECT r.id FROM releases r + JOIN release_artists ra ON ra.release_id = r.id + WHERE r.title = ?1 COLLATE NOCASE AND ra.artist_id = ?2", + params![import.release_title, album_artist_id], + |row| row.get(0), + ) + .optional()?; + let release_id = match release_id { + Some(id) => { + // Fill in the year if this file is the first one to know it. + if import.year.is_some() { + tx.execute( + "UPDATE releases SET year = COALESCE(year, ?2) WHERE id = ?1", + params![id, import.year], + )?; + } + id + } + None => { + tx.execute( + "INSERT INTO releases (title, release_type, year) VALUES (?1, ?2, ?3)", + params![ + import.release_title, + import.release_type.as_deref().unwrap_or("album"), + import.year, + ], + )?; + let id = tx.last_insert_rowid(); + for (position, name) in import.album_artists.iter().enumerate() { + let artist_id = find_or_create_artist(&tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO release_artists (release_id, artist_id, position) + VALUES (?1, ?2, ?3)", + params![id, artist_id, position as i64], + )?; + } + id + } + }; + + let existing: Option = tx + .query_row( + "SELECT id FROM tracks WHERE file_path = ?1", + [&import.file_path], + |row| row.get(0), + ) + .optional()?; + let (track_id, created) = match existing { + Some(id) => { + tx.execute( + "UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4, + duration_seconds = ?5, release_id = ?6, audio_format = ?7, + audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10, + file_size_bytes = ?11, content_id = ?12 + WHERE id = ?1", + params![ + id, + import.title, + import.track_number, + import.disc_number, + import.duration_seconds, + release_id, + import.audio_format, + import.audio_bitrate, + import.audio_sample_rate, + import.audio_bit_depth, + import.file_size_bytes, + content_id.as_deref(), + ], + )?; + tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?; + (id, false) + } + None => { + tx.execute( + "INSERT INTO tracks (title, track_number, disc_number, duration_seconds, + release_id, file_path, audio_format, audio_bitrate, audio_sample_rate, + audio_bit_depth, file_size_bytes, content_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + import.title, + import.track_number, + import.disc_number, + import.duration_seconds, + release_id, + import.file_path, + import.audio_format, + import.audio_bitrate, + import.audio_sample_rate, + import.audio_bit_depth, + import.file_size_bytes, + content_id.as_deref(), + ], + )?; + (tx.last_insert_rowid(), true) + } + }; + for (position, name) in import.artists.iter().enumerate() { + let artist_id = find_or_create_artist(&tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position) + VALUES (?1, ?2, 'main', ?3)", + params![track_id, artist_id, position as i64], + )?; + } + for (position, name) in import.featured_artists.iter().enumerate() { + let artist_id = find_or_create_artist(&tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position) + VALUES (?1, ?2, 'featured', ?3)", + params![track_id, artist_id, position as i64], + )?; + } + + // Cover: a release keeps the first cover found — an image file next to + // the audio, or the embedded picture saved into the covers directory. + let has_cover: bool = tx + .query_row( + "SELECT cover_path IS NOT NULL FROM releases WHERE id = ?1", + [release_id], + |row| row.get(0), + ) + .unwrap_or(false); + if !has_cover && let Some(cover_path) = resolve_cover(library, release_id, import) { + tx.execute( + "UPDATE releases SET cover_path = ?2 WHERE id = ?1", + params![release_id, cover_path], + )?; + } + + tx.commit()?; + Ok((track_id, created)) +} + +/// Find a cover image for the release: a cover/folder/front image in the +/// audio file's directory, or the embedded picture written to disk. +fn resolve_cover(library: &Library, release_id: i64, import: &TrackImport) -> Option { + let directory = Path::new(&import.file_path).parent()?; + if let Ok(entries) = std::fs::read_dir(directory) { + for entry in entries.flatten() { + let path = entry.path(); + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| stem.to_ascii_lowercase()); + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()); + let is_image = matches!( + extension.as_deref(), + Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif") + ); + if is_image + && matches!( + stem.as_deref(), + Some("cover" | "folder" | "front" | "album") + ) + { + return Some(path.to_string_lossy().into_owned()); + } + } + } + let (data, extension) = import.cover.as_ref()?; + let covers_dir = library.covers_dir(); + if let Err(err) = std::fs::create_dir_all(covers_dir) { + tracing::warn!(%err, "cannot create covers directory"); + return None; + } + let path = covers_dir.join(format!("release_{release_id}.{extension}")); + match std::fs::write(&path, data) { + Ok(()) => Some(path.to_string_lossy().into_owned()), + Err(err) => { + tracing::warn!(%err, path = %path.display(), "cannot save embedded cover"); + None + } + } +} + +/// Split an artist tag into (main artists, featured artists). +/// Separators: ";" and "/" between main artists; "feat."/"ft."/"featuring" +/// starts the featured list. +pub fn split_artist_tag(raw: &str) -> (Vec, Vec) { + let raw = raw.trim(); + if raw.is_empty() { + return (Vec::new(), Vec::new()); + } + let (main_part, feat_part) = match find_feat_marker(raw) { + Some((at, marker_len)) => { + let main = raw[..at].trim_end_matches(['(', '[', ' ', ',', '-']); + let feat = raw[at + marker_len..].trim_end_matches([')', ']']); + (main, feat) + } + None => (raw, ""), + }; + (split_names(main_part), split_names(feat_part)) +} + +/// The earliest "feat."/"ft."/"featuring" marker that stands as its own +/// word — preceded by a separator and followed by a space — so artist names +/// like "Daft Punk" are not split on the "ft" inside them. +fn find_feat_marker(raw: &str) -> Option<(usize, usize)> { + let lowered = raw.to_lowercase(); + let mut best: Option<(usize, usize)> = None; + for marker in ["featuring", "feat.", "feat", "ft.", "ft"] { + for (at, _) in lowered.match_indices(marker) { + let before_ok = raw[..at] + .chars() + .next_back() + .is_some_and(|c| matches!(c, ' ' | '(' | '[' | ',' | '-')); + let after_ok = raw[at + marker.len()..].starts_with(' '); + if before_ok && after_ok && best.is_none_or(|(current, _)| at < current) { + best = Some((at, marker.len())); + } + } + } + best +} + +fn split_names(raw: &str) -> Vec { + raw.split([';', '/']) + .flat_map(|part| part.split(" & ")) + .map(|name| name.trim().trim_matches(',').trim().to_string()) + .filter(|name| !name.is_empty()) + .collect() +} + +/// Extract "(feat. X)" / "[ft. Y]" from a track title. +fn extract_title_feat(title: &str) -> Option<(String, Vec)> { + let lowered = title.to_lowercase(); + for marker in ["(feat.", "(feat ", "(ft.", "[feat.", "[ft."] { + if let Some(start) = lowered.find(marker) { + let closer = if marker.starts_with('(') { ')' } else { ']' }; + let rest = &title[start + marker.len()..]; + let end = rest.find(closer)?; + let names = split_names(&rest[..end]); + if names.is_empty() { + return None; + } + let mut clean = title[..start].trim_end().to_string(); + clean.push_str(rest[end + 1..].trim_end()); + return Some((clean.trim().to_string(), names)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal valid WAV file: 0.1s of silence at 8kHz mono 16-bit. + fn write_test_wav(path: &Path) { + let samples: u32 = 800; + let data_len = samples * 2; + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); // PCM + bytes.extend_from_slice(&1u16.to_le_bytes()); // mono + bytes.extend_from_slice(&8000u32.to_le_bytes()); // sample rate + bytes.extend_from_slice(&16000u32.to_le_bytes()); // byte rate + bytes.extend_from_slice(&2u16.to_le_bytes()); // block align + bytes.extend_from_slice(&16u16.to_le_bytes()); // bits per sample + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_len.to_le_bytes()); + bytes.resize(bytes.len() + data_len as usize, 0); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn imports_a_real_audio_file_end_to_end() { + let dir = std::env::temp_dir().join(format!("furumi-import-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let wav = dir.join("My Song.wav"); + write_test_wav(&wav); + + let db = dir.join("library.db"); + let library = Library::open(&db).unwrap(); + let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap(); + assert_eq!(outcome.added, 1); + assert!(outcome.failed.is_empty()); + + // Untagged files fall back to the file name and placeholder names. + let results = library.search("My Song", 10).unwrap(); + assert_eq!(results.tracks.len(), 1); + let track = &results.tracks[0]; + assert_eq!(track.title, "My Song"); + assert_eq!(track.artists[0].name, "Unknown Artist"); + assert_eq!(track.release_title, "Unknown Album"); + assert!(track.duration_seconds > 0.05); + assert_eq!(track.audio_sample_rate, Some(8000)); + assert!(std::fs::File::open(&track.file_path).is_ok()); + + // Re-importing the same directory only updates. + let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap(); + assert_eq!((outcome.added, outcome.updated), (0, 1)); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn splits_plain_artist() { + let (main, feat) = split_artist_tag("Daft Punk"); + assert_eq!(main, vec!["Daft Punk"]); + assert!(feat.is_empty()); + } + + #[test] + fn splits_multiple_and_featured() { + let (main, feat) = split_artist_tag("A; B feat. C & D"); + assert_eq!(main, vec!["A", "B"]); + assert_eq!(feat, vec!["C", "D"]); + } + + #[test] + fn keeps_commas_inside_names() { + let (main, _) = split_artist_tag("Tyler, The Creator"); + assert_eq!(main, vec!["Tyler, The Creator"]); + } + + #[test] + fn extracts_feat_from_title() { + let (title, names) = extract_title_feat("Song (feat. X & Y)").unwrap(); + assert_eq!(title, "Song"); + assert_eq!(names, vec!["X", "Y"]); + assert!(extract_title_feat("Plain Song").is_none()); + } +} diff --git a/crates/furumi-library/src/lib.rs b/crates/furumi-library/src/lib.rs new file mode 100644 index 0000000..daa46d5 --- /dev/null +++ b/crates/furumi-library/src/lib.rs @@ -0,0 +1,3605 @@ +//! The local music library: a SQLite database holding artists, releases, +//! tracks (with paths to local audio files), playlists, likes and play +//! history. This module is the only place that talks SQL; it returns the +//! same data shapes the furumusic API used to serve, so the views did not +//! have to change their model of the world. +//! +//! All methods take `&self` and lock the connection internally, so an +//! `Arc` can be shared across blocking tasks. + +pub mod import; +pub mod models; + +use std::collections::{HashMap, HashSet}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use rusqlite::{Connection, OptionalExtension as _, params}; + +pub use models::{ + ArtistCard, ArtistDetail, ArtistRef, ArtistsPage, Availability, FederatedTrack, LibraryFilters, + LibrarySourceMode, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, ReleaseEdit, + SearchResults, TrackEdit, TrackItem, +}; + +/// The virtual "Liked tracks" playlist id, kept from the server API. +pub const LIKES_PLAYLIST_ID: i64 = -1; +const NETWORK_ARTIST_CACHE_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000; +static NEXT_PENDING_TRACK_ID: AtomicI64 = AtomicI64::new(-1); + +type FederatedLikeState = ( + String, + String, + String, + String, + Option, + Option, + Option, + Option, + Option, + Option, +); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListenHistoryEntry { + pub listen_id: String, + pub content_id: String, + pub title: String, + pub artist: String, + pub origin_device_id: String, + pub started_at_ms: i64, +} + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS artists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + image_path TEXT +); +CREATE TABLE IF NOT EXISTS releases ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + release_type TEXT NOT NULL DEFAULT 'album', + year INTEGER, + cover_path TEXT +); +CREATE TABLE IF NOT EXISTS release_artists ( + release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, + artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (release_id, artist_id) +); +CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + track_number INTEGER, + disc_number INTEGER, + duration_seconds REAL NOT NULL DEFAULT 0, + release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, + file_path TEXT NOT NULL UNIQUE, + content_id TEXT, + audio_format TEXT, + audio_bitrate INTEGER, + audio_sample_rate INTEGER, + audio_bit_depth INTEGER, + file_size_bytes INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS track_artists ( + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'main', + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (track_id, artist_id, role) +); +CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY, + sync_id TEXT UNIQUE, + title TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS playlist_tracks ( + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (playlist_id, track_id) +); +CREATE TABLE IF NOT EXISTS likes ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + liked_at TEXT NOT NULL DEFAULT (datetime('now')), + liked_hlc_ms INTEGER +); +CREATE TABLE IF NOT EXISTS fed_likes ( + item_id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + title TEXT NOT NULL, + artist_names TEXT NOT NULL DEFAULT '', + featured_artist_names TEXT NOT NULL DEFAULT '', + year INTEGER, + duration_seconds REAL, + content_id TEXT, + release_title TEXT, + track_number INTEGER, + disc_number INTEGER, + liked_at TEXT NOT NULL DEFAULT (datetime('now')), + liked_hlc_ms INTEGER +); +CREATE TABLE IF NOT EXISTS fed_playlist_tracks ( + playlist_sync_id TEXT NOT NULL, + item_id TEXT NOT NULL, + owner TEXT NOT NULL, + title TEXT NOT NULL, + artist_names TEXT NOT NULL DEFAULT '', + featured_artist_names TEXT NOT NULL DEFAULT '', + year INTEGER, + duration_seconds REAL, + content_id TEXT NOT NULL, + release_title TEXT, + track_number INTEGER, + disc_number INTEGER, + position INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (playlist_sync_id, content_id) +); +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + started_at INTEGER, + listened_seconds INTEGER NOT NULL DEFAULT 0, + completed INTEGER NOT NULL DEFAULT 0, + played_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS listen_events ( + listen_id TEXT PRIMARY KEY, + content_id TEXT NOT NULL, + local_track_id INTEGER REFERENCES tracks(id) ON DELETE SET NULL, + origin_device_id TEXT NOT NULL, + started_at_ms INTEGER NOT NULL, + listened_ms INTEGER NOT NULL, + track_duration_ms INTEGER, + ended_reason TEXT NOT NULL, + qualified INTEGER NOT NULL, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_tracks_release ON tracks(release_id); +CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id); +CREATE INDEX IF NOT EXISTS idx_release_artists_artist ON release_artists(artist_id); +CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id); +CREATE INDEX IF NOT EXISTS idx_listen_events_content + ON listen_events(content_id, started_at_ms DESC); +CREATE INDEX IF NOT EXISTS idx_listen_events_local_track + ON listen_events(local_track_id, qualified); +CREATE TRIGGER IF NOT EXISTS reconcile_listen_events_after_track_insert +AFTER INSERT ON tracks +WHEN NEW.content_id IS NOT NULL +BEGIN + UPDATE listen_events + SET local_track_id = NEW.id + WHERE content_id = NEW.content_id; +END; +CREATE TRIGGER IF NOT EXISTS reconcile_listen_events_after_track_content_id +AFTER UPDATE OF content_id ON tracks +WHEN NEW.content_id IS NOT NULL +BEGIN + UPDATE listen_events + SET local_track_id = NEW.id + WHERE content_id = NEW.content_id; +END; +CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id); +CREATE INDEX IF NOT EXISTS idx_fed_playlist_tracks_playlist + ON fed_playlist_tracks(playlist_sync_id, position); +CREATE TABLE IF NOT EXISTS network_artist_cache ( + source_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + artist_key TEXT NOT NULL, + name TEXT NOT NULL, + image_path TEXT, + remote_image_hint TEXT, + release_count INTEGER NOT NULL DEFAULT 0, + track_count INTEGER NOT NULL DEFAULT 0, + seen_at_ms INTEGER NOT NULL, + PRIMARY KEY (source_id, artist_key) +); +CREATE INDEX IF NOT EXISTS idx_network_artist_cache_kind + ON network_artist_cache(source_kind, seen_at_ms); +CREATE INDEX IF NOT EXISTS idx_network_artist_cache_artist + ON network_artist_cache(artist_key); +CREATE TABLE IF NOT EXISTS similarity_profiles ( + profile_id TEXT PRIMARY KEY, + model_id TEXT NOT NULL, + model_version TEXT NOT NULL, + model_sha256 TEXT NOT NULL, + preprocessing TEXT NOT NULL, + dimensions INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS track_embeddings ( + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + profile_id TEXT NOT NULL REFERENCES similarity_profiles(profile_id) ON DELETE CASCADE, + dimensions INTEGER NOT NULL, + vector BLOB NOT NULL, + routing_signature BLOB, + source_content_id TEXT, + computed_at_ms INTEGER NOT NULL, + PRIMARY KEY (track_id, profile_id) +); +CREATE INDEX IF NOT EXISTS idx_track_embeddings_profile + ON track_embeddings(profile_id, track_id); +"; + +/// The SELECT column list every TrackItem row is built from; artist lists +/// are attached in a second pass. +const TRACK_COLUMNS: &str = " + t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, + t.release_id, r.title, r.year, r.cover_path, + t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate, + t.audio_bit_depth, t.file_size_bytes, + t.content_id, + ((SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1) + + (SELECT COUNT(*) FROM listen_events le + WHERE le.local_track_id = t.id AND le.qualified = 1)) +"; + +/// Plain rows handed to the federation for publishing (see +/// `federation_export`). +#[derive(Debug)] +pub struct FederationExport { + pub artists: Vec<(i64, String)>, + pub releases: Vec, + pub tracks: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ContentIdBackfillStats { + pub checked: usize, + pub normalized: usize, + pub hashed: usize, + pub failed: usize, +} + +impl ContentIdBackfillStats { + pub fn updated(&self) -> usize { + self.normalized + self.hashed + } +} + +#[derive(Debug)] +pub struct ExportRelease { + pub id: i64, + pub title: String, + pub year: Option, + pub release_type: String, + pub artist_names: Vec, +} + +#[derive(Debug)] +pub struct ExportTrack { + pub id: i64, + pub title: String, + pub year: Option, + pub duration_seconds: f64, + pub artist_names: Vec, + pub featured_artist_names: Vec, + pub release_title: String, + pub release_type: String, + pub track_number: Option, + pub disc_number: Option, + pub content_id: Option, +} + +#[derive(Debug, Clone)] +pub struct NetworkArtistPreview { + pub artist_key: String, + pub name: String, + pub image_path: Option, + pub release_count: i64, + pub track_count: i64, +} + +#[derive(Debug, Clone)] +pub struct NetworkArtistImageRequest { + pub source_id: String, + pub artist_key: String, + pub name: String, +} + +/// Minimal durable-track row used by the background embedding pipeline. +#[derive(Debug, Clone)] +pub struct SimilarityTrack { + pub id: i64, + pub title: String, + pub file_path: String, + pub content_id: Option, + pub duration_seconds: f64, +} + +/// One validated vector loaded from SQLite for the in-memory exact index. +#[derive(Debug, Clone)] +pub struct StoredEmbedding { + pub track_id: i64, + pub vector: Vec, + pub artist_key: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SimilarityStorageStats { + pub total_tracks: usize, + pub embedded_tracks: usize, + pub stored_vectors: usize, + pub stored_bytes: u64, +} + +pub struct Library { + conn: Mutex, + db_path: PathBuf, + /// Directory where extracted embedded covers are stored. + covers_dir: PathBuf, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LocalLibraryStats { + pub artist_count: i64, + pub release_count: i64, + pub track_count: i64, + pub audio_bytes: u64, + pub tracks_without_size: i64, + pub cover_bytes: u64, + pub database_bytes: u64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MusicRelocationStats { + pub tracks: usize, + pub images: usize, +} + +impl LocalLibraryStats { + pub fn total_bytes(&self) -> u64 { + self.audio_bytes + .saturating_add(self.cover_bytes) + .saturating_add(self.database_bytes) + } +} + +/// Default database location: `/furumi/library.db`. +pub fn default_db_path() -> Result { + let dirs = directories::ProjectDirs::from("", "", "furumi") + .context("cannot determine the Furumi data directory")?; + Ok(dirs.data_dir().join("library.db")) +} + +impl Library { + pub fn open(db_path: &Path) -> Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let conn = Connection::open(db_path) + .with_context(|| format!("opening database {}", db_path.display()))?; + conn.busy_timeout(Duration::from_secs(5))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + register_norm_function(&conn)?; + conn.execute_batch(SCHEMA).context("applying schema")?; + ensure_schema_migrations(&conn)?; + let covers_dir = db_path + .parent() + .map(|dir| dir.join("covers")) + .unwrap_or_else(|| PathBuf::from("covers")); + Ok(Self { + conn: Mutex::new(conn), + db_path: db_path.to_path_buf(), + covers_dir, + }) + } + + pub fn covers_dir(&self) -> &Path { + &self.covers_dir + } + + /// Verifies that `path` can actually be used for durable downloads. The + /// returned path is absolute/canonical, so the persisted setting does not + /// later depend on Furumi's working directory. + pub fn validate_music_directory(path: &Path) -> Result { + anyhow::ensure!(!path.as_os_str().is_empty(), "music directory is empty"); + std::fs::create_dir_all(path) + .with_context(|| format!("creating music directory {}", path.display()))?; + let path = std::fs::canonicalize(path) + .with_context(|| format!("resolving music directory {}", path.display()))?; + anyhow::ensure!(path.is_dir(), "{} is not a directory", path.display()); + + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let probe = path.join(format!( + ".furumi-write-test-{}-{unique}", + std::process::id() + )); + let result = (|| -> Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + .with_context(|| format!("no write access to {}", path.display()))?; + file.write_all(b"furumi")?; + file.sync_all()?; + Ok(()) + })(); + let _ = std::fs::remove_file(&probe); + result?; + Ok(path) + } + + /// Copies Furumi-managed audio and its managed artwork into a portable + /// `Artist/Release` tree, atomically switches SQLite paths, then removes + /// the old copies. User-imported audio outside `old_root` is untouched. + pub fn relocate_managed_music( + &self, + old_root: &Path, + new_root: &Path, + ) -> Result { + let new_root = Self::validate_music_directory(new_root)?; + let old_root = std::fs::canonicalize(old_root) + .with_context(|| format!("resolving old music directory {}", old_root.display()))?; + anyhow::ensure!( + old_root != new_root, + "the new music directory is the current directory" + ); + anyhow::ensure!( + !old_root.starts_with(&new_root) && !new_root.starts_with(&old_root), + "choose a directory outside the current music directory" + ); + + #[derive(Debug)] + struct Row { + track_id: i64, + track_path: String, + release_id: i64, + release_title: String, + cover_path: Option, + artist_id: Option, + artist_name: String, + image_path: Option, + } + + let rows = { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT t.id, t.file_path, r.id, r.title, r.cover_path, + a.id, COALESCE(a.name, 'Unknown Artist'), a.image_path + FROM tracks t + JOIN releases r ON r.id = t.release_id + LEFT JOIN track_artists ta + ON ta.track_id = t.id AND ta.role = 'main' AND ta.position = 0 + LEFT JOIN artists a ON a.id = ta.artist_id + ORDER BY t.id", + )?; + statement + .query_map([], |row| { + Ok(Row { + track_id: row.get(0)?, + track_path: row.get(1)?, + release_id: row.get(2)?, + release_title: row.get(3)?, + cover_path: row.get(4)?, + artist_id: row.get(5)?, + artist_name: row.get(6)?, + image_path: row.get(7)?, + }) + })? + .collect::>>()? + }; + + #[derive(Debug, Clone)] + struct CopyOp { + source: PathBuf, + destination: PathBuf, + } + let covers_root = + std::fs::canonicalize(&self.covers_dir).unwrap_or_else(|_| self.covers_dir.clone()); + let managed_art = + |path: &Path| path.starts_with(&old_root) || path.starts_with(&covers_root); + let mut reserved = HashSet::::new(); + let mut copies = Vec::::new(); + let mut track_updates = Vec::<(i64, String, String)>::new(); + let mut release_updates = HashMap::::new(); + let mut artist_updates = HashMap::::new(); + + for row in rows { + let source = PathBuf::from(&row.track_path); + let source = std::fs::canonicalize(&source).unwrap_or(source); + if !source.is_file() || !source.starts_with(&old_root) { + continue; + } + let artist_dir = new_root.join(storage_name(&row.artist_name, "Unknown Artist")); + let release_dir = artist_dir.join(storage_name(&row.release_title, "Unknown Release")); + let filename = source + .file_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| std::ffi::OsStr::new("track")); + let destination = + unique_destination(release_dir.join(filename), row.track_id, &mut reserved); + copies.push(CopyOp { + source: source.clone(), + destination: destination.clone(), + }); + track_updates.push(( + row.track_id, + row.track_path, + destination.to_string_lossy().into_owned(), + )); + + if let Some(cover) = row.cover_path { + let cover_source = PathBuf::from(&cover); + let cover_source = std::fs::canonicalize(&cover_source).unwrap_or(cover_source); + if cover_source.is_file() + && managed_art(&cover_source) + && !release_updates.contains_key(&row.release_id) + { + let extension = cover_source.extension().unwrap_or_default(); + let mut destination = release_dir.join("cover"); + destination.set_extension(extension); + let destination = + unique_destination(destination, row.release_id, &mut reserved); + copies.push(CopyOp { + source: cover_source, + destination: destination.clone(), + }); + release_updates.insert( + row.release_id, + (cover, destination.to_string_lossy().into_owned()), + ); + } + } + + if let (Some(artist_id), Some(image)) = (row.artist_id, row.image_path) { + let image_source = PathBuf::from(&image); + let image_source = std::fs::canonicalize(&image_source).unwrap_or(image_source); + if image_source.is_file() + && managed_art(&image_source) + && !artist_updates.contains_key(&artist_id) + { + let extension = image_source.extension().unwrap_or_default(); + let mut destination = artist_dir.join("artist"); + destination.set_extension(extension); + let destination = unique_destination(destination, artist_id, &mut reserved); + copies.push(CopyOp { + source: image_source, + destination: destination.clone(), + }); + artist_updates.insert( + artist_id, + (image, destination.to_string_lossy().into_owned()), + ); + } + } + } + + let mut created = Vec::::new(); + let copy_result = (|| -> Result<()> { + for op in &copies { + let parent = op + .destination + .parent() + .context("music destination has no parent")?; + std::fs::create_dir_all(parent)?; + copy_file_exclusive(&op.source, &op.destination)?; + created.push(op.destination.clone()); + } + Ok(()) + })(); + if let Err(error) = copy_result { + for path in created.iter().rev() { + let _ = std::fs::remove_file(path); + } + return Err(error.context("copying the existing music library")); + } + + let database_result = (|| -> Result<()> { + let mut conn = self.lock(); + let transaction = conn.transaction()?; + for (id, old, new) in &track_updates { + anyhow::ensure!( + transaction.execute( + "UPDATE tracks SET file_path = ?3 WHERE id = ?1 AND file_path = ?2", + params![id, old, new], + )? == 1, + "track {id} changed while the music library was moving" + ); + } + for (id, (old, new)) in &release_updates { + anyhow::ensure!( + transaction.execute( + "UPDATE releases SET cover_path = ?3 WHERE id = ?1 AND cover_path = ?2", + params![id, old, new], + )? == 1, + "release {id} changed while the music library was moving" + ); + } + for (id, (old, new)) in &artist_updates { + anyhow::ensure!( + transaction.execute( + "UPDATE artists SET image_path = ?3 WHERE id = ?1 AND image_path = ?2", + params![id, old, new], + )? == 1, + "artist {id} changed while the music library was moving" + ); + } + transaction.commit()?; + Ok(()) + })(); + if let Err(error) = database_result { + for path in created.iter().rev() { + let _ = std::fs::remove_file(path); + } + return Err(error.context("updating music paths in the library")); + } + + let mut removed = HashSet::new(); + for op in &copies { + if removed.insert(op.source.clone()) + && let Err(error) = std::fs::remove_file(&op.source) + { + // The committed destination is authoritative. A failed old + // delete only leaves a recoverable duplicate. + tracing::warn!(path = %op.source.display(), %error, "old music copy was not removed"); + } + } + remove_empty_directories(&old_root); + + Ok(MusicRelocationStats { + tracks: track_updates.len(), + images: release_updates.len() + artist_updates.len(), + }) + } + + pub fn local_stats(&self) -> Result { + let (artist_count, release_count, track_count, audio_bytes, tracks_without_size) = { + let conn = self.lock(); + conn.query_row( + "SELECT + (SELECT COUNT(*) FROM artists), + (SELECT COUNT(*) FROM releases), + (SELECT COUNT(*) FROM tracks), + (SELECT COALESCE(SUM(CASE + WHEN file_size_bytes IS NOT NULL AND file_size_bytes >= 0 + THEN file_size_bytes ELSE 0 END), 0) FROM tracks), + (SELECT COUNT(*) FROM tracks WHERE file_size_bytes IS NULL) + ", + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }, + )? + }; + Ok(LocalLibraryStats { + artist_count, + release_count, + track_count, + audio_bytes: audio_bytes.max(0) as u64, + tracks_without_size, + cover_bytes: directory_size(&self.covers_dir), + database_bytes: sqlite_database_size(&self.db_path), + }) + } + + /// Make `content_id` a local-library invariant. + /// + /// Old databases can have NULL/invalid ids because the column was added + /// after import already existed. This scans rows cheaply, hashes only the + /// tracks that actually need an id, and never holds the SQLite lock while + /// reading audio files from disk. + pub fn backfill_missing_content_ids(&self) -> Result { + let rows = { + let conn = self.lock(); + let mut statement = + conn.prepare("SELECT id, content_id, file_path FROM tracks ORDER BY id")?; + statement + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()? + }; + + let mut stats = ContentIdBackfillStats { + checked: rows.len(), + ..ContentIdBackfillStats::default() + }; + let mut updates = Vec::new(); + for (track_id, raw_content_id, file_path) in rows { + if let Some(normalized) = raw_content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + { + if raw_content_id.as_deref() != Some(normalized.as_str()) { + updates.push((track_id, normalized)); + stats.normalized += 1; + } + } else if let Some(content_id) = audio_content_id(&file_path) { + updates.push((track_id, content_id)); + stats.hashed += 1; + } else { + stats.failed += 1; + tracing::warn!( + track_id, + path = %file_path, + "content id backfill skipped an unreadable track" + ); + } + + if updates.len() >= 64 { + self.write_content_id_updates(&mut updates)?; + } + } + self.write_content_id_updates(&mut updates)?; + Ok(stats) + } + + fn write_content_id_updates(&self, updates: &mut Vec<(i64, String)>) -> Result<()> { + if updates.is_empty() { + return Ok(()); + } + let mut conn = self.lock(); + let tx = conn.transaction()?; + for (track_id, content_id) in updates.drain(..) { + tx.execute( + "UPDATE tracks SET content_id = ?2 WHERE id = ?1", + params![track_id, content_id], + )?; + } + tx.commit()?; + Ok(()) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Connection> { + self.conn.lock().unwrap_or_else(|poisoned| { + // A panic mid-query leaves the connection usable; keep going. + poisoned.into_inner() + }) + } + + // ----------------------------------------------------------------- + // Reads (same shapes the API used to return) + // ----------------------------------------------------------------- + + pub fn artists(&self, page: i64, limit: i64, filters: LibraryFilters) -> Result { + if !filters.source_mode.includes_network() { + return self.local_artists(page, limit, filters.hide_featured_only); + } + self.merged_artists(page, limit, filters) + } + + fn local_artists( + &self, + page: i64, + limit: i64, + hide_featured_only: bool, + ) -> Result { + let conn = self.lock(); + let hide_featured_only = i64::from(hide_featured_only); + let total: i64 = conn.query_row( + "SELECT COUNT(*) + FROM artists a + WHERE ?1 = 0 + OR EXISTS ( + SELECT 1 + FROM release_artists ra + WHERE ra.artist_id = a.id + )", + [hide_featured_only], + |row| row.get(0), + )?; + let offset = (page.max(1) - 1) * limit; + let mut statement = conn.prepare( + "SELECT a.id, a.name, a.image_path, + (SELECT COUNT(DISTINCT ra.release_id) + FROM release_artists ra + WHERE ra.artist_id = a.id) AS release_count, + (SELECT COUNT(DISTINCT ta.track_id) + FROM track_artists ta + WHERE ta.artist_id = a.id) AS track_count + FROM artists a + WHERE ?1 = 0 + OR EXISTS ( + SELECT 1 + FROM release_artists ra + WHERE ra.artist_id = a.id + ) + ORDER BY release_count DESC, track_count DESC, a.name COLLATE NOCASE + LIMIT ?2 OFFSET ?3", + )?; + let items = statement + .query_map(params![hide_featured_only, limit, offset], |row| { + Ok(ArtistCard { + id: row.get(0)?, + name: row.get(1)?, + image_path: row.get(2)?, + release_count: row.get(3)?, + track_count: row.get(4)?, + availability: Availability::Local, + }) + })? + .collect::>>()?; + let has_more = offset + (items.len() as i64) < total; + Ok(ArtistsPage { + has_more, + items, + total, + page: page.max(1), + }) + } + + fn local_artist_cards(&self, hide_featured_only: bool) -> Result> { + let conn = self.lock(); + let hide_featured_only = i64::from(hide_featured_only); + let mut statement = conn.prepare( + "SELECT a.id, a.name, a.image_path, + (SELECT COUNT(DISTINCT ra.release_id) + FROM release_artists ra + WHERE ra.artist_id = a.id) AS release_count, + (SELECT COUNT(DISTINCT ta.track_id) + FROM track_artists ta + WHERE ta.artist_id = a.id) AS track_count + FROM artists a + WHERE ?1 = 0 + OR EXISTS ( + SELECT 1 + FROM release_artists ra + WHERE ra.artist_id = a.id + )", + )?; + statement + .query_map(params![hide_featured_only], |row| { + Ok(ArtistCard { + id: row.get(0)?, + name: row.get(1)?, + image_path: row.get(2)?, + release_count: row.get(3)?, + track_count: row.get(4)?, + availability: Availability::Local, + }) + })? + .collect::>>() + .map_err(Into::into) + } + + fn merged_artists( + &self, + page: i64, + limit: i64, + filters: LibraryFilters, + ) -> Result { + let mut by_key: HashMap = HashMap::new(); + for artist in self.local_artist_cards(filters.hide_featured_only)? { + let key = music_dht::normalize_name(&artist.name); + if !key.is_empty() { + by_key.insert(key, artist); + } + } + + let conn = self.lock(); + let cutoff = now_ms_i64().saturating_sub(NETWORK_ARTIST_CACHE_TTL_MS); + let source_predicate = if filters.source_mode.includes_global_peers() { + "seen_at_ms >= ?1" + } else { + "seen_at_ms >= ?1 AND source_kind = 'personal'" + }; + let release_predicate = if filters.hide_featured_only { + " AND release_count > 0" + } else { + "" + }; + let sql = format!( + "SELECT artist_key, + COALESCE(NULLIF(MIN(name), ''), artist_key) AS name, + MAX(image_path) AS image_path, + MAX(release_count) AS release_count, + MAX(track_count) AS track_count + FROM network_artist_cache + WHERE {source_predicate}{release_predicate} + GROUP BY artist_key" + ); + let mut statement = conn.prepare(&sql)?; + let rows = statement.query_map([cutoff], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + })?; + for row in rows { + let (artist_key, name, image_path, release_count, track_count) = row?; + if artist_key.trim().is_empty() || track_count <= 0 { + continue; + } + match by_key.get_mut(&artist_key) { + Some(local) => { + if release_count > local.release_count || track_count > local.track_count { + local.availability = Availability::Mixed; + } + local.release_count = local.release_count.max(release_count); + local.track_count = local.track_count.max(track_count); + if local.image_path.is_none() { + local.image_path = image_path; + } + } + None => { + by_key.insert( + artist_key.clone(), + ArtistCard { + id: remote_artist_id(&artist_key), + name, + image_path, + release_count, + track_count, + availability: Availability::Remote, + }, + ); + } + } + } + + let mut items: Vec = by_key.into_values().collect(); + items.sort_by(|left, right| { + right + .release_count + .cmp(&left.release_count) + .then_with(|| right.track_count.cmp(&left.track_count)) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); + let total = items.len() as i64; + let offset = (page.max(1) - 1) * limit; + let page_items = items + .into_iter() + .skip(offset.max(0) as usize) + .take(limit.max(0) as usize) + .collect::>(); + let has_more = offset + (page_items.len() as i64) < total; + Ok(ArtistsPage { + items: page_items, + total, + page: page.max(1), + has_more, + }) + } + + pub fn artist_preview_slice( + &self, + offset: usize, + limit: usize, + ) -> Result<(Vec, Option)> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT a.name, a.image_path, + (SELECT COUNT(DISTINCT ra.release_id) + FROM release_artists ra + WHERE ra.artist_id = a.id) AS release_count, + (SELECT COUNT(DISTINCT ta.track_id) + FROM track_artists ta + WHERE ta.artist_id = a.id) AS track_count + FROM artists a + WHERE EXISTS ( + SELECT 1 FROM track_artists ta WHERE ta.artist_id = a.id + ) + ORDER BY release_count DESC, track_count DESC, a.name COLLATE NOCASE + LIMIT ?1 OFFSET ?2", + )?; + let items = statement + .query_map(params![limit as i64 + 1, offset as i64], |row| { + let name: String = row.get(0)?; + Ok(NetworkArtistPreview { + artist_key: music_dht::normalize_name(&name), + name, + image_path: row.get(1)?, + release_count: row.get(2)?, + track_count: row.get(3)?, + }) + })? + .collect::>>()?; + let has_more = items.len() > limit; + let mut items: Vec<_> = items + .into_iter() + .take(limit) + .filter(|artist| !artist.artist_key.is_empty() && artist.track_count > 0) + .collect(); + let next = has_more.then(|| (offset + items.len()).to_string()); + Ok((std::mem::take(&mut items), next)) + } + + pub fn replace_network_artist_cache( + &self, + source_id: &str, + source_kind: &str, + artists: &[NetworkArtistPreview], + _replace_source: bool, + ) -> Result { + let source_id = source_id.trim(); + let source_kind = source_kind.trim(); + if source_id.is_empty() || source_kind.is_empty() { + return Ok(0); + } + let now = now_ms_i64(); + let mut conn = self.lock(); + let tx = conn.transaction()?; + let mut inserted = 0usize; + { + let mut stmt = tx.prepare( + "INSERT INTO network_artist_cache + (source_id, source_kind, artist_key, name, image_path, remote_image_hint, + release_count, track_count, seen_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(source_id, artist_key) DO UPDATE SET + source_kind = excluded.source_kind, + name = excluded.name, + image_path = COALESCE(network_artist_cache.image_path, excluded.image_path), + remote_image_hint = excluded.remote_image_hint, + release_count = excluded.release_count, + track_count = excluded.track_count, + seen_at_ms = excluded.seen_at_ms", + )?; + for artist in artists { + let artist_key = if artist.artist_key.trim().is_empty() { + music_dht::normalize_name(&artist.name) + } else { + artist.artist_key.clone() + }; + if artist_key.is_empty() || artist.track_count <= 0 { + continue; + } + stmt.execute(params![ + source_id, + source_kind, + artist_key, + artist.name.trim(), + Option::<&str>::None, + artist.image_path.as_deref(), + artist.release_count.max(0), + artist.track_count.max(0), + now, + ])?; + inserted += 1; + } + } + tx.commit()?; + Ok(inserted) + } + + pub fn network_artist_image_requests( + &self, + filters: LibraryFilters, + artist_names: &[String], + limit: usize, + ) -> Result> { + if !filters.source_mode.includes_network() || artist_names.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let conn = self.lock(); + let cutoff = now_ms_i64().saturating_sub(NETWORK_ARTIST_CACHE_TTL_MS); + let source_predicate = if filters.source_mode.includes_global_peers() { + "seen_at_ms >= ?2" + } else { + "seen_at_ms >= ?2 AND source_kind = 'personal'" + }; + let sql = format!( + "SELECT source_id, artist_key, name + FROM network_artist_cache + WHERE artist_key = ?1 + AND {source_predicate} + AND image_path IS NULL + AND remote_image_hint IS NOT NULL + AND remote_image_hint <> '' + ORDER BY CASE source_kind WHEN 'personal' THEN 0 ELSE 1 END, + seen_at_ms DESC + LIMIT 1" + ); + let mut statement = conn.prepare(&sql)?; + let mut seen_keys = HashSet::new(); + let mut requests = Vec::new(); + for name in artist_names { + let artist_key = music_dht::normalize_name(name); + if artist_key.is_empty() || !seen_keys.insert(artist_key.clone()) { + continue; + } + let row = statement + .query_row(params![artist_key, cutoff], |row| { + Ok(NetworkArtistImageRequest { + source_id: row.get(0)?, + artist_key: row.get(1)?, + name: row.get(2)?, + }) + }) + .optional()?; + if let Some(request) = row { + requests.push(request); + if requests.len() >= limit { + break; + } + } + } + Ok(requests) + } + + pub fn set_network_artist_image( + &self, + source_id: &str, + artist_key: &str, + image_path: &str, + ) -> Result { + let changed = self.lock().execute( + "UPDATE network_artist_cache + SET image_path = ?3 + WHERE source_id = ?1 AND artist_key = ?2", + params![source_id, artist_key, image_path], + )?; + Ok(changed > 0) + } + + pub fn artist(&self, id: i64) -> Result { + let conn = self.lock(); + let (name, image_path): (String, Option) = conn + .query_row( + "SELECT name, image_path FROM artists WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .context("artist not found")?; + let total_track_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM track_artists WHERE artist_id = ?1 AND role = 'main'", + [id], + |row| row.get(0), + )?; + let total_play_count: i64 = conn.query_row( + "SELECT + (SELECT COUNT(*) FROM history h + WHERE h.completed = 1 AND h.track_id IN + (SELECT track_id FROM track_artists WHERE artist_id = ?1)) + + + (SELECT COUNT(*) FROM listen_events le + WHERE le.qualified = 1 AND le.local_track_id IN + (SELECT track_id FROM track_artists WHERE artist_id = ?1))", + [id], + |row| row.get(0), + )?; + let top_tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + JOIN track_artists ta ON ta.track_id = t.id + WHERE ta.artist_id = ?1 AND ta.role = 'main' + ORDER BY 17 DESC, t.title COLLATE NOCASE + LIMIT 10" + ), + params![id], + )?; + let featured_tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + JOIN track_artists ta ON ta.track_id = t.id + WHERE ta.artist_id = ?1 AND ta.role = 'featured' + ORDER BY t.title COLLATE NOCASE" + ), + params![id], + )?; + let mut statement = conn.prepare( + "SELECT r.id, r.title, r.release_type, r.year, r.cover_path, + (SELECT COUNT(*) FROM tracks t WHERE t.release_id = r.id) + FROM releases r + JOIN release_artists ra ON ra.release_id = r.id + WHERE ra.artist_id = ?1 + ORDER BY r.year IS NULL, r.year DESC, r.title COLLATE NOCASE", + )?; + let releases = statement + .query_map([id], release_card_from_row)? + .collect::>>()?; + Ok(ArtistDetail { + id, + name, + image_path, + total_track_count, + total_play_count, + top_tracks, + releases, + featured_tracks, + }) + } + + /// Returns every local release in stable library order. + pub fn releases(&self) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT r.id, r.title, r.release_type, r.year, r.cover_path, + (SELECT COUNT(*) FROM tracks t WHERE t.release_id = r.id) + FROM releases r + ORDER BY r.year IS NULL, r.year DESC, r.title COLLATE NOCASE, r.id", + )?; + Ok(statement + .query_map([], release_card_from_row)? + .collect::>>()?) + } + + pub fn release(&self, id: i64) -> Result { + let conn = self.lock(); + let (title, release_type, year, cover_path) = conn + .query_row( + "SELECT title, release_type, year, cover_path FROM releases WHERE id = ?1", + [id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) + .context("release not found")?; + let mut statement = conn.prepare( + "SELECT a.id, a.name FROM artists a + JOIN release_artists ra ON ra.artist_id = a.id + WHERE ra.release_id = ?1 ORDER BY ra.position", + )?; + let artists = statement + .query_map([id], |row| { + Ok(ArtistRef { + id: row.get(0)?, + name: row.get(1)?, + }) + })? + .collect::>>()?; + let tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + WHERE t.release_id = ?1 + ORDER BY t.disc_number IS NULL, t.disc_number, + t.track_number IS NULL, t.track_number, + t.title COLLATE NOCASE" + ), + params![id], + )?; + Ok(ReleaseDetail { + id, + title, + release_type, + year, + cover_path, + artists, + tracks, + }) + } + + pub fn search(&self, query: &str, limit: i64) -> Result { + let query = query.trim(); + if query.is_empty() { + return Ok(SearchResults::default()); + } + // instr over norm() folds case for every script, unlike LIKE/NOCASE + // which only handle ASCII. + let pattern = music_dht::normalize_name(query); + if pattern.is_empty() { + // Punctuation-only queries (e.g. "%") normalize to nothing. + return Ok(SearchResults::default()); + } + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT a.id, a.name, a.image_path, + (SELECT COUNT(DISTINCT ra.release_id) + FROM release_artists ra + WHERE ra.artist_id = a.id), + (SELECT COUNT(DISTINCT ta.track_id) + FROM track_artists ta + WHERE ta.artist_id = a.id) + FROM artists a WHERE instr(norm(a.name), ?1) > 0 + ORDER BY a.name COLLATE NOCASE LIMIT ?2", + )?; + let artists = statement + .query_map(params![pattern, limit], |row| { + Ok(ArtistCard { + id: row.get(0)?, + name: row.get(1)?, + image_path: row.get(2)?, + release_count: row.get(3)?, + track_count: row.get(4)?, + availability: Availability::Local, + }) + })? + .collect::>>()?; + let mut statement = conn.prepare( + "SELECT r.id, r.title, r.release_type, r.year, r.cover_path, + (SELECT COUNT(*) FROM tracks t WHERE t.release_id = r.id) + FROM releases r WHERE instr(norm(r.title), ?1) > 0 + ORDER BY r.title COLLATE NOCASE LIMIT ?2", + )?; + let releases = statement + .query_map(params![pattern, limit], release_card_from_row)? + .collect::>>()?; + let tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + WHERE instr(norm(t.title), ?1) > 0 + ORDER BY t.title COLLATE NOCASE LIMIT ?2" + ), + params![pattern, limit], + )?; + let mut results = SearchResults { + artists, + releases, + tracks, + }; + rank_search_results(&mut results, &pattern); + Ok(results) + } + + /// Everything the federation publishes into the DHT: plain rows, so the + /// federation module needs no SQL of its own. + pub fn federation_export(&self) -> Result { + let conn = self.lock(); + let mut statement = conn.prepare("SELECT id, name FROM artists")?; + let artists = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>>()?; + + let mut statement = conn.prepare( + "SELECT ra.release_id, a.name FROM release_artists ra + JOIN artists a ON a.id = ra.artist_id + ORDER BY ra.release_id, ra.position", + )?; + let mut release_artists: std::collections::HashMap> = Default::default(); + for row in statement.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? { + let (id, name) = row?; + release_artists.entry(id).or_default().push(name); + } + let mut statement = conn.prepare("SELECT id, title, year, release_type FROM releases")?; + let releases = statement + .query_map([], |row| { + Ok(ExportRelease { + id: row.get(0)?, + title: row.get(1)?, + year: row.get(2)?, + release_type: row.get(3)?, + artist_names: Vec::new(), + }) + })? + .collect::>>()? + .into_iter() + .map(|mut release| { + release.artist_names = release_artists.remove(&release.id).unwrap_or_default(); + release + }) + .collect(); + + let mut statement = conn.prepare( + "SELECT ta.track_id, a.name, ta.role FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + WHERE ta.role IN ('main', 'featured') + ORDER BY ta.track_id, + CASE ta.role WHEN 'main' THEN 0 ELSE 1 END, + ta.position", + )?; + let mut track_artists: std::collections::HashMap> = Default::default(); + let mut featured_artists: std::collections::HashMap> = Default::default(); + for row in statement.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? { + let (id, name, role) = row?; + if role == "featured" { + featured_artists.entry(id).or_default().push(name); + } else { + track_artists.entry(id).or_default().push(name); + } + } + let mut statement = conn.prepare( + "SELECT t.id, t.title, r.year, t.duration_seconds, + r.title, r.release_type, t.track_number, t.disc_number, + t.content_id, t.file_path + FROM tracks t JOIN releases r ON r.id = t.release_id", + )?; + let track_rows = statement + .query_map([], |row| { + Ok(( + ExportTrack { + id: row.get(0)?, + title: row.get(1)?, + year: row.get(2)?, + duration_seconds: row.get(3)?, + artist_names: Vec::new(), + featured_artist_names: Vec::new(), + release_title: row.get(4)?, + release_type: row.get(5)?, + track_number: row.get(6)?, + disc_number: row.get(7)?, + content_id: None, + }, + row.get::<_, Option>(8)?, + row.get::<_, String>(9)?, + )) + })? + .collect::>>()?; + drop(statement); + let mut tracks = Vec::with_capacity(track_rows.len()); + for (mut track, raw_content_id, file_path) in track_rows { + let mut content_id = raw_content_id + .as_deref() + .and_then(music_dht::normalize_content_id); + if content_id.is_none() { + content_id = audio_content_id(&file_path); + } + if content_id != raw_content_id { + conn.execute( + "UPDATE tracks SET content_id = ?2 WHERE id = ?1", + params![track.id, content_id.as_deref()], + )?; + } + track.artist_names = track_artists.remove(&track.id).unwrap_or_default(); + track.featured_artist_names = featured_artists.remove(&track.id).unwrap_or_default(); + track.content_id = content_id; + tracks.push(track); + } + + Ok(FederationExport { + artists, + releases, + tracks, + }) + } + + pub fn tracks_by_ids(&self, ids: &[i64]) -> Result> { + let conn = self.lock(); + let mut tracks = Vec::with_capacity(ids.len()); + for &id in ids { + let mut found = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + WHERE t.id = ?1" + ), + params![id], + )?; + tracks.append(&mut found); + } + Ok(tracks) + } + + pub fn track_by_content_id(&self, content_id: &str) -> Result> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(None); + }; + let conn = self.lock(); + let mut tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + WHERE t.content_id = ?1 + LIMIT 1" + ), + params![content_id], + )?; + Ok(tracks.pop()) + } + + // ----------------------------------------------------------------- + // Similarity embeddings. SQLite is the canonical store; callers build + // replaceable in-memory indexes from these rows. + // ----------------------------------------------------------------- + + pub fn ensure_similarity_profile( + &self, + profile_id: &str, + model_id: &str, + model_version: &str, + model_sha256: &str, + preprocessing: &str, + dimensions: usize, + ) -> Result<()> { + let conn = self.lock(); + conn.execute( + "INSERT INTO similarity_profiles ( + profile_id, model_id, model_version, model_sha256, + preprocessing, dimensions, created_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(profile_id) DO NOTHING", + params![ + profile_id, + model_id, + model_version, + model_sha256, + preprocessing, + dimensions as i64, + now_ms_i64(), + ], + )?; + Ok(()) + } + + pub fn pending_similarity_tracks(&self, profile_id: &str) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT t.id, t.title, t.file_path, t.content_id, t.duration_seconds + FROM tracks t + WHERE NOT EXISTS ( + SELECT 1 FROM track_embeddings e + WHERE e.track_id = t.id + AND e.profile_id = ?1 + AND e.source_content_id IS t.content_id + ) + ORDER BY t.id", + )?; + Ok(statement + .query_map([profile_id], |row| { + Ok(SimilarityTrack { + id: row.get(0)?, + title: row.get(1)?, + file_path: row.get(2)?, + content_id: row.get(3)?, + duration_seconds: row.get(4)?, + }) + })? + .collect::>>()?) + } + + pub fn store_similarity_embedding( + &self, + track: &SimilarityTrack, + profile_id: &str, + vector: &[f32], + ) -> Result<()> { + anyhow::ensure!(!vector.is_empty(), "embedding vector is empty"); + anyhow::ensure!( + vector.iter().all(|value| value.is_finite()), + "embedding contains a non-finite value" + ); + let bytes = embedding_to_bytes(vector); + let routing_signature = music_dht::similarity_lsh::routing_signature(vector)?; + let conn = self.lock(); + conn.execute( + "INSERT INTO track_embeddings ( + track_id, profile_id, dimensions, vector, routing_signature, + source_content_id, computed_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(track_id, profile_id) DO UPDATE SET + dimensions = excluded.dimensions, + vector = excluded.vector, + routing_signature = excluded.routing_signature, + source_content_id = excluded.source_content_id, + computed_at_ms = excluded.computed_at_ms", + params![ + track.id, + profile_id, + vector.len() as i64, + bytes, + routing_signature.as_slice(), + track.content_id.as_deref(), + now_ms_i64(), + ], + )?; + Ok(()) + } + + pub fn similarity_embedding( + &self, + track_id: i64, + profile_id: &str, + ) -> Result>> { + let conn = self.lock(); + let row = conn + .query_row( + "SELECT e.dimensions, e.vector + FROM track_embeddings e + JOIN tracks t ON t.id = e.track_id + WHERE e.track_id = ?1 + AND e.profile_id = ?2 + AND e.source_content_id IS t.content_id", + params![track_id, profile_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)), + ) + .optional()?; + row.map(|(dimensions, bytes)| embedding_from_bytes(dimensions, &bytes)) + .transpose() + } + + pub fn load_similarity_index(&self, profile_id: &str) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT e.track_id, e.dimensions, e.vector, + COALESCE(( + SELECT norm(a.name) + FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + WHERE ta.track_id = e.track_id AND ta.role = 'main' + ORDER BY ta.position LIMIT 1 + ), '') + FROM track_embeddings e + JOIN tracks t ON t.id = e.track_id + WHERE e.profile_id = ?1 + AND e.source_content_id IS t.content_id + ORDER BY e.track_id", + )?; + let rows = statement.query_map([profile_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Vec>(2)?, + row.get::<_, String>(3)?, + )) + })?; + let mut embeddings = Vec::new(); + for row in rows { + let (track_id, dimensions, bytes, artist_key) = row?; + embeddings.push(StoredEmbedding { + track_id, + vector: embedding_from_bytes(dimensions, &bytes)?, + artist_key, + }); + } + Ok(embeddings) + } + + /// Loads the compact DHT-routing signatures for every current local + /// embedding. Rows created before similarity routing existed are + /// backfilled in place from their durable vectors. + pub fn similarity_routing_signatures(&self, profile_id: &str) -> Result> { + let mut conn = self.lock(); + let transaction = conn.transaction()?; + let missing = { + let mut statement = transaction.prepare( + "SELECT e.track_id, e.dimensions, e.vector + FROM track_embeddings e + JOIN tracks t ON t.id = e.track_id + WHERE e.profile_id = ?1 + AND e.source_content_id IS t.content_id + AND (e.routing_signature IS NULL OR length(e.routing_signature) != 32) + ORDER BY e.track_id", + )?; + statement + .query_map([profile_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Vec>(2)?, + )) + })? + .collect::>>()? + }; + for (track_id, dimensions, vector_bytes) in missing { + let vector = embedding_from_bytes(dimensions, &vector_bytes)?; + let signature = music_dht::similarity_lsh::routing_signature(&vector)?; + transaction.execute( + "UPDATE track_embeddings + SET routing_signature = ?3 + WHERE track_id = ?1 AND profile_id = ?2", + params![track_id, profile_id, signature.as_slice()], + )?; + } + transaction.commit()?; + + let mut statement = conn.prepare( + "SELECT e.routing_signature + FROM track_embeddings e + JOIN tracks t ON t.id = e.track_id + WHERE e.profile_id = ?1 + AND e.source_content_id IS t.content_id + ORDER BY e.track_id", + )?; + let stored = statement + .query_map([profile_id], |row| row.get::<_, Vec>(0))? + .collect::>>()?; + let signatures = stored + .into_iter() + .map(|signature| { + <[u8; 32]>::try_from(signature) + .map_err(|_| anyhow::anyhow!("invalid similarity routing signature length")) + }) + .collect::>>()?; + Ok(signatures) + } + + pub fn similarity_storage_stats(&self, profile_id: &str) -> Result { + let conn = self.lock(); + let total_tracks = conn.query_row("SELECT COUNT(*) FROM tracks", [], |row| { + row.get::<_, i64>(0) + })?; + let embedded_tracks = conn.query_row( + "SELECT COUNT(*) + FROM track_embeddings e JOIN tracks t ON t.id = e.track_id + WHERE e.profile_id = ?1 AND e.source_content_id IS t.content_id", + [profile_id], + |row| row.get::<_, i64>(0), + )?; + let (stored_vectors, stored_bytes) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(length(vector)), 0) FROM track_embeddings", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + )?; + Ok(SimilarityStorageStats { + total_tracks: total_tracks.max(0) as usize, + embedded_tracks: embedded_tracks.max(0) as usize, + stored_vectors: stored_vectors.max(0) as usize, + stored_bytes: stored_bytes.max(0) as u64, + }) + } + + pub fn clear_similarity_embeddings(&self) -> Result<()> { + let conn = self.lock(); + conn.execute("DELETE FROM track_embeddings", [])?; + conn.execute("DELETE FROM similarity_profiles", [])?; + Ok(()) + } + + // ----------------------------------------------------------------- + // Playlists & likes + // ----------------------------------------------------------------- + + pub fn playlists(&self) -> Result> { + let conn = self.lock(); + let liked: i64 = conn.query_row( + "SELECT (SELECT COUNT(*) FROM likes) + (SELECT COUNT(*) FROM fed_likes)", + [], + |row| row.get(0), + )?; + let mut list = vec![PlaylistCard { + id: LIKES_PLAYLIST_ID, + title: "Liked tracks".to_string(), + track_count: liked, + kind: "likes".to_string(), + }]; + let mut statement = conn.prepare( + "SELECT p.id, p.title, + (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlist_id = p.id) + + + (SELECT COUNT(*) FROM fed_playlist_tracks f + WHERE f.playlist_sync_id = p.sync_id + AND NOT EXISTS ( + SELECT 1 FROM playlist_tracks pt + JOIN tracks t ON t.id = pt.track_id + WHERE pt.playlist_id = p.id + AND t.content_id = f.content_id + )) + FROM playlists p ORDER BY p.title COLLATE NOCASE", + )?; + let rows = statement + .query_map([], |row| { + Ok(PlaylistCard { + id: row.get(0)?, + title: row.get(1)?, + track_count: row.get(2)?, + kind: "normal".to_string(), + }) + })? + .collect::>>()?; + list.extend(rows); + Ok(list) + } + + pub fn playlist(&self, id: i64) -> Result { + let conn = self.lock(); + if id == LIKES_PLAYLIST_ID { + let local_tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + JOIN likes k ON k.track_id = t.id" + ), + params![], + )?; + let mut liked_at_by_track = HashMap::new(); + let mut liked_stmt = conn.prepare( + "SELECT track_id, + COALESCE(liked_hlc_ms, + CAST(strftime('%s', liked_at) AS INTEGER) * 1000, + 0) + FROM likes", + )?; + let liked_rows = liked_stmt + .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?; + for row in liked_rows { + let (track_id, liked_at) = row?; + liked_at_by_track.insert(track_id, liked_at); + } + drop(liked_stmt); + + let mut entries: Vec<(i64, String, TrackItem)> = local_tracks + .into_iter() + .map(|track| { + let liked_at = liked_at_by_track + .get(&track.id) + .copied() + .unwrap_or_default(); + let stable_key = track + .content_id + .clone() + .unwrap_or_else(|| format!("local:{:020}", track.id)); + (liked_at, stable_key, track) + }) + .collect(); + + let mut fed_stmt = conn.prepare( + "SELECT COALESCE(liked_hlc_ms, + CAST(strftime('%s', liked_at) AS INTEGER) * 1000, + 0), + item_id, owner, title, artist_names, featured_artist_names, + year, duration_seconds, content_id, release_title, track_number, disc_number + FROM fed_likes", + )?; + let fed_rows = fed_stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, fed_track_from_offset_row(row, 1)?)) + })?; + for row in fed_rows { + let (liked_at, fed) = row?; + let stable_key = fed + .content_id + .clone() + .unwrap_or_else(|| format!("fed:{}", fed.item_id)); + entries.push((liked_at, stable_key, pending_track(&fed))); + } + entries.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + return Ok(PlaylistDetail { + id, + title: "Liked tracks".to_string(), + description: None, + tracks: entries.into_iter().map(|(_, _, track)| track).collect(), + }); + } + let (title, description, sync_id): (String, Option, Option) = conn + .query_row( + "SELECT title, description, sync_id FROM playlists WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .context("playlist not found")?; + let local_tracks = query_tracks( + &conn, + &format!( + "SELECT {TRACK_COLUMNS} FROM tracks t + JOIN releases r ON r.id = t.release_id + JOIN playlist_tracks pt ON pt.track_id = t.id + WHERE pt.playlist_id = ?1 + ORDER BY pt.position" + ), + params![id], + )?; + let mut positions = HashMap::new(); + let mut position_stmt = + conn.prepare("SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ?1")?; + let position_rows = position_stmt.query_map([id], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + })?; + for row in position_rows { + let (track_id, position) = row?; + positions.insert(track_id, position); + } + drop(position_stmt); + let mut entries: Vec<(i64, TrackItem)> = local_tracks + .into_iter() + .enumerate() + .map(|(index, track)| { + let position = positions.get(&track.id).copied().unwrap_or(index as i64); + (position, track) + }) + .collect(); + if let Some(sync_id) = sync_id.as_deref() { + for (position, fed) in fed_playlist_tracks(&conn, id, sync_id)? { + entries.push((position, pending_track(&fed))); + } + } + entries.sort_by(|(left_pos, left_track), (right_pos, right_track)| { + left_pos + .cmp(right_pos) + .then_with(|| left_track.title.cmp(&right_track.title)) + }); + Ok(PlaylistDetail { + id, + title, + description, + tracks: entries.into_iter().map(|(_, track)| track).collect(), + }) + } + + pub fn create_playlist(&self, title: &str) -> Result { + let conn = self.lock(); + conn.execute("INSERT INTO playlists (title) VALUES (?1)", [title])?; + let id = conn.last_insert_rowid(); + let sync_id = make_playlist_sync_id(id, title); + conn.execute( + "UPDATE playlists SET sync_id = ?2 WHERE id = ?1", + params![id, sync_id], + )?; + Ok(PlaylistCard { + id, + title: title.to_string(), + track_count: 0, + kind: "normal".to_string(), + }) + } + + pub fn update_playlist(&self, id: i64, title: &str, description: Option<&str>) -> Result<()> { + let conn = self.lock(); + conn.execute( + "UPDATE playlists SET title = ?2, description = ?3 WHERE id = ?1", + params![id, title, description], + )?; + Ok(()) + } + + pub fn delete_playlist(&self, id: i64) -> Result<()> { + let conn = self.lock(); + let sync_id: Option = conn + .query_row("SELECT sync_id FROM playlists WHERE id = ?1", [id], |row| { + row.get::<_, Option>(0) + }) + .optional()? + .flatten(); + if let Some(sync_id) = sync_id { + conn.execute( + "DELETE FROM fed_playlist_tracks WHERE playlist_sync_id = ?1", + [sync_id], + )?; + } + conn.execute("DELETE FROM playlists WHERE id = ?1", [id])?; + Ok(()) + } + + pub fn delete_playlist_by_sync_id(&self, sync_id: &str) -> Result<()> { + let conn = self.lock(); + conn.execute( + "DELETE FROM fed_playlist_tracks WHERE playlist_sync_id = ?1", + [sync_id], + )?; + conn.execute("DELETE FROM playlists WHERE sync_id = ?1", [sync_id])?; + Ok(()) + } + + pub fn playlist_sync_id(&self, id: i64) -> Result> { + let conn = self.lock(); + Ok(conn + .query_row("SELECT sync_id FROM playlists WHERE id = ?1", [id], |row| { + row.get(0) + }) + .optional()?) + } + + pub fn ensure_playlist_sync_id(&self, id: i64) -> Result { + let conn = self.lock(); + let existing: Option = conn + .query_row("SELECT sync_id FROM playlists WHERE id = ?1", [id], |row| { + row.get(0) + }) + .optional()? + .flatten(); + if let Some(sync_id) = existing { + return Ok(sync_id); + } + let title: String = + conn.query_row("SELECT title FROM playlists WHERE id = ?1", [id], |row| { + row.get(0) + })?; + let sync_id = make_playlist_sync_id(id, &title); + conn.execute( + "UPDATE playlists SET sync_id = ?2 WHERE id = ?1", + params![id, sync_id], + )?; + Ok(sync_id) + } + + pub fn upsert_synced_playlist(&self, sync_id: &str, title: &str) -> Result { + let conn = self.lock(); + if let Some(id) = conn + .query_row( + "SELECT id FROM playlists WHERE sync_id = ?1", + [sync_id], + |row| row.get(0), + ) + .optional()? + { + conn.execute( + "UPDATE playlists SET title = ?2 WHERE id = ?1", + params![id, title], + )?; + return Ok(id); + } + conn.execute( + "INSERT INTO playlists (sync_id, title) VALUES (?1, ?2)", + params![sync_id, title], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn add_tracks_to_playlist(&self, playlist_id: i64, track_ids: &[i64]) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + let mut next: i64 = tx.query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1", + [playlist_id], + |row| row.get(0), + )?; + for &track_id in track_ids { + let inserted = tx.execute( + "INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) + VALUES (?1, ?2, ?3)", + params![playlist_id, track_id, next], + )?; + if inserted > 0 { + next += 1; + } + } + tx.commit()?; + Ok(()) + } + + pub fn add_fed_tracks_to_playlist( + &self, + playlist_id: i64, + tracks: &[FederatedTrack], + ) -> Result<()> { + if tracks.is_empty() { + return Ok(()); + } + let mut conn = self.lock(); + let tx = conn.transaction()?; + let playlist_sync_id: Option = tx + .query_row( + "SELECT sync_id FROM playlists WHERE id = ?1", + [playlist_id], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten(); + let playlist_sync_id = match playlist_sync_id { + Some(sync_id) => sync_id, + None => { + let title: String = tx.query_row( + "SELECT title FROM playlists WHERE id = ?1", + [playlist_id], + |row| row.get(0), + )?; + let sync_id = make_playlist_sync_id(playlist_id, &title); + tx.execute( + "UPDATE playlists SET sync_id = ?2 WHERE id = ?1", + params![playlist_id, sync_id], + )?; + sync_id + } + }; + let local_max: i64 = tx.query_row( + "SELECT COALESCE(MAX(position), -1) FROM playlist_tracks WHERE playlist_id = ?1", + [playlist_id], + |row| row.get(0), + )?; + let fed_max: i64 = tx.query_row( + "SELECT COALESCE(MAX(position), -1) FROM fed_playlist_tracks WHERE playlist_sync_id = ?1", + [&playlist_sync_id], + |row| row.get(0), + )?; + let mut next = local_max.max(fed_max) + 1; + for fed in tracks { + let Some(content_id) = fed + .content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + else { + continue; + }; + let existing_position: Option = tx + .query_row( + "SELECT position FROM fed_playlist_tracks + WHERE playlist_sync_id = ?1 AND content_id = ?2", + params![playlist_sync_id, content_id], + |row| row.get(0), + ) + .optional()?; + let position = match existing_position { + Some(position) => position, + None => { + let position = next; + next += 1; + position + } + }; + tx.execute( + "INSERT INTO fed_playlist_tracks + (playlist_sync_id, item_id, owner, title, artist_names, + featured_artist_names, year, duration_seconds, content_id, + release_title, track_number, disc_number, position) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(playlist_sync_id, content_id) DO UPDATE SET + item_id = excluded.item_id, + owner = excluded.owner, + title = excluded.title, + artist_names = excluded.artist_names, + featured_artist_names = excluded.featured_artist_names, + year = excluded.year, + duration_seconds = excluded.duration_seconds, + release_title = excluded.release_title, + track_number = excluded.track_number, + disc_number = excluded.disc_number, + position = excluded.position", + params![ + playlist_sync_id, + fed.item_id, + fed.owner, + fed.title, + fed.artist_names.join("; "), + fed.featured_artist_names.join("; "), + fed.year, + fed.duration_seconds.map(|d| d as f64), + content_id, + fed.release_title, + fed.track_number, + fed.disc_number, + position, + ], + )?; + } + tx.commit()?; + Ok(()) + } + + pub fn remove_tracks_from_playlist(&self, playlist_id: i64, track_ids: &[i64]) -> Result<()> { + let conn = self.lock(); + for &track_id in track_ids { + conn.execute( + "DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND track_id = ?2", + params![playlist_id, track_id], + )?; + } + Ok(()) + } + + pub fn remove_content_ids_from_playlist( + &self, + playlist_id: i64, + content_ids: &[String], + ) -> Result<()> { + let conn = self.lock(); + let playlist_sync_id: Option = conn + .query_row( + "SELECT sync_id FROM playlists WHERE id = ?1", + [playlist_id], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten(); + for content_id in content_ids { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + continue; + }; + conn.execute( + "DELETE FROM playlist_tracks + WHERE playlist_id = ?1 + AND track_id IN ( + SELECT id FROM tracks WHERE content_id = ?2 + )", + params![playlist_id, content_id], + )?; + if let Some(sync_id) = playlist_sync_id.as_deref() { + conn.execute( + "DELETE FROM fed_playlist_tracks + WHERE playlist_sync_id = ?1 AND content_id = ?2", + params![sync_id, content_id], + )?; + } + } + Ok(()) + } + + pub fn track_content_id_by_id(&self, track_id: i64) -> Result> { + let conn = self.lock(); + let content_id: Option = conn + .query_row( + "SELECT content_id FROM tracks WHERE id = ?1", + [track_id], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten() + .and_then(|value| music_dht::normalize_content_id(&value)); + Ok(content_id) + } + + pub fn playlist_track_content_positions( + &self, + playlist_id: i64, + track_ids: &[i64], + ) -> Result> { + let conn = self.lock(); + let mut out = Vec::new(); + for &track_id in track_ids { + let row: Option<(Option, i64)> = conn + .query_row( + "SELECT t.content_id, pt.position + FROM tracks t + JOIN playlist_tracks pt ON pt.track_id = t.id + WHERE t.id = ?1 AND pt.playlist_id = ?2", + params![track_id, playlist_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + let Some((content_id, position)) = row else { + continue; + }; + if let Some(content_id) = content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + { + out.push((content_id, position)); + } + } + Ok(out) + } + + pub fn playlist_content_position( + &self, + playlist_id: i64, + content_id: &str, + ) -> Result> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(None); + }; + let conn = self.lock(); + let local_position = conn + .query_row( + "SELECT pt.position + FROM playlist_tracks pt + JOIN tracks t ON t.id = pt.track_id + WHERE pt.playlist_id = ?1 AND t.content_id = ?2 + LIMIT 1", + params![playlist_id, content_id], + |row| row.get(0), + ) + .optional()?; + if local_position.is_some() { + return Ok(local_position); + } + let sync_id: Option = conn + .query_row( + "SELECT sync_id FROM playlists WHERE id = ?1", + [playlist_id], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten(); + let Some(sync_id) = sync_id else { + return Ok(None); + }; + Ok(conn + .query_row( + "SELECT position + FROM fed_playlist_tracks + WHERE playlist_sync_id = ?1 AND content_id = ?2 + LIMIT 1", + params![sync_id, content_id], + |row| row.get(0), + ) + .optional()?) + } + + pub fn track_id_by_content_id(&self, content_id: &str) -> Result> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(None); + }; + let conn = self.lock(); + Ok(conn + .query_row( + "SELECT id FROM tracks WHERE content_id = ?1 LIMIT 1", + [content_id], + |row| row.get(0), + ) + .optional()?) + } + + pub fn liked_content_ids(&self) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT DISTINCT t.content_id + FROM likes k + JOIN tracks t ON t.id = k.track_id + WHERE t.content_id IS NOT NULL", + )?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + Ok(rows + .into_iter() + .filter_map(|content_id| music_dht::normalize_content_id(&content_id)) + .collect()) + } + + pub fn local_content_ids(&self) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT DISTINCT content_id + FROM tracks + WHERE content_id IS NOT NULL", + )?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + Ok(rows + .into_iter() + .filter_map(|content_id| music_dht::normalize_content_id(&content_id)) + .collect()) + } + + /// Returns the new liked state for this content id. + pub fn toggle_like_by_content_id(&self, content_id: &str) -> Result { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + anyhow::bail!("invalid content id"); + }; + let mut conn = self.lock(); + let tx = conn.transaction()?; + let removed_local = tx.execute( + "DELETE FROM likes + WHERE track_id IN ( + SELECT id FROM tracks WHERE content_id = ?1 + )", + [&content_id], + )?; + let removed_fed = + tx.execute("DELETE FROM fed_likes WHERE content_id = ?1", [&content_id])?; + if removed_local + removed_fed > 0 { + tx.commit()?; + return Ok(false); + } + let track_id: i64 = tx + .query_row( + "SELECT id FROM tracks WHERE content_id = ?1 ORDER BY id LIMIT 1", + [&content_id], + |row| row.get(0), + ) + .optional()? + .with_context(|| format!("no local track with content id {content_id}"))?; + tx.execute("INSERT INTO likes (track_id) VALUES (?1)", [track_id])?; + tx.commit()?; + Ok(true) + } + + pub fn set_synced_like(&self, track_id: i64, liked: bool, liked_hlc_ms: i64) -> Result { + let conn = self.lock(); + let changed = if liked { + conn.execute( + "INSERT INTO likes (track_id, liked_at, liked_hlc_ms) + VALUES (?1, datetime(?2 / 1000, 'unixepoch'), ?2) + ON CONFLICT(track_id) DO UPDATE SET + liked_at = excluded.liked_at, + liked_hlc_ms = excluded.liked_hlc_ms", + params![track_id, liked_hlc_ms], + )? + } else { + conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])? + }; + Ok(changed > 0) + } + + pub fn upsert_fed_playlist_track( + &self, + playlist_sync_id: &str, + fed: &FederatedTrack, + position: i64, + ) -> Result { + let Some(content_id) = fed + .content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + else { + return Ok(false); + }; + let conn = self.lock(); + Ok(conn.execute( + "INSERT INTO fed_playlist_tracks + (playlist_sync_id, item_id, owner, title, artist_names, + featured_artist_names, year, duration_seconds, content_id, + release_title, track_number, disc_number, position) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(playlist_sync_id, content_id) DO UPDATE SET + item_id = excluded.item_id, + owner = excluded.owner, + title = excluded.title, + artist_names = excluded.artist_names, + featured_artist_names = excluded.featured_artist_names, + year = excluded.year, + duration_seconds = excluded.duration_seconds, + release_title = excluded.release_title, + track_number = excluded.track_number, + disc_number = excluded.disc_number, + position = excluded.position", + params![ + playlist_sync_id, + fed.item_id, + fed.owner, + fed.title, + fed.artist_names.join("; "), + fed.featured_artist_names.join("; "), + fed.year, + fed.duration_seconds.map(|d| d as f64), + content_id, + fed.release_title, + fed.track_number, + fed.disc_number, + position, + ], + )? > 0) + } + + pub fn fed_playlist_track_by_content_id( + &self, + playlist_sync_id: &str, + content_id: &str, + ) -> Result> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(None); + }; + let conn = self.lock(); + conn.query_row( + "SELECT item_id, owner, title, artist_names, featured_artist_names, + year, duration_seconds, content_id, release_title, track_number, disc_number + FROM fed_playlist_tracks + WHERE playlist_sync_id = ?1 AND content_id = ?2 + LIMIT 1", + params![playlist_sync_id, content_id], + fed_track_from_row, + ) + .optional() + .map_err(Into::into) + } + + pub fn has_playlist_content_reference( + &self, + playlist_sync_id: &str, + content_id: &str, + ) -> Result { + if self.track_id_by_content_id(content_id)?.is_some() { + return Ok(true); + } + Ok(self + .fed_playlist_track_by_content_id(playlist_sync_id, content_id)? + .is_some()) + } + + pub fn add_content_id_to_synced_playlist( + &self, + playlist_sync_id: &str, + content_id: &str, + ) -> Result { + let Some(track_id) = self.track_id_by_content_id(content_id)? else { + return Ok(false); + }; + let conn = self.lock(); + let playlist_id: Option = conn + .query_row( + "SELECT id FROM playlists WHERE sync_id = ?1", + [playlist_sync_id], + |row| row.get::<_, i64>(0), + ) + .optional()?; + let Some(playlist_id) = playlist_id else { + return Ok(false); + }; + let next: i64 = conn.query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1", + [playlist_id], + |row| row.get(0), + )?; + conn.execute( + "INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) + VALUES (?1, ?2, ?3)", + params![playlist_id, track_id, next], + )?; + Ok(true) + } + + pub fn remove_content_id_from_synced_playlist( + &self, + playlist_sync_id: &str, + content_id: &str, + ) -> Result<()> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(()); + }; + let conn = self.lock(); + let playlist_id: Option = conn + .query_row( + "SELECT id FROM playlists WHERE sync_id = ?1", + [playlist_sync_id], + |row| row.get::<_, i64>(0), + ) + .optional()?; + if let Some(playlist_id) = playlist_id { + let track_id: Option = conn + .query_row( + "SELECT id FROM tracks WHERE content_id = ?1 LIMIT 1", + [&content_id], + |row| row.get(0), + ) + .optional()?; + if let Some(track_id) = track_id { + conn.execute( + "DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND track_id = ?2", + params![playlist_id, track_id], + )?; + } + } + conn.execute( + "DELETE FROM fed_playlist_tracks + WHERE playlist_sync_id = ?1 AND content_id = ?2", + params![playlist_sync_id, content_id], + )?; + Ok(()) + } + + /// Item ids and content ids of every liked federated track (for the ♥ markers). + pub fn fed_like_ids(&self) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare("SELECT item_id, content_id FROM fed_likes")?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) + })? + .collect::>>()?; + let mut keys = Vec::new(); + for (item_id, content_id) in rows { + keys.push(item_id); + if let Some(content_id) = content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + { + keys.push(content_id); + } + } + Ok(keys) + } + + /// Toggles a like on a federated track; returns the resulting state. + pub fn toggle_fed_like(&self, fed: &FederatedTrack) -> Result { + let content_id = fed + .content_id + .as_deref() + .and_then(music_dht::normalize_content_id); + let mut conn = self.lock(); + let tx = conn.transaction()?; + let removed = match content_id.as_deref() { + Some(content_id) => { + let removed_fed = tx.execute( + "DELETE FROM fed_likes WHERE item_id = ?1 OR content_id = ?2", + params![fed.item_id, content_id], + )?; + let removed_local = tx.execute( + "DELETE FROM likes + WHERE track_id IN ( + SELECT id FROM tracks WHERE content_id = ?1 + )", + [content_id], + )?; + removed_fed + removed_local + } + None => tx.execute("DELETE FROM fed_likes WHERE item_id = ?1", [&fed.item_id])?, + }; + if removed > 0 { + tx.commit()?; + return Ok(false); + } + if let Some(content_id) = content_id.as_deref() { + tx.execute( + "DELETE FROM fed_likes WHERE content_id = ?1 AND item_id != ?2", + params![content_id, fed.item_id], + )?; + } + tx.execute( + "INSERT INTO fed_likes (item_id, owner, title, artist_names, + featured_artist_names, year, duration_seconds, content_id, + release_title, track_number, disc_number) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + fed.item_id, + fed.owner, + fed.title, + fed.artist_names.join("; "), + fed.featured_artist_names.join("; "), + fed.year, + fed.duration_seconds.map(|d| d as f64), + content_id, + fed.release_title, + fed.track_number, + fed.disc_number, + ], + )?; + tx.commit()?; + Ok(true) + } + + pub fn fed_like_by_content_id(&self, content_id: &str) -> Result> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(None); + }; + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT item_id, owner, title, artist_names, featured_artist_names, + year, duration_seconds, content_id, release_title, track_number, disc_number + FROM fed_likes + WHERE content_id = ?1 + ORDER BY COALESCE(liked_hlc_ms, + CAST(strftime('%s', liked_at) AS INTEGER) * 1000, + 0) DESC + LIMIT 1", + )?; + let track = statement + .query_row([content_id], |row| { + let artists: String = row.get(3)?; + Ok(FederatedTrack { + item_id: row.get(0)?, + owner: row.get(1)?, + own: false, + title: row.get(2)?, + artist_names: artists + .split("; ") + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(), + featured_artist_names: row + .get::<_, String>(4)? + .split("; ") + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(), + year: row.get(5)?, + duration_seconds: row.get::<_, Option>(6)?.map(|d| d.round() as i64), + content_id: row.get(7)?, + release_title: row.get(8)?, + track_number: row.get(9)?, + disc_number: row.get(10)?, + }) + }) + .optional()?; + Ok(track) + } + + pub fn upsert_synced_fed_like(&self, fed: &FederatedTrack, liked_hlc_ms: i64) -> Result { + let Some(content_id) = fed + .content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + else { + return Ok(false); + }; + let conn = self.lock(); + let duplicate_rows = conn.execute( + "DELETE FROM fed_likes WHERE content_id = ?1 AND item_id != ?2", + params![content_id, fed.item_id], + )?; + let existing: Option = conn + .query_row( + "SELECT owner, title, artist_names, featured_artist_names, + year, duration_seconds, release_title, track_number, disc_number, + liked_hlc_ms + FROM fed_likes + WHERE item_id = ?1", + [&fed.item_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get::<_, Option>(5)?.map(|d| d.round() as i64), + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + )) + }, + ) + .optional()?; + let incoming = ( + fed.owner.clone(), + fed.title.clone(), + fed.artist_names.join("; "), + fed.featured_artist_names.join("; "), + fed.year, + fed.duration_seconds, + fed.release_title.clone(), + fed.track_number, + fed.disc_number, + Some(liked_hlc_ms), + ); + if duplicate_rows == 0 && existing.as_ref() == Some(&incoming) { + return Ok(false); + } + conn.execute( + "INSERT INTO fed_likes (item_id, owner, title, artist_names, + featured_artist_names, year, duration_seconds, content_id, + release_title, track_number, disc_number, liked_at, liked_hlc_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, + datetime(?12 / 1000, 'unixepoch'), ?12) + ON CONFLICT(item_id) DO UPDATE SET + owner = excluded.owner, + title = excluded.title, + artist_names = excluded.artist_names, + featured_artist_names = excluded.featured_artist_names, + year = excluded.year, + duration_seconds = excluded.duration_seconds, + content_id = excluded.content_id, + release_title = excluded.release_title, + track_number = excluded.track_number, + disc_number = excluded.disc_number, + liked_at = excluded.liked_at, + liked_hlc_ms = excluded.liked_hlc_ms", + params![ + fed.item_id, + fed.owner, + fed.title, + fed.artist_names.join("; "), + fed.featured_artist_names.join("; "), + fed.year, + fed.duration_seconds.map(|d| d as f64), + content_id, + fed.release_title, + fed.track_number, + fed.disc_number, + liked_hlc_ms, + ], + )?; + Ok(true) + } + + pub fn remove_fed_like_by_content_id(&self, content_id: &str) -> Result { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(false); + }; + let conn = self.lock(); + Ok(conn.execute("DELETE FROM fed_likes WHERE content_id = ?1", [content_id])? > 0) + } + + /// Moves a federated like onto a freshly imported local track. Returns + /// whether a transfer happened. + pub fn transfer_fed_like(&self, item_id: &str, track_id: i64) -> Result { + let conn = self.lock(); + let liked: Option<(String, Option)> = conn + .query_row( + "SELECT liked_at, liked_hlc_ms FROM fed_likes WHERE item_id = ?1", + [item_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + let removed = conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [item_id])?; + if removed == 0 { + return Ok(false); + } + if let Some((liked_at, liked_hlc_ms)) = liked { + conn.execute( + "INSERT INTO likes (track_id, liked_at, liked_hlc_ms) + VALUES (?1, ?2, ?3) + ON CONFLICT(track_id) DO UPDATE SET + liked_at = excluded.liked_at, + liked_hlc_ms = excluded.liked_hlc_ms", + params![track_id, liked_at, liked_hlc_ms], + )?; + } else { + conn.execute( + "INSERT OR IGNORE INTO likes (track_id) VALUES (?1)", + [track_id], + )?; + } + Ok(true) + } + + /// Idempotently materialize a portable trusted-device listen event. + pub fn apply_listen_event( + &self, + event: &music_dht::device_sync::ListenEvent, + origin_device_id: &str, + ) -> Result { + if !event.should_record() || origin_device_id.trim().is_empty() { + return Ok(false); + } + let content_id = music_dht::normalize_content_id(&event.content_id) + .context("invalid listen content id")?; + let conn = self.lock(); + let local_track_id: Option = conn + .query_row( + "SELECT id FROM tracks WHERE content_id = ?1 LIMIT 1", + [&content_id], + |row| row.get(0), + ) + .optional()?; + let inserted = conn.execute( + "INSERT OR IGNORE INTO listen_events + (listen_id, content_id, local_track_id, origin_device_id, + started_at_ms, listened_ms, track_duration_ms, ended_reason, + qualified, metadata_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + event.listen_id, + content_id, + local_track_id, + origin_device_id, + event.started_at_ms, + event.listened_ms, + event.track_duration_ms, + serde_json::to_string(&event.ended_reason)?, + i64::from(event.qualifies_as_play()), + serde_json::to_string(&event.track)?, + ], + )?; + Ok(inserted > 0) + } + + /// Most recent qualified listens, including tracks that are not present + /// in this device's local library. + pub fn listen_history(&self, limit: usize) -> Result> { + let conn = self.lock(); + let mut stmt = conn.prepare( + "SELECT listen_id, content_id, origin_device_id, started_at_ms, metadata_json + FROM listen_events + WHERE qualified = 1 + ORDER BY started_at_ms DESC, listen_id DESC + LIMIT ?1", + )?; + let rows = stmt + .query_map([limit.min(i64::MAX as usize) as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + )) + })? + .collect::>>()?; + rows.into_iter() + .map( + |(listen_id, content_id, origin_device_id, started_at_ms, metadata_json)| { + let metadata: music_dht::device_sync::ListenTrackMetadata = + serde_json::from_str(&metadata_json) + .context("invalid listen history metadata")?; + let mut artists = metadata.artist_names; + artists.extend(metadata.featured_artist_names); + Ok(ListenHistoryEntry { + listen_id, + content_id, + title: metadata.title, + artist: artists.join(", "), + origin_device_id, + started_at_ms, + }) + }, + ) + .collect() + } + + // ----------------------------------------------------------------- + // Editing & deleting + // ----------------------------------------------------------------- + + pub fn update_track(&self, id: i64, edit: &TrackEdit) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute( + "UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4 WHERE id = ?1", + params![id, edit.title, edit.track_number, edit.disc_number], + )?; + // The cover is a release attribute; editing it from a track updates + // the release cover (what every view shows for this track). + tx.execute( + "UPDATE releases SET cover_path = ?2 + WHERE id = (SELECT release_id FROM tracks WHERE id = ?1)", + params![id, edit.cover_path], + )?; + tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?; + link_track_artists(&tx, id, &edit.artists, "main")?; + link_track_artists(&tx, id, &edit.featured_artists, "featured")?; + tx.commit()?; + Ok(()) + } + + pub fn update_release(&self, id: i64, edit: &ReleaseEdit) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute( + "UPDATE releases SET title = ?2, release_type = ?3, year = ?4 WHERE id = ?1", + params![id, edit.title, edit.release_type, edit.year], + )?; + // An empty artist list means "keep the current artists". + if !edit.artists.is_empty() { + tx.execute("DELETE FROM release_artists WHERE release_id = ?1", [id])?; + for (position, name) in edit.artists.iter().enumerate() { + let artist_id = find_or_create_artist(&tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO release_artists (release_id, artist_id, position) + VALUES (?1, ?2, ?3)", + params![id, artist_id, position as i64], + )?; + } + } + tx.commit()?; + Ok(()) + } + + pub fn update_artist(&self, id: i64, name: &str, image_path: Option<&str>) -> Result<()> { + let conn = self.lock(); + let taken: Option = conn + .query_row( + "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE AND id != ?2", + params![name, id], + |row| row.get(0), + ) + .optional()?; + if taken.is_some() { + anyhow::bail!("an artist named \"{name}\" already exists"); + } + conn.execute( + "UPDATE artists SET name = ?2, image_path = ?3 WHERE id = ?1", + params![id, name, image_path], + )?; + Ok(()) + } + + /// Artist id by display name (case-insensitive), for catalog requests. + pub fn artist_id_by_name(&self, name: &str) -> Result> { + let conn = self.lock(); + Ok(conn + .query_row( + "SELECT id FROM artists WHERE norm(name) = norm(?1) LIMIT 1", + [name], + |row| row.get(0), + ) + .optional()?) + } + + /// Cover path of an artist's release, matched by names (for the peer + /// catalog image protocol). + pub fn release_cover_by_names(&self, artist: &str, release: &str) -> Result> { + let conn = self.lock(); + Ok(conn + .query_row( + "SELECT r.cover_path FROM releases r + JOIN release_artists ra ON ra.release_id = r.id + JOIN artists a ON a.id = ra.artist_id + WHERE norm(a.name) = norm(?1) AND norm(r.title) = norm(?2) + LIMIT 1", + params![artist, release], + |row| row.get(0), + ) + .optional()? + .flatten()) + } + + /// Image of one artist, for the federation metadata exchange. + pub fn artist_image(&self, artist_id: i64) -> Result> { + let conn = self.lock(); + Ok(conn + .query_row( + "SELECT image_path FROM artists WHERE id = ?1", + [artist_id], + |row| row.get(0), + ) + .optional()? + .flatten()) + } + + /// `true` when the artist exists and has no image yet. + pub fn artist_image_missing(&self, name: &str) -> Result { + let conn = self.lock(); + let missing: Option = conn + .query_row( + "SELECT image_path IS NULL FROM artists WHERE norm(name) = norm(?1)", + [name], + |row| row.get(0), + ) + .optional()?; + Ok(missing.unwrap_or(false)) + } + + /// Sets an artist's image (matched by name) unless one is already set. + /// Returns whether the image was applied. + pub fn set_artist_image_if_missing(&self, name: &str, image_path: &str) -> Result { + let conn = self.lock(); + let changed = conn.execute( + "UPDATE artists SET image_path = ?2 + WHERE norm(name) = norm(?1) AND image_path IS NULL", + params![name, image_path], + )?; + Ok(changed > 0) + } + + pub fn delete_track(&self, id: i64) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?; + cleanup_empty_releases(&tx)?; + tx.commit()?; + Ok(()) + } + + pub fn delete_release(&self, id: i64) -> Result<()> { + let conn = self.lock(); + conn.execute("DELETE FROM releases WHERE id = ?1", [id])?; + Ok(()) + } + + /// Delete an artist together with everything that only existed because + /// of them: tracks left without a main artist and releases left without + /// tracks. Collaborations with other artists survive. + pub fn delete_artist(&self, id: i64) -> Result<()> { + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute("DELETE FROM artists WHERE id = ?1", [id])?; + tx.execute( + "DELETE FROM tracks WHERE id NOT IN + (SELECT track_id FROM track_artists WHERE role = 'main')", + [], + )?; + cleanup_empty_releases(&tx)?; + tx.commit()?; + Ok(()) + } +} + +/// Produces a filesystem-portable component for managed music paths. +pub fn storage_name(value: &str, fallback: &str) -> String { + let cleaned: String = value + .chars() + .map(|character| match character { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + character if character.is_control() => '_', + character => character, + }) + .collect(); + let cleaned = cleaned.trim().trim_matches('.'); + let mut shortened: String = cleaned.chars().take(120).collect(); + if shortened.is_empty() { + return fallback.to_string(); + } + let windows_base = shortened + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + let windows_reserved = matches!(windows_base.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || windows_base + .strip_prefix("COM") + .or_else(|| windows_base.strip_prefix("LPT")) + .is_some_and(|number| number.len() == 1 && matches!(number.as_bytes()[0], b'1'..=b'9')); + if windows_reserved { + shortened.insert(0, '_'); + } + shortened +} + +fn unique_destination(requested: PathBuf, id: i64, reserved: &mut HashSet) -> PathBuf { + if !requested.exists() && reserved.insert(requested.clone()) { + return requested; + } + let stem = requested + .file_stem() + .unwrap_or_else(|| std::ffi::OsStr::new("file")) + .to_string_lossy(); + let extension = requested.extension().map(|value| value.to_os_string()); + let mut candidate = requested.with_file_name(format!("{stem}-{id}")); + if let Some(extension) = extension { + candidate.set_extension(extension); + } + let mut suffix = 2usize; + while candidate.exists() || !reserved.insert(candidate.clone()) { + candidate = requested.with_file_name(format!("{stem}-{id}-{suffix}")); + if let Some(extension) = requested.extension() { + candidate.set_extension(extension); + } + suffix += 1; + } + candidate +} + +fn copy_file_exclusive(source: &Path, destination: &Path) -> Result<()> { + let mut input = + std::fs::File::open(source).with_context(|| format!("opening {}", source.display()))?; + let mut output = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .with_context(|| format!("creating {}", destination.display()))?; + let result = std::io::copy(&mut input, &mut output) + .with_context(|| format!("copying {}", source.display())) + .and_then(|_| { + output + .sync_all() + .with_context(|| format!("syncing {}", destination.display())) + }); + if let Err(error) = result { + drop(output); + let _ = std::fs::remove_file(destination); + return Err(error); + } + Ok(()) +} + +fn remove_empty_directories(root: &Path) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + remove_empty_directories(&path); + } + } + let _ = std::fs::remove_dir(root); +} + +fn cleanup_empty_releases(tx: &rusqlite::Transaction) -> rusqlite::Result<()> { + tx.execute( + "DELETE FROM releases WHERE id NOT IN (SELECT release_id FROM tracks)", + [], + )?; + Ok(()) +} + +fn link_track_artists( + tx: &rusqlite::Transaction, + track_id: i64, + names: &[String], + role: &str, +) -> Result<()> { + for (position, name) in names.iter().enumerate() { + let artist_id = find_or_create_artist(tx, name)?; + tx.execute( + "INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position) + VALUES (?1, ?2, ?3, ?4)", + params![track_id, artist_id, role, position as i64], + )?; + } + Ok(()) +} + +pub(crate) fn find_or_create_artist(conn: &Connection, name: &str) -> Result { + let name = name.trim(); + anyhow::ensure!(!name.is_empty(), "artist name is empty"); + if let Some(id) = conn + .query_row( + "SELECT id FROM artists WHERE norm(name) = norm(?1) LIMIT 1", + [name], + |row| row.get(0), + ) + .optional()? + { + return Ok(id); + } + conn.execute("INSERT INTO artists (name) VALUES (?1)", [name])?; + Ok(conn.last_insert_rowid()) +} + +fn release_card_from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(ReleaseCard { + id: row.get(0)?, + title: row.get(1)?, + release_type: row.get(2)?, + year: row.get(3)?, + cover_path: row.get(4)?, + track_count: row.get(5)?, + availability: Availability::Local, + }) +} + +fn rank_search_results(results: &mut SearchResults, normalized_query: &str) { + results + .artists + .sort_by_key(|artist| exact_match_rank(&artist.name, normalized_query)); + results + .releases + .sort_by_key(|release| exact_match_rank(&release.title, normalized_query)); + results + .tracks + .sort_by_key(|track| track_match_rank(track, normalized_query)); +} + +fn exact_match_rank(value: &str, normalized_query: &str) -> u8 { + if music_dht::normalize_name(value) == normalized_query { + 0 + } else { + 1 + } +} + +fn track_match_rank(track: &TrackItem, normalized_query: &str) -> u8 { + if music_dht::normalize_name(&track.title) == normalized_query { + return 0; + } + if music_dht::normalize_name(&track.release_title) == normalized_query { + return 1; + } + if track + .artists + .iter() + .chain(track.featured_artists.iter()) + .any(|artist| music_dht::normalize_name(&artist.name) == normalized_query) + { + return 2; + } + 3 +} + +/// Run a track query built on TRACK_COLUMNS and attach artist lists. +fn query_tracks( + conn: &Connection, + sql: &str, + params: &[&dyn rusqlite::types::ToSql], +) -> Result> { + let mut statement = conn.prepare(sql)?; + let mut tracks = statement + .query_map(params, |row| { + Ok(TrackItem { + id: row.get(0)?, + title: row.get(1)?, + track_number: row.get(2)?, + disc_number: row.get(3)?, + duration_seconds: row.get(4)?, + release_id: row.get(5)?, + release_title: row.get(6)?, + release_year: row.get(7)?, + cover_path: row.get(8)?, + file_path: row.get(9)?, + audio_format: row.get(10)?, + audio_bitrate: row.get(11)?, + audio_sample_rate: row.get(12)?, + audio_bit_depth: row.get(13)?, + file_size_bytes: row.get(14)?, + content_id: row + .get::<_, Option>(15)? + .as_deref() + .and_then(music_dht::normalize_content_id), + play_count: row.get(16)?, + artists: Vec::new(), + featured_artists: Vec::new(), + fed: None, + }) + })? + .collect::>>()?; + let mut artist_statement = conn.prepare( + "SELECT a.id, a.name, ta.role FROM artists a + JOIN track_artists ta ON ta.artist_id = a.id + WHERE ta.track_id = ?1 + ORDER BY ta.position", + )?; + for track in &mut tracks { + let rows = artist_statement.query_map([track.id], |row| { + Ok(( + ArtistRef { + id: row.get(0)?, + name: row.get(1)?, + }, + row.get::<_, String>(2)?, + )) + })?; + for row in rows { + let (artist, role) = row?; + if role == "featured" { + track.featured_artists.push(artist); + } else { + track.artists.push(artist); + } + } + } + Ok(tracks) +} + +fn pending_track(fed: &FederatedTrack) -> TrackItem { + let refs = |names: &[String]| { + names + .iter() + .map(|name| ArtistRef { + id: -1, + name: name.clone(), + }) + .collect() + }; + TrackItem { + id: NEXT_PENDING_TRACK_ID.fetch_sub(1, Ordering::Relaxed), + title: fed.title.clone(), + track_number: fed.track_number, + disc_number: fed.disc_number, + duration_seconds: fed.duration_seconds.unwrap_or(0) as f64, + artists: refs(&fed.artist_names), + featured_artists: refs(&fed.featured_artist_names), + release_id: -1, + release_title: fed + .release_title + .clone() + .unwrap_or_else(|| format!("federation · {}", fed.owner_short())), + release_year: fed.year, + file_path: String::new(), + content_id: fed.content_id.clone(), + cover_path: None, + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: Some(fed.clone()), + } +} + +fn fed_playlist_tracks( + conn: &Connection, + playlist_id: i64, + playlist_sync_id: &str, +) -> Result> { + let mut statement = conn.prepare( + "SELECT position, item_id, owner, title, artist_names, featured_artist_names, + year, duration_seconds, content_id, release_title, track_number, disc_number + FROM fed_playlist_tracks f + WHERE f.playlist_sync_id = ?1 + AND NOT EXISTS ( + SELECT 1 FROM playlist_tracks pt + JOIN tracks t ON t.id = pt.track_id + WHERE pt.playlist_id = ?2 + AND t.content_id = f.content_id + ) + ORDER BY position, title COLLATE NOCASE", + )?; + let rows = statement.query_map(params![playlist_sync_id, playlist_id], |row| { + Ok((row.get::<_, i64>(0)?, fed_track_from_offset_row(row, 1)?)) + })?; + Ok(rows.collect::>>()?) +} + +fn fed_track_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + fed_track_from_offset_row(row, 0) +} + +fn fed_track_from_offset_row( + row: &rusqlite::Row<'_>, + offset: usize, +) -> rusqlite::Result { + Ok(FederatedTrack { + item_id: row.get(offset)?, + owner: row.get(offset + 1)?, + own: false, + title: row.get(offset + 2)?, + artist_names: split_joined_names(row.get(offset + 3)?), + featured_artist_names: split_joined_names(row.get(offset + 4)?), + year: row.get(offset + 5)?, + duration_seconds: row + .get::<_, Option>(offset + 6)? + .map(|d| d.round() as i64), + content_id: row.get(offset + 7)?, + release_title: row.get(offset + 8)?, + track_number: row.get(offset + 9)?, + disc_number: row.get(offset + 10)?, + }) +} + +fn split_joined_names(names: String) -> Vec { + names + .split("; ") + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect() +} + +/// Escape LIKE wildcards in user input; queries use `ESCAPE '\'`. +/// Registers `norm(text)` — Unicode-aware case folding and normalization +/// (NFKC, lowercase, punctuation stripped). SQLite's own LIKE/NOCASE only +/// fold ASCII, so Cyrillic and other non-Latin names were unsearchable +/// without it. Shares the exact algorithm with the federation DHT. +fn register_norm_function(conn: &Connection) -> Result<()> { + use rusqlite::functions::FunctionFlags; + conn.create_scalar_function( + "norm", + 1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + let value: String = ctx.get(0)?; + Ok(music_dht::normalize_name(&value)) + }, + )?; + Ok(()) +} + +/// Computes the stable Furumi content identifier for a local audio file. +pub fn audio_content_id(path: &str) -> Option { + let mut file = std::fs::File::open(path).ok()?; + let mut hasher = blake3::Hasher::new(); + std::io::copy(&mut file, &mut hasher).ok()?; + Some(format!("b3:{}", hasher.finalize().to_hex())) +} + +fn directory_size(path: &Path) -> u64 { + let Ok(metadata) = std::fs::metadata(path) else { + return 0; + }; + if metadata.is_file() { + return metadata.len(); + } + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + entries + .filter_map(|entry| entry.ok()) + .map(|entry| directory_size(&entry.path())) + .sum() +} + +fn file_size(path: &Path) -> u64 { + std::fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0) +} + +fn sqlite_database_size(path: &Path) -> u64 { + file_size(path) + .saturating_add(file_size(&path_with_suffix(path, "-wal"))) + .saturating_add(file_size(&path_with_suffix(path, "-shm"))) +} + +fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut value = path.as_os_str().to_os_string(); + value.push(suffix); + PathBuf::from(value) +} + +fn now_ms_i64() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +fn embedding_to_bytes(vector: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(std::mem::size_of_val(vector)); + for value in vector { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes +} + +fn embedding_from_bytes(dimensions: i64, bytes: &[u8]) -> Result> { + anyhow::ensure!(dimensions > 0, "stored embedding has invalid dimensions"); + let dimensions = dimensions as usize; + anyhow::ensure!( + bytes.len() == dimensions * std::mem::size_of::(), + "stored embedding byte length does not match its dimensions" + ); + let vector: Vec = bytes + .chunks_exact(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + anyhow::ensure!( + vector.iter().all(|value| value.is_finite()), + "stored embedding contains a non-finite value" + ); + Ok(vector) +} + +fn remote_artist_id(artist_key: &str) -> i64 { + let hash = blake3::hash(artist_key.as_bytes()); + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&hash.as_bytes()[..8]); + -(i64::from_be_bytes(bytes) & i64::MAX).max(1) +} + +fn ensure_schema_migrations(conn: &Connection) -> Result<()> { + let fed_like_columns = table_columns(conn, "fed_likes")?; + if !fed_like_columns + .iter() + .any(|column| column == "featured_artist_names") + { + conn.execute( + "ALTER TABLE fed_likes + ADD COLUMN featured_artist_names TEXT NOT NULL DEFAULT ''", + [], + )?; + } + if !fed_like_columns.iter().any(|column| column == "content_id") { + conn.execute("ALTER TABLE fed_likes ADD COLUMN content_id TEXT", [])?; + } + if !fed_like_columns + .iter() + .any(|column| column == "liked_hlc_ms") + { + conn.execute("ALTER TABLE fed_likes ADD COLUMN liked_hlc_ms INTEGER", [])?; + } + let like_columns = table_columns(conn, "likes")?; + if !like_columns.iter().any(|column| column == "liked_hlc_ms") { + conn.execute("ALTER TABLE likes ADD COLUMN liked_hlc_ms INTEGER", [])?; + } + let track_columns = table_columns(conn, "tracks")?; + if !track_columns.iter().any(|column| column == "content_id") { + conn.execute("ALTER TABLE tracks ADD COLUMN content_id TEXT", [])?; + } + let playlist_columns = table_columns(conn, "playlists")?; + if !playlist_columns.iter().any(|column| column == "sync_id") { + conn.execute("ALTER TABLE playlists ADD COLUMN sync_id TEXT", [])?; + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_sync_id + ON playlists(sync_id)", + [], + )?; + } + let network_artist_columns = table_columns(conn, "network_artist_cache")?; + if !network_artist_columns + .iter() + .any(|column| column == "remote_image_hint") + { + conn.execute( + "ALTER TABLE network_artist_cache ADD COLUMN remote_image_hint TEXT", + [], + )?; + } + let embedding_columns = table_columns(conn, "track_embeddings")?; + if !embedding_columns + .iter() + .any(|column| column == "routing_signature") + { + conn.execute( + "ALTER TABLE track_embeddings ADD COLUMN routing_signature BLOB", + [], + )?; + } + let mut rows = conn.prepare("SELECT id, title FROM playlists WHERE sync_id IS NULL")?; + let missing = rows + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()?; + drop(rows); + for (id, title) in missing { + conn.execute( + "UPDATE playlists SET sync_id = ?2 WHERE id = ?1", + params![id, make_playlist_sync_id(id, &title)], + )?; + } + Ok(()) +} + +fn table_columns(conn: &Connection, table: &str) -> Result> { + let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?; + Ok(statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?) +} + +fn make_playlist_sync_id(id: i64, title: &str) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let seed = format!("playlist:{id}:{title}:{now}:{}", std::process::id()); + format!("pl_{}", &blake3::hash(seed.as_bytes()).to_hex()[..24]) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/crates/furumi-library/src/models.rs b/crates/furumi-library/src/models.rs new file mode 100644 index 0000000..a21d056 --- /dev/null +++ b/crates/furumi-library/src/models.rs @@ -0,0 +1,347 @@ +//! Data shapes the views render. They mirror what the furumusic API used to +//! return, but every field is now filled from the local SQLite library. + +use serde::{Deserialize, Serialize}; + +/// Which catalog sources a client wants library queries to include. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LibrarySourceMode { + Local, + My, + #[default] + Global, +} + +impl LibrarySourceMode { + pub fn label(self) -> &'static str { + match self { + Self::Local => "Local", + Self::My => "My", + Self::Global => "Global", + } + } + + pub fn includes_network(self) -> bool { + !matches!(self, Self::Local) + } + + pub fn includes_global_peers(self) -> bool { + matches!(self, Self::Global) + } + + pub fn next(self) -> Self { + match self { + Self::Local => Self::My, + Self::My => Self::Global, + Self::Global => Self::Local, + } + } +} + +/// Client-neutral filters accepted by library browse/search operations. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryFilters { + #[serde(default)] + pub hide_featured_only: bool, + #[serde(default)] + pub source_mode: LibrarySourceMode, +} + +/// Durable metadata for a track known through federation but not necessarily +/// materialized as a local audio file. Networking remains a client concern. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FederatedTrack { + pub item_id: String, + pub owner: String, + pub own: bool, + pub title: String, + pub artist_names: Vec, + pub featured_artist_names: Vec, + pub year: Option, + pub duration_seconds: Option, + pub content_id: Option, + pub release_title: Option, + pub track_number: Option, + pub disc_number: Option, +} + +impl FederatedTrack { + pub fn artist_line(&self) -> String { + let mut line = self.artist_names.join(", "); + if !self.featured_artist_names.is_empty() { + if !line.is_empty() { + line.push_str(" feat. "); + } + line.push_str(&self.featured_artist_names.join(", ")); + } + line + } + + pub fn owner_short(&self) -> String { + self.owner.chars().take(10).collect() + } + + pub fn duration_label(&self) -> String { + self.duration_seconds.map_or_else(String::new, |total| { + format!("{}:{:02}", total / 60, total % 60) + }) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Availability { + #[default] + Local, + Mixed, + Remote, +} + +impl Availability { + pub fn is_remoteish(self) -> bool { + matches!(self, Availability::Mixed | Availability::Remote) + } +} + +#[derive(Debug, Clone)] +pub struct ArtistCard { + pub id: i64, + pub name: String, + /// Path to a local image file, if one is set for the artist. + pub image_path: Option, + pub release_count: i64, + pub track_count: i64, + pub availability: Availability, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistRef { + pub id: i64, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct TrackItem { + pub id: i64, + pub title: String, + pub track_number: Option, + pub disc_number: Option, + pub duration_seconds: f64, + pub artists: Vec, + pub featured_artists: Vec, + pub release_id: i64, + pub release_title: String, + pub release_year: Option, + /// Absolute path to the local audio file. + pub file_path: String, + /// Stable audio content id (`b3:<64 hex>`) when known. + pub content_id: Option, + /// Path to a local cover image (the release cover). + pub cover_path: Option, + pub audio_format: Option, + pub audio_bitrate: Option, + pub audio_sample_rate: Option, + pub audio_bit_depth: Option, + pub file_size_bytes: Option, + /// Completed local plays, from the history table. + pub play_count: i64, + /// Set for federated tracks that are not in the local library (yet): + /// carries everything needed to download them from the owning peer. + /// With an empty `file_path` the player resolves the track on demand. + pub fed: Option, +} + +impl TrackItem { + /// A federated track that still needs downloading before playback. + pub fn is_fed_pending(&self) -> bool { + self.fed.is_some() && self.file_path.is_empty() + } + + pub fn artist_line(&self) -> String { + let artists = self + .artists + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", "); + let featured = self + .featured_artists + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", "); + match (artists.is_empty(), featured.is_empty()) { + (false, false) => format!("{artists} feat. {featured}"), + (false, true) => artists, + (true, false) => format!("feat. {featured}"), + (true, true) => String::new(), + } + } + + pub fn duration_label(&self) -> String { + let total = self.duration_seconds.round() as i64; + format!("{}:{:02}", total / 60, total % 60) + } + + /// Full tech line for the status bar, including the sample rate. + pub fn tech_label_full(&self) -> String { + let mut parts = Vec::new(); + if let Some(format) = &self.audio_format { + parts.push(format.to_uppercase()); + } + if let Some(bitrate) = self.audio_bitrate { + parts.push(format!("{bitrate}kbps")); + } + if let Some(rate) = self.audio_sample_rate { + parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0)); + } + if let Some(bytes) = self.file_size_bytes { + parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0)); + } + parts.join(" · ") + } +} + +#[derive(Debug, Clone)] +pub struct ReleaseCard { + pub id: i64, + pub title: String, + pub release_type: String, + pub year: Option, + pub cover_path: Option, + pub track_count: i64, + pub availability: Availability, +} + +#[derive(Debug)] +pub struct ArtistDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub name: String, + pub image_path: Option, + pub total_track_count: i64, + pub total_play_count: i64, + pub top_tracks: Vec, + pub releases: Vec, + /// Tracks where this artist is featured (the only content for artists + /// without own releases). + pub featured_tracks: Vec, +} + +#[derive(Debug)] +pub struct ReleaseDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub title: String, + pub release_type: String, + pub year: Option, + pub cover_path: Option, + pub artists: Vec, + pub tracks: Vec, +} + +#[derive(Debug, Clone)] +pub struct PlaylistCard { + pub id: i64, + pub title: String, + pub track_count: i64, + /// "normal" for user playlists, "likes" for the virtual Likes playlist. + pub kind: String, +} + +#[derive(Debug)] +pub struct PlaylistDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub title: String, + #[allow(dead_code, reason = "shown in a detail header later")] + pub description: Option, + pub tracks: Vec, +} + +#[derive(Debug, Default)] +pub struct SearchResults { + pub artists: Vec, + pub releases: Vec, + pub tracks: Vec, +} + +impl SearchResults { + pub fn len(&self) -> usize { + self.artists.len() + self.releases.len() + self.tracks.len() + } + + pub fn is_empty(&self) -> bool { + self.artists.is_empty() && self.releases.is_empty() && self.tracks.is_empty() + } +} + +#[derive(Debug)] +pub struct ArtistsPage { + pub items: Vec, + pub total: i64, + pub page: i64, + pub has_more: bool, +} + +/// Edited values submitted from the track edit form. `None` numbers clear +/// the column. +#[derive(Debug, Clone)] +pub struct TrackEdit { + pub title: String, + pub artists: Vec, + pub featured_artists: Vec, + pub track_number: Option, + pub disc_number: Option, + /// Cover image path; the cover lives on the track's release (the same + /// image every view shows for the track). None clears it. + pub cover_path: Option, +} + +#[derive(Debug, Clone)] +pub struct ReleaseEdit { + pub title: String, + pub release_type: String, + pub year: Option, + pub artists: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn artist(name: &str) -> ArtistRef { + ArtistRef { + id: 1, + name: name.to_string(), + } + } + + #[test] + fn artist_line_formats_featured_artists() { + let track = TrackItem { + id: 1, + title: "Track".into(), + track_number: None, + disc_number: None, + duration_seconds: 1.0, + artists: vec![artist("Main")], + featured_artists: vec![artist("Guest"), artist("Other")], + release_id: 1, + release_title: "Release".into(), + release_year: None, + file_path: "/tmp/track.mp3".into(), + content_id: None, + cover_path: None, + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: None, + }; + + assert_eq!(track.artist_line(), "Main feat. Guest, Other"); + } +} diff --git a/crates/furumi-library/src/tests.rs b/crates/furumi-library/src/tests.rs new file mode 100644 index 0000000..85475f2 --- /dev/null +++ b/crates/furumi-library/src/tests.rs @@ -0,0 +1,787 @@ +use super::*; + +fn test_library() -> Library { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + register_norm_function(&conn).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + Library { + conn: Mutex::new(conn), + db_path: std::env::temp_dir().join("furumi-test-library.db"), + covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"), + } +} + +fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 { + add_track_with_featured(lib, title, artist, &[], album) +} + +fn add_track_with_featured( + lib: &Library, + title: &str, + artist: &str, + featured: &[&str], + album: &str, +) -> i64 { + let import = import::TrackImport { + release_type: None, + file_path: format!("/music/{artist}/{album}/{title}.mp3"), + title: title.to_string(), + artists: vec![artist.to_string()], + featured_artists: featured.iter().map(|name| (*name).to_string()).collect(), + album_artists: vec![artist.to_string()], + release_title: album.to_string(), + year: Some(2020), + track_number: None, + disc_number: None, + duration_seconds: 60.0, + audio_format: Some("mp3".into()), + audio_bitrate: Some(320), + audio_sample_rate: Some(44100), + audio_bit_depth: None, + file_size_bytes: Some(1), + cover: None, + }; + let id = import::upsert_track(lib, &import).unwrap().0; + let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex()); + lib.lock() + .execute( + "UPDATE tracks SET content_id = ?2 WHERE id = ?1", + params![id, content_id], + ) + .unwrap(); + id +} + +fn artist_filters(hide_featured_only: bool) -> LibraryFilters { + LibraryFilters { + hide_featured_only, + ..Default::default() + } +} + +fn unique_test_dir(label: &str) -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("furumi-{label}-{}-{unique}", std::process::id())) +} + +#[test] +fn managed_music_relocation_builds_artist_release_tree_and_updates_paths() { + let root = unique_test_dir("music-relocation"); + let old = root.join("old"); + let new = root.join("new"); + let covers = root.join("covers"); + std::fs::create_dir_all(&old).unwrap(); + std::fs::create_dir_all(&covers).unwrap(); + let audio = old.join("legacy.flac"); + let cover = covers.join("release.jpg"); + let artist_image = covers.join("artist.png"); + std::fs::write(&audio, b"audio").unwrap(); + std::fs::write(&cover, b"cover").unwrap(); + std::fs::write(&artist_image, b"artist").unwrap(); + + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + register_norm_function(&conn).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + let library = Library { + conn: Mutex::new(conn), + db_path: root.join("library.db"), + covers_dir: covers, + }; + let track_id = import::upsert_track( + &library, + &import::TrackImport { + file_path: audio.to_string_lossy().into_owned(), + title: "Song".into(), + artists: vec!["Artist".into()], + featured_artists: vec![], + album_artists: vec!["Artist".into()], + release_title: "Release".into(), + release_type: Some("album".into()), + year: Some(2026), + track_number: Some(1), + disc_number: Some(1), + duration_seconds: 1.0, + audio_format: Some("flac".into()), + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: Some(5), + cover: None, + }, + ) + .unwrap() + .0; + let (release_id, artist_id): (i64, i64) = library + .lock() + .query_row( + "SELECT t.release_id, ta.artist_id + FROM tracks t JOIN track_artists ta ON ta.track_id = t.id + WHERE t.id = ?1 AND ta.role = 'main'", + [track_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + library + .lock() + .execute( + "UPDATE releases SET cover_path = ?2 WHERE id = ?1", + params![release_id, cover.to_string_lossy()], + ) + .unwrap(); + library + .lock() + .execute( + "UPDATE artists SET image_path = ?2 WHERE id = ?1", + params![artist_id, artist_image.to_string_lossy()], + ) + .unwrap(); + + let stats = library.relocate_managed_music(&old, &new).unwrap(); + assert_eq!(stats.tracks, 1); + assert_eq!(stats.images, 2); + let new = std::fs::canonicalize(new).unwrap(); + let track = library.tracks_by_ids(&[track_id]).unwrap().remove(0); + assert_eq!( + track.file_path, + new.join("Artist/Release/legacy.flac").to_string_lossy() + ); + assert_eq!( + track.cover_path.as_deref(), + Some( + new.join("Artist/Release/cover.jpg") + .to_string_lossy() + .as_ref() + ) + ); + let image: String = library + .lock() + .query_row( + "SELECT image_path FROM artists WHERE id = ?1", + [artist_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(image, new.join("Artist/artist.png").to_string_lossy()); + assert!(!audio.exists()); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn music_directory_validation_rejects_a_file_without_touching_it() { + let root = unique_test_dir("music-validation"); + std::fs::create_dir_all(&root).unwrap(); + let file = root.join("not-a-directory"); + std::fs::write(&file, b"keep").unwrap(); + + assert!(Library::validate_music_directory(&file).is_err()); + assert_eq!(std::fs::read(&file).unwrap(), b"keep"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn managed_music_names_are_portable_to_windows() { + assert_eq!(storage_name("Artist/Name", "fallback"), "Artist_Name"); + assert_eq!(storage_name("CON", "fallback"), "_CON"); + assert_eq!(storage_name("lpt9.live", "fallback"), "_lpt9.live"); + assert_eq!(storage_name("...", "fallback"), "fallback"); +} + +#[test] +fn local_stats_counts_library_rows_and_audio_bytes() { + let lib = test_library(); + add_track(&lib, "One", "Artist", "First"); + add_track(&lib, "Two", "Artist", "Second"); + + let stats = lib.local_stats().unwrap(); + assert_eq!(stats.artist_count, 1); + assert_eq!(stats.release_count, 2); + assert_eq!(stats.track_count, 2); + assert_eq!(stats.audio_bytes, 2); + assert_eq!(stats.tracks_without_size, 0); +} + +#[test] +fn artists_page_prioritizes_releases_then_tracks() { + let lib = test_library(); + add_track(&lib, "Solo", "Zed", "Zed Album"); + add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album"); + add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album"); + + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); + let zed_pos = page + .items + .iter() + .position(|artist| artist.name == "Zed") + .unwrap(); + let guest_pos = page + .items + .iter() + .position(|artist| artist.name == "Guest") + .unwrap(); + let guest = &page.items[guest_pos]; + + assert_eq!(guest.release_count, 0); + assert_eq!(guest.track_count, 2); + assert!(zed_pos < guest_pos); + + let filtered = lib.artists(1, 10, artist_filters(true)).unwrap(); + assert!(filtered.items.iter().all(|artist| artist.release_count > 0)); + assert!(!filtered.items.iter().any(|artist| artist.name == "Guest")); +} + +#[test] +fn network_artist_image_hint_becomes_local_image_after_fetch() { + let lib = test_library(); + let artist_key = music_dht::normalize_name("Remote Artist"); + lib.replace_network_artist_cache( + "peer-a", + "personal", + &[NetworkArtistPreview { + artist_key: artist_key.clone(), + name: "Remote Artist".into(), + image_path: Some("peer-local/image.jpg".into()), + release_count: 1, + track_count: 3, + }], + true, + ) + .unwrap(); + + let filters = LibraryFilters { + source_mode: LibrarySourceMode::My, + ..Default::default() + }; + let page = lib.artists(1, 10, filters).unwrap(); + assert_eq!(page.items[0].image_path, None); + + let requests = lib + .network_artist_image_requests(filters, &["Remote Artist".into()], 8) + .unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].source_id, "peer-a"); + assert_eq!(requests[0].artist_key, artist_key); + + lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg") + .unwrap(); + let page = lib.artists(1, 10, filters).unwrap(); + assert_eq!( + page.items[0].image_path.as_deref(), + Some("/tmp/remote-artist.jpg") + ); +} + +#[test] +fn import_creates_artist_release_track() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.items[0].name, "Artist"); + assert_eq!(page.items[0].track_count, 1); + + let detail = lib.artist(page.items[0].id).unwrap(); + assert_eq!(detail.releases.len(), 1); + assert_eq!(detail.top_tracks.len(), 1); + + let release = lib.release(detail.releases[0].id).unwrap(); + assert_eq!(release.tracks.len(), 1); + assert_eq!(release.tracks[0].id, track_id); + assert_eq!(release.tracks[0].artists[0].name, "Artist"); +} + +#[test] +fn reimport_updates_instead_of_duplicating() { + let lib = test_library(); + let first = add_track(&lib, "Song", "Artist", "Album"); + let second = add_track(&lib, "Song", "Artist", "Album"); + assert_eq!(first, second); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); + assert_eq!(page.items[0].track_count, 1); +} + +#[test] +fn content_id_backfill_hashes_missing_track_ids() { + let lib = test_library(); + let path = std::env::temp_dir().join(format!( + "furumi-content-id-test-{}-{}.bin", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"portable content id").unwrap(); + let file_path = path.to_string_lossy().into_owned(); + let import = import::TrackImport { + release_type: None, + file_path: file_path.clone(), + title: "Portable".to_string(), + artists: vec!["Artist".to_string()], + featured_artists: Vec::new(), + album_artists: vec!["Artist".to_string()], + release_title: "Album".to_string(), + year: Some(2026), + track_number: None, + disc_number: None, + duration_seconds: 60.0, + audio_format: Some("bin".into()), + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: Some(19), + cover: None, + }; + let track_id = import::upsert_track(&lib, &import).unwrap().0; + let expected = audio_content_id(&file_path).unwrap(); + { + let conn = lib.lock(); + conn.execute( + "UPDATE tracks SET content_id = NULL WHERE id = ?1", + [track_id], + ) + .unwrap(); + } + + let stats = lib.backfill_missing_content_ids().unwrap(); + assert_eq!(stats.hashed, 1); + assert_eq!(stats.updated(), 1); + assert_eq!( + lib.track_content_id_by_id(track_id).unwrap().as_deref(), + Some(expected.as_str()) + ); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn search_finds_all_kinds() { + let lib = test_library(); + add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album"); + let results = lib.search("neon", 10).unwrap(); + assert_eq!(results.artists.len(), 1); + assert_eq!(results.releases.len(), 1); + assert_eq!(results.tracks.len(), 1); + // LIKE wildcards in the query must not match everything. + assert_eq!(lib.search("%", 10).unwrap().len(), 0); +} + +#[test] +fn search_ranks_exact_names_first() { + let lib = test_library(); + add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album"); + add_track(&lib, "Needle", "Needle", "Needle"); + + let results = lib.search("needle", 10).unwrap(); + assert_eq!(results.artists[0].name, "Needle"); + assert_eq!(results.releases[0].title, "Needle"); + assert_eq!(results.tracks[0].title, "Needle"); +} + +#[test] +fn search_folds_case_beyond_ascii() { + let lib = test_library(); + add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом"); + // SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script. + assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1); + assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1); + assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1); + assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1); +} + +#[test] +fn playlists_and_likes_round_trip() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + let playlist = lib.create_playlist("Mix").unwrap(); + lib.add_tracks_to_playlist(playlist.id, &[track_id]) + .unwrap(); + assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1); + + let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap(); + assert!(lib.toggle_like_by_content_id(&content_id).unwrap()); + assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]); + assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1); + assert!(!lib.toggle_like_by_content_id(&content_id).unwrap()); + + lib.remove_tracks_from_playlist(playlist.id, &[track_id]) + .unwrap(); + assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0); + lib.delete_playlist(playlist.id).unwrap(); + // Only the virtual Likes playlist remains. + assert_eq!(lib.playlists().unwrap().len(), 1); +} + +#[test] +fn likes_playlist_orders_local_and_federated_by_liked_at() { + let lib = test_library(); + let old_id = add_track(&lib, "Old Local", "Artist", "Album"); + let new_id = add_track(&lib, "New Local", "Artist", "Album"); + let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap(); + let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap(); + let content_id = format!("b3:{}", "c".repeat(64)); + let fed = FederatedTrack { + item_id: "fed_item_order".to_string(), + owner: "fed_owner_order".to_string(), + own: false, + title: "Middle Fed".to_string(), + artist_names: vec!["Remote Artist".to_string()], + featured_artist_names: Vec::new(), + year: Some(2026), + duration_seconds: Some(123), + content_id: Some(content_id), + release_title: Some("Remote Release".to_string()), + track_number: Some(1), + disc_number: Some(1), + }; + + assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap()); + assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap()); + assert!(lib.toggle_fed_like(&fed).unwrap()); + { + let conn = lib.lock(); + conn.execute( + "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", + params![old_id, "2026-01-01 00:00:00"], + ) + .unwrap(); + conn.execute( + "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", + params![new_id, "2026-01-02 00:00:00"], + ) + .unwrap(); + conn.execute( + "UPDATE fed_likes SET liked_at = ?2 WHERE item_id = ?1", + params![fed.item_id, "2026-01-03 00:00:00"], + ) + .unwrap(); + } + + let titles: Vec = lib + .playlist(LIKES_PLAYLIST_ID) + .unwrap() + .tracks + .into_iter() + .map(|track| track.title) + .collect(); + assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]); + + assert!(!lib.toggle_like_by_content_id(&old_content_id).unwrap()); + assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap()); + { + let conn = lib.lock(); + conn.execute( + "UPDATE likes SET liked_at = ?2 WHERE track_id = ?1", + params![old_id, "2026-01-04 00:00:00"], + ) + .unwrap(); + } + let titles: Vec = lib + .playlist(LIKES_PLAYLIST_ID) + .unwrap() + .tracks + .into_iter() + .map(|track| track.title) + .collect(); + assert_eq!(titles, vec!["Old Local", "Middle Fed", "New Local"]); +} + +#[test] +fn synced_playlist_can_show_federated_pending_tracks() { + let lib = test_library(); + let playlist = lib.create_playlist("Remote Mix").unwrap(); + let sync_id = lib.ensure_playlist_sync_id(playlist.id).unwrap(); + let content_id = format!("b3:{}", "a".repeat(64)); + let fed = FederatedTrack { + item_id: "fed_item_1".to_string(), + owner: "fed_owner_1".to_string(), + own: false, + title: "Remote Song".to_string(), + artist_names: vec!["Remote Artist".to_string()], + featured_artist_names: vec!["Remote Guest".to_string()], + year: Some(2026), + duration_seconds: Some(123), + content_id: Some(content_id.clone()), + release_title: Some("Remote Release".to_string()), + track_number: Some(2), + disc_number: Some(1), + }; + + assert!(lib.upsert_fed_playlist_track(&sync_id, &fed, 4).unwrap()); + assert!( + lib.has_playlist_content_reference(&sync_id, &content_id) + .unwrap() + ); + + let detail = lib.playlist(playlist.id).unwrap(); + assert_eq!(detail.tracks.len(), 1); + let track = &detail.tracks[0]; + assert!(track.is_fed_pending()); + assert_eq!(track.title, "Remote Song"); + assert_eq!(track.artist_line(), "Remote Artist feat. Remote Guest"); + assert_eq!(track.release_title, "Remote Release"); + assert_eq!(track.content_id.as_deref(), Some(content_id.as_str())); + + let card = lib + .playlists() + .unwrap() + .into_iter() + .find(|card| card.id == playlist.id) + .unwrap(); + assert_eq!(card.track_count, 1); + + lib.remove_content_ids_from_playlist(playlist.id, std::slice::from_ref(&content_id)) + .unwrap(); + assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0); + assert!( + lib.fed_playlist_track_by_content_id(&sync_id, &content_id) + .unwrap() + .is_none() + ); +} + +#[test] +fn add_federated_pending_track_to_playlist_records_position() { + let lib = test_library(); + let local_id = add_track(&lib, "Local Song", "Artist", "Album"); + let playlist = lib.create_playlist("Remote Mix").unwrap(); + let content_id = format!("b3:{}", "b".repeat(64)); + let fed = FederatedTrack { + item_id: "fed_item_2".to_string(), + owner: "fed_owner_2".to_string(), + own: false, + title: "Remote Song".to_string(), + artist_names: vec!["Remote Artist".to_string()], + featured_artist_names: Vec::new(), + year: Some(2026), + duration_seconds: Some(123), + content_id: Some(content_id.clone()), + release_title: Some("Remote Release".to_string()), + track_number: Some(2), + disc_number: Some(1), + }; + + lib.add_tracks_to_playlist(playlist.id, &[local_id]) + .unwrap(); + lib.add_fed_tracks_to_playlist(playlist.id, std::slice::from_ref(&fed)) + .unwrap(); + + let position = lib + .playlist_content_position(playlist.id, &content_id) + .unwrap(); + assert_eq!(position, Some(1)); + let detail = lib.playlist(playlist.id).unwrap(); + assert_eq!( + detail + .tracks + .into_iter() + .map(|track| track.title) + .collect::>(), + vec!["Local Song", "Remote Song"] + ); +} + +#[test] +fn track_edit_relinks_artists() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + lib.update_track( + track_id, + &TrackEdit { + title: "Renamed".into(), + artists: vec!["Other".into()], + featured_artists: vec!["Guest".into()], + track_number: Some(2), + disc_number: None, + cover_path: None, + }, + ) + .unwrap(); + let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0); + assert_eq!(track.title, "Renamed"); + assert_eq!(track.artists[0].name, "Other"); + assert_eq!(track.featured_artists[0].name, "Guest"); + assert_eq!(track.track_number, Some(2)); +} + +#[test] +fn deleting_artist_cleans_up_own_content() { + let lib = test_library(); + add_track(&lib, "Song", "Solo", "Solo Album"); + let page = lib.artists(1, 10, artist_filters(false)).unwrap(); + lib.delete_artist(page.items[0].id).unwrap(); + assert_eq!(lib.artists(1, 10, artist_filters(false)).unwrap().total, 0); + assert_eq!(lib.search("Song", 10).unwrap().len(), 0); +} + +#[test] +fn delete_track_drops_empty_release() { + let lib = test_library(); + let track_id = add_track(&lib, "Only", "Artist", "Album"); + lib.delete_track(track_id).unwrap(); + let detail = lib + .artist(lib.artists(1, 10, artist_filters(false)).unwrap().items[0].id) + .unwrap(); + assert!(detail.releases.is_empty()); +} + +#[test] +fn history_counts_completed_plays() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + let content_id = lib + .tracks_by_ids(&[track_id]) + .unwrap() + .remove(0) + .content_id + .unwrap(); + let event = music_dht::device_sync::ListenEvent { + listen_id: "listen-1".to_string(), + content_id, + started_at_ms: 1_700_000_000_000, + listened_ms: 60_000, + track_duration_ms: Some(60_000), + ended_reason: music_dht::device_sync::ListenEndReason::Finished, + track: music_dht::device_sync::ListenTrackMetadata { + title: "Song".to_string(), + artist_names: vec!["Artist".to_string()], + featured_artist_names: Vec::new(), + release_title: Some("Album".to_string()), + }, + }; + assert!(lib.apply_listen_event(&event, "device-a").unwrap()); + assert!(!lib.apply_listen_event(&event, "device-a").unwrap()); + let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0); + assert_eq!(track.play_count, 1); + let history = lib.listen_history(20).unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].listen_id, "listen-1"); + assert_eq!(history[0].title, "Song"); + assert_eq!(history[0].artist, "Artist"); + assert_eq!(history[0].origin_device_id, "device-a"); +} + +#[test] +fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() { + let lib = test_library(); + let event = music_dht::device_sync::ListenEvent { + listen_id: "remote-listen".to_string(), + content_id: format!("b3:{}", "a".repeat(64)), + started_at_ms: 1_700_000_000_000, + listened_ms: 10_000, + track_duration_ms: Some(120_000), + ended_reason: music_dht::device_sync::ListenEndReason::Skipped, + track: music_dht::device_sync::ListenTrackMetadata { + title: "Remote song".to_string(), + artist_names: vec!["Remote artist".to_string()], + featured_artist_names: vec!["Guest".to_string()], + release_title: None, + }, + }; + assert!(lib.apply_listen_event(&event, "remote-device").unwrap()); + assert!(lib.listen_history(20).unwrap().is_empty()); +} + +#[test] +fn similarity_embeddings_round_trip_and_keep_profiles_separate() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + for profile in ["profile-a", "profile-b"] { + lib.ensure_similarity_profile(profile, "model", "1", "sha", "prep", 3) + .unwrap(); + } + let track = lib + .pending_similarity_tracks("profile-a") + .unwrap() + .into_iter() + .find(|track| track.id == track_id) + .unwrap(); + let first = [0.26726124, 0.5345225, 0.8017837]; + let second = [0.8017837, 0.5345225, 0.26726124]; + lib.store_similarity_embedding(&track, "profile-a", &first) + .unwrap(); + lib.store_similarity_embedding(&track, "profile-b", &second) + .unwrap(); + + assert_eq!( + lib.similarity_embedding(track_id, "profile-a").unwrap(), + Some(first.to_vec()) + ); + assert_eq!( + lib.similarity_embedding(track_id, "profile-b").unwrap(), + Some(second.to_vec()) + ); + let stats = lib.similarity_storage_stats("profile-a").unwrap(); + assert_eq!(stats.total_tracks, 1); + assert_eq!(stats.embedded_tracks, 1); + assert_eq!(stats.stored_vectors, 2); + assert_eq!(stats.stored_bytes, 24); + lib.lock() + .execute( + "UPDATE track_embeddings SET routing_signature = NULL WHERE profile_id = 'profile-a'", + [], + ) + .unwrap(); + assert_eq!( + lib.similarity_routing_signatures("profile-a") + .unwrap() + .len(), + 1 + ); + let stored_signature_bytes: i64 = lib + .lock() + .query_row( + "SELECT length(routing_signature) FROM track_embeddings WHERE profile_id = 'profile-a'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored_signature_bytes, 32); +} + +#[test] +fn changed_content_id_invalidates_only_the_stale_embedding() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2) + .unwrap(); + let track = lib.pending_similarity_tracks("profile").unwrap().remove(0); + lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8]) + .unwrap(); + lib.lock() + .execute( + "UPDATE tracks SET content_id = ?2 WHERE id = ?1", + params![track_id, format!("b3:{}", "f".repeat(64))], + ) + .unwrap(); + + assert_eq!(lib.pending_similarity_tracks("profile").unwrap().len(), 1); + assert_eq!(lib.similarity_embedding(track_id, "profile").unwrap(), None); + assert!(lib.load_similarity_index("profile").unwrap().is_empty()); +} + +#[test] +fn clearing_embeddings_preserves_the_library() { + let lib = test_library(); + let track_id = add_track(&lib, "Song", "Artist", "Album"); + lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2) + .unwrap(); + let track = lib.pending_similarity_tracks("profile").unwrap().remove(0); + lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8]) + .unwrap(); + + lib.clear_similarity_embeddings().unwrap(); + + assert_eq!(lib.tracks_by_ids(&[track_id]).unwrap().len(), 1); + assert!( + lib.similarity_embedding(track_id, "profile") + .unwrap() + .is_none() + ); +}