diff --git a/Cargo.lock b/Cargo.lock index f290f50..7298fce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1647,6 +1647,23 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "furumi-library" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f15ab98c65d89ea18e55ed8852abd2f029f63876d357270c4647d73601d0a2" +dependencies = [ + "anyhow", + "blake3", + "directories", + "lofty", + "music-dht", + "rusqlite", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "furumi_tui" version = "0.2.6" @@ -1657,6 +1674,7 @@ dependencies = [ "crokey", "crossterm", "directories", + "furumi-library", "futures-util", "image", "libc", diff --git a/Cargo.toml b/Cargo.toml index 1962dde..510eaf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ crokey = "1.4.0" crossterm = { version = "0.29.0", features = ["event-stream"] } directories = "6.0.0" futures-util = "0.3.32" +furumi-library = "0.1.0" image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] } lofty = "0.22" # P2P federation: library index in a shared DHT + audio streaming between diff --git a/src/config/settings.rs b/src/config/settings.rs index 2183641..380055c 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -2,48 +2,7 @@ use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -#[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 { - LibrarySourceMode::Local => "Local", - LibrarySourceMode::My => "My", - LibrarySourceMode::Global => "Global", - } - } - - pub fn includes_network(self) -> bool { - !matches!(self, LibrarySourceMode::Local) - } - - pub fn includes_global_peers(self) -> bool { - matches!(self, LibrarySourceMode::Global) - } - - pub fn next(self) -> Self { - match self { - LibrarySourceMode::Local => LibrarySourceMode::My, - LibrarySourceMode::My => LibrarySourceMode::Global, - LibrarySourceMode::Global => LibrarySourceMode::Local, - } - } -} - -#[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, -} +pub use furumi_library::{LibraryFilters, LibrarySourceMode}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SimilaritySettings { diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 2784033..7a0eba9 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -360,43 +360,7 @@ pub struct FedSimilaritySearchResults { } /// A track found through federated search. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FedTrack { - /// Hex item id in the DHT (the key audio is requested by). - pub item_id: String, - /// Hex endpoint id of the owning peer. - pub owner: String, - /// The item is published by this very instance. - pub own: bool, - pub title: String, - pub artist_names: Vec, - pub featured_artist_names: Vec, - pub year: Option, - pub duration_seconds: Option, - /// Stable audio content id (`b3:<64 hex>`) when the owner published it. - pub content_id: Option, - /// Release context, known when the track came from an artist card. - pub release_title: Option, - pub track_number: Option, - pub disc_number: Option, -} - -impl FedTrack { - pub fn artist_line(&self) -> String { - artist_line(&self.artist_names, &self.featured_artist_names) - } - - pub fn owner_short(&self) -> String { - self.owner.chars().take(10).collect() - } - - pub fn duration_label(&self) -> String { - match self.duration_seconds { - Some(total) => format!("{}:{:02}", total / 60, total % 60), - None => String::new(), - } - } -} +pub use furumi_library::FederatedTrack as FedTrack; /// Live status snapshot rendered on the Federation tab. #[derive(Debug, Clone, Default)] @@ -2600,28 +2564,6 @@ fn push_artist_once(names: &mut Vec, name: &str) { names.push(name.to_string()); } -fn artist_line(artists: &[String], featured_artists: &[String]) -> String { - let mut main = Vec::new(); - for artist in artists { - push_artist_once(&mut main, artist); - } - let mut featured = Vec::new(); - for artist in featured_artists { - if !main - .iter() - .any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist)) - { - push_artist_once(&mut featured, artist); - } - } - match (main.is_empty(), featured.is_empty()) { - (false, false) => format!("{} feat. {}", main.join(", "), featured.join(", ")), - (false, true) => main.join(", "), - (true, false) => format!("feat. {}", featured.join(", ")), - (true, true) => String::new(), - } -} - fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) { appearances.sort_by(|a, b| { b.year diff --git a/src/library/import.rs b/src/library/import.rs deleted file mode 100644 index 730d23e..0000000 --- a/src/library/import.rs +++ /dev/null @@ -1,561 +0,0 @@ -//! 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/src/library/mod.rs b/src/library/mod.rs index 9d60dea..81d2b45 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -1,3554 +1,3 @@ -//! 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. +//! Compatibility facade for the client-independent `furumi-library` crate. -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 anyhow::{Context as _, Result}; -use rusqlite::{Connection, OptionalExtension as _, params}; - -use models::{ - ArtistCard, ArtistDetail, ArtistRef, ArtistsPage, Availability, 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; - -#[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 = crate::config::project_dirs().context("cannot determine the 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.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 managed_art = - |path: &Path| path.starts_with(&old_root) || path.starts_with(&self.covers_dir); - 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: crate::config::settings::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: crate::config::settings::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: crate::config::settings::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, - }) - } - - 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, crate::federation::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, crate::federation::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: &[crate::federation::FedTrack], - ) -> 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: &crate::federation::FedTrack, - 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: &crate::federation::FedTrack) -> 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(crate::federation::FedTrack { - 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: &crate::federation::FedTrack, - 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<( - String, - String, - String, - String, - Option, - Option, - Option, - Option, - Option, - 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(()) - } -} - -pub(crate) 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 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(crate::federation::FedTrack { - 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(()) -} - -pub(crate) 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; +pub use furumi_library::*; diff --git a/src/library/models.rs b/src/library/models.rs deleted file mode 100644 index 7633030..0000000 --- a/src/library/models.rs +++ /dev/null @@ -1,255 +0,0 @@ -//! 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. - -#[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() - } -} - -#[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/src/library/tests.rs b/src/library/tests.rs deleted file mode 100644 index 283bbd7..0000000 --- a/src/library/tests.rs +++ /dev/null @@ -1,786 +0,0 @@ -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) -> crate::config::settings::LibraryFilters { - crate::config::settings::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 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 = crate::config::settings::LibraryFilters { - source_mode: crate::config::settings::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 = crate::federation::FedTrack { - 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 = crate::federation::FedTrack { - 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 = crate::federation::FedTrack { - 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() - ); -} diff --git a/src/ui/global.rs b/src/ui/global.rs index cbadfd9..1cce883 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -927,7 +927,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { return centered_line(frame, inner, line); } }; - if results.len() == 0 + if results.is_empty() && state.search.fed_tracks.is_empty() && state.search.fed_artists.is_empty() && !state.search.fed_loading