Added furumi_library with furumi database primitives
CI / check (push) Successful in 1m25s

This commit is contained in:
Ultradesu
2026-08-11 18:07:19 +01:00
parent f9a5096aea
commit 02ef0095f5
13 changed files with 5543 additions and 10 deletions
+8 -4
View File
@@ -4,10 +4,11 @@ These instructions apply to the entire Frid workspace.
## Project role
Frid is shared networking infrastructure. `federation-net` is the generic
transport; `music-dht` is the distributed music-directory overlay used by
Furumi applications. Treat public APIs, persisted state, hash derivations, and
wire formats as compatibility-sensitive.
Frid is shared Furumi infrastructure. `federation-net` is the generic
transport; `music-dht` is the distributed music-directory overlay; and
`furumi-library` owns the client-independent local music database and import
contract. Treat public APIs, persisted state, hash derivations, and wire
formats as compatibility-sensitive.
Read `ARCHITECTURE.md` before changing protocols, routing, persistence,
rendezvous, tickets, identity, or record derivation.
@@ -51,6 +52,9 @@ breaking wire change even when serde would accept the Rust source change.
authorization logic.
- `music-dht` owns Kademlia routing, requests, records, replication, search,
content lookup, and its persistence abstraction.
- `furumi-library` owns the local catalog schema and migrations, metadata
import, artwork, playlists, likes, history, and similarity persistence. It
must not acquire UI, playback-engine, or client-runtime responsibilities.
- Rich catalogs, audio, and device sync use dedicated ALPN stream protocols;
do not push bulk data through the typed DHT event channel.
- Applications own policy: knowing a network ID or discovering a peer is not
+23 -2
View File
@@ -7,8 +7,11 @@ queried**.
```text
application
├── UI, playback, runtime, policy
├── domain API, persistence, policy
furumi-library
├── local catalog, import, migrations
├── playlists, likes, history, similarity persistence
music-dht
├── Kademlia routing and iterative lookup
@@ -22,7 +25,25 @@ federation-net
└── optional Mainline-DHT rendezvous
```
Neither layer assigns a permanent server role to a peer.
No layer assigns a permanent server role to a peer.
`furumi-library` is client-independent local persistence. It may reuse stable
music-domain identifiers and device-sync DTOs from `music-dht`, but it does
not start networking, own an application runtime, or depend on a particular
UI or playback engine. Dependency direction remains one-way:
`federation-net <- music-dht <- furumi-library <- applications`.
## Local library: furumi-library
All Furumi clients share one platform-native `library.db` contract. The crate
owns its SQLite schema and migrations, local catalog models and queries,
metadata import, artwork resolution, playlists, likes, listening history, and
similarity index storage. Connections enable foreign keys, WAL, and a bounded
busy timeout so independently running clients can safely share the database.
Filesystem scanning schedules, playback, federation lifecycle, and UI state
remain application responsibilities. Database and metadata work must stay off
interactive and async reactor threads.
## Transport: federation-net
Generated
+138
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
@@ -416,6 +422,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
@@ -660,6 +675,27 @@ dependencies = [
"crypto-common 0.2.2",
]
[[package]]
name = "directories"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
@@ -832,6 +868,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -864,6 +910,21 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "furumi-library"
version = "0.1.0"
dependencies = [
"anyhow",
"blake3",
"directories",
"lofty",
"music-dht",
"rusqlite",
"serde",
"serde_json",
"tracing",
]
[[package]]
name = "futures"
version = "0.3.33"
@@ -1816,6 +1877,15 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
@@ -1854,6 +1924,32 @@ dependencies = [
"scopeguard",
]
[[package]]
name = "lofty"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "log"
version = "0.4.33"
@@ -1937,6 +2033,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.2"
@@ -2366,6 +2472,15 @@ dependencies = [
"objc2-security",
]
[[package]]
name = "ogg_pager"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2388,6 +2503,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "papaya"
version = "0.2.4"
@@ -2735,6 +2856,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.19",
]
[[package]]
name = "regex-automata"
version = "0.4.16"
@@ -3134,6 +3266,12 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
+4
View File
@@ -2,6 +2,7 @@
resolver = "3"
members = [
"crates/federation-net",
"crates/furumi-library",
"crates/music-dht",
]
@@ -13,6 +14,7 @@ repository = "https://gt.hexor.cy/ab/frid"
[workspace.dependencies]
federation-net = { path = "crates/federation-net", version = "0.3.0" }
music-dht = { path = "crates/music-dht", version = "0.4.0" }
iroh = "1"
iroh-base = "1"
iroh-tickets = "1"
@@ -31,6 +33,8 @@ tracing = "0.1"
clap = { version = "4", features = ["derive"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
directories = "6"
lofty = "0.22"
data-encoding = "2"
rand = "0.9"
tempfile = "3"
+10 -4
View File
@@ -1,10 +1,10 @@
# frid
Frid is the decentralized networking core behind Furumi. It provides reusable
Rust libraries for building equal-peer applications with no application
server, central peer registry, or privileged node.
Frid is the shared infrastructure core behind Furumi. It provides reusable
Rust libraries for local music persistence and equal-peer networking without
an application server, central peer registry, or privileged node.
The workspace has two layers:
The workspace has three layers:
- [`federation-net`](crates/federation-net) establishes authenticated,
NAT-traversing P2P connections over iroh, isolates independent networks and
@@ -14,6 +14,9 @@ The workspace has two layers:
directory on top of that transport, including catalog discovery, content-id
lookup, signed similarity-LSH routing, direct byte streams, and shared Furumi
wire types.
- [`furumi-library`](crates/furumi-library) owns the shared local `library.db`
contract, migrations, catalog operations, metadata import, artwork,
playlists, likes, history, and similarity persistence used by Furumi clients.
Each participant is a client, router, and storage peer. Automatic rendezvous
has no Frid-operated bootstrap service, while self-contained peer tickets
@@ -42,6 +45,7 @@ failure handling, and compatibility rules.
crates/
federation-net/ generic iroh transport and rendezvous
music-dht/ distributed music catalog and content discovery
furumi-library/ client-independent local music database and import
```
## Using the libraries
@@ -52,12 +56,14 @@ Until crates are published to a registry, depend on the repository directly:
[dependencies]
federation-net = { git = "https://gt.hexor.cy/ab/frid.git" }
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
furumi-library = { git = "https://gt.hexor.cy/ab/frid.git" }
```
Start with the crate documentation:
- [`federation-net` guide](crates/federation-net/README.md)
- [`music-dht` guide](crates/music-dht/README.md)
- [`furumi-library` guide](crates/furumi-library/README.md)
## Development
+3
View File
@@ -0,0 +1,3 @@
/target
*.sqlite3-shm
*.sqlite3-wal
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "furumi-library"
version = "0.1.0"
description = "Client-independent SQLite music library for Furumi players"
readme = "README.md"
documentation = "https://docs.rs/furumi-library"
repository.workspace = true
keywords = ["music", "sqlite", "metadata", "library", "furumi"]
categories = ["multimedia::audio", "database"]
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
anyhow = { workspace = true }
blake3 = { workspace = true }
directories = { workspace = true }
lofty = { workspace = true }
music-dht = { workspace = true }
rusqlite = { workspace = true, features = ["functions"] }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
+13
View File
@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
+21
View File
@@ -0,0 +1,21 @@
# furumi-library
Client-independent persistence foundation for Furumi music players.
The crate owns the shared `library.db` contract: SQLite schema and migrations,
catalog models and queries, local import and metadata extraction, artwork
resolution, playlists, likes, listening history, and similarity index storage.
It contains no UI, playback engine, or application runtime.
During local development clients use a path dependency:
```toml
furumi-library = { path = "../frid/crates/furumi-library" }
```
Adjust the relative path to match the position of the client repository. After
publication, clients can depend on the released crate version instead.
`Library::open(default_db_path()?)` opens the same platform-native Furumi
library used by every client. Connections use WAL, foreign keys, and a busy
timeout so independently running clients can safely share the database.
+561
View File
@@ -0,0 +1,561 @@
//! Importing audio files into the library: directory scanning, tag reading
//! (via lofty) and cover extraction. Importing the same file again updates
//! its metadata instead of duplicating it.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use lofty::file::{AudioFile as _, TaggedFileExt as _};
use lofty::picture::MimeType;
use lofty::tag::{Accessor as _, ItemKey};
use rusqlite::{OptionalExtension as _, params};
use super::{Library, audio_content_id, find_or_create_artist};
/// Extensions the playback engine can decode (rodio/symphonia feature set).
const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"];
/// Everything known about one audio file, ready to be written to the DB.
#[derive(Debug)]
pub struct TrackImport {
pub file_path: String,
pub title: String,
pub artists: Vec<String>,
pub featured_artists: Vec<String>,
pub album_artists: Vec<String>,
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<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: f64,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
pub file_size_bytes: Option<i64>,
/// Embedded cover art (bytes, file extension), if any.
pub cover: Option<(Vec<u8>, &'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<ImportOutcome> {
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<PathBuf>) {
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<TrackImport> {
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<i64> = 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<i64> = 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<String> {
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<String>, Vec<String>) {
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<String> {
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<String>)> {
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());
}
}
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
//! Data shapes the views render. They mirror what the furumusic API used to
//! return, but every field is now filled from the local SQLite library.
use serde::{Deserialize, Serialize};
/// Which catalog sources a client wants library queries to include.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LibrarySourceMode {
Local,
My,
#[default]
Global,
}
impl LibrarySourceMode {
pub fn label(self) -> &'static str {
match self {
Self::Local => "Local",
Self::My => "My",
Self::Global => "Global",
}
}
pub fn includes_network(self) -> bool {
!matches!(self, Self::Local)
}
pub fn includes_global_peers(self) -> bool {
matches!(self, Self::Global)
}
pub fn next(self) -> Self {
match self {
Self::Local => Self::My,
Self::My => Self::Global,
Self::Global => Self::Local,
}
}
}
/// Client-neutral filters accepted by library browse/search operations.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LibraryFilters {
#[serde(default)]
pub hide_featured_only: bool,
#[serde(default)]
pub source_mode: LibrarySourceMode,
}
/// Durable metadata for a track known through federation but not necessarily
/// materialized as a local audio file. Networking remains a client concern.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FederatedTrack {
pub item_id: String,
pub owner: String,
pub own: bool,
pub title: String,
pub artist_names: Vec<String>,
pub featured_artist_names: Vec<String>,
pub year: Option<i32>,
pub duration_seconds: Option<i64>,
pub content_id: Option<String>,
pub release_title: Option<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
}
impl FederatedTrack {
pub fn artist_line(&self) -> String {
let mut line = self.artist_names.join(", ");
if !self.featured_artist_names.is_empty() {
if !line.is_empty() {
line.push_str(" feat. ");
}
line.push_str(&self.featured_artist_names.join(", "));
}
line
}
pub fn owner_short(&self) -> String {
self.owner.chars().take(10).collect()
}
pub fn duration_label(&self) -> String {
self.duration_seconds.map_or_else(String::new, |total| {
format!("{}:{:02}", total / 60, total % 60)
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Availability {
#[default]
Local,
Mixed,
Remote,
}
impl Availability {
pub fn is_remoteish(self) -> bool {
matches!(self, Availability::Mixed | Availability::Remote)
}
}
#[derive(Debug, Clone)]
pub struct ArtistCard {
pub id: i64,
pub name: String,
/// Path to a local image file, if one is set for the artist.
pub image_path: Option<String>,
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<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: f64,
pub artists: Vec<ArtistRef>,
pub featured_artists: Vec<ArtistRef>,
pub release_id: i64,
pub release_title: String,
pub release_year: Option<i32>,
/// Absolute path to the local audio file.
pub file_path: String,
/// Stable audio content id (`b3:<64 hex>`) when known.
pub content_id: Option<String>,
/// Path to a local cover image (the release cover).
pub cover_path: Option<String>,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
pub file_size_bytes: Option<i64>,
/// 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<FederatedTrack>,
}
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::<Vec<_>>()
.join(", ");
let featured = self
.featured_artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.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<i32>,
pub cover_path: Option<String>,
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<String>,
pub total_track_count: i64,
pub total_play_count: i64,
pub top_tracks: Vec<TrackItem>,
pub releases: Vec<ReleaseCard>,
/// Tracks where this artist is featured (the only content for artists
/// without own releases).
pub featured_tracks: Vec<TrackItem>,
}
#[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<i32>,
pub cover_path: Option<String>,
pub artists: Vec<ArtistRef>,
pub tracks: Vec<TrackItem>,
}
#[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<String>,
pub tracks: Vec<TrackItem>,
}
#[derive(Debug, Default)]
pub struct SearchResults {
pub artists: Vec<ArtistCard>,
pub releases: Vec<ReleaseCard>,
pub tracks: Vec<TrackItem>,
}
impl SearchResults {
pub fn len(&self) -> usize {
self.artists.len() + self.releases.len() + self.tracks.len()
}
pub fn is_empty(&self) -> bool {
self.artists.is_empty() && self.releases.is_empty() && self.tracks.is_empty()
}
}
#[derive(Debug)]
pub struct ArtistsPage {
pub items: Vec<ArtistCard>,
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<String>,
pub featured_artists: Vec<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
/// 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<String>,
}
#[derive(Debug, Clone)]
pub struct ReleaseEdit {
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub artists: Vec<String>,
}
#[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");
}
}
+787
View File
@@ -0,0 +1,787 @@
use super::*;
fn test_library() -> Library {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
register_norm_function(&conn).unwrap();
conn.execute_batch(SCHEMA).unwrap();
Library {
conn: Mutex::new(conn),
db_path: std::env::temp_dir().join("furumi-test-library.db"),
covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"),
}
}
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
add_track_with_featured(lib, title, artist, &[], album)
}
fn add_track_with_featured(
lib: &Library,
title: &str,
artist: &str,
featured: &[&str],
album: &str,
) -> i64 {
let import = import::TrackImport {
release_type: None,
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
title: title.to_string(),
artists: vec![artist.to_string()],
featured_artists: featured.iter().map(|name| (*name).to_string()).collect(),
album_artists: vec![artist.to_string()],
release_title: album.to_string(),
year: Some(2020),
track_number: None,
disc_number: None,
duration_seconds: 60.0,
audio_format: Some("mp3".into()),
audio_bitrate: Some(320),
audio_sample_rate: Some(44100),
audio_bit_depth: None,
file_size_bytes: Some(1),
cover: None,
};
let id = import::upsert_track(lib, &import).unwrap().0;
let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex());
lib.lock()
.execute(
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
params![id, content_id],
)
.unwrap();
id
}
fn artist_filters(hide_featured_only: bool) -> LibraryFilters {
LibraryFilters {
hide_featured_only,
..Default::default()
}
}
fn unique_test_dir(label: &str) -> std::path::PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("furumi-{label}-{}-{unique}", std::process::id()))
}
#[test]
fn managed_music_relocation_builds_artist_release_tree_and_updates_paths() {
let root = unique_test_dir("music-relocation");
let old = root.join("old");
let new = root.join("new");
let covers = root.join("covers");
std::fs::create_dir_all(&old).unwrap();
std::fs::create_dir_all(&covers).unwrap();
let audio = old.join("legacy.flac");
let cover = covers.join("release.jpg");
let artist_image = covers.join("artist.png");
std::fs::write(&audio, b"audio").unwrap();
std::fs::write(&cover, b"cover").unwrap();
std::fs::write(&artist_image, b"artist").unwrap();
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
register_norm_function(&conn).unwrap();
conn.execute_batch(SCHEMA).unwrap();
let library = Library {
conn: Mutex::new(conn),
db_path: root.join("library.db"),
covers_dir: covers,
};
let track_id = import::upsert_track(
&library,
&import::TrackImport {
file_path: audio.to_string_lossy().into_owned(),
title: "Song".into(),
artists: vec!["Artist".into()],
featured_artists: vec![],
album_artists: vec!["Artist".into()],
release_title: "Release".into(),
release_type: Some("album".into()),
year: Some(2026),
track_number: Some(1),
disc_number: Some(1),
duration_seconds: 1.0,
audio_format: Some("flac".into()),
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: Some(5),
cover: None,
},
)
.unwrap()
.0;
let (release_id, artist_id): (i64, i64) = library
.lock()
.query_row(
"SELECT t.release_id, ta.artist_id
FROM tracks t JOIN track_artists ta ON ta.track_id = t.id
WHERE t.id = ?1 AND ta.role = 'main'",
[track_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
library
.lock()
.execute(
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
params![release_id, cover.to_string_lossy()],
)
.unwrap();
library
.lock()
.execute(
"UPDATE artists SET image_path = ?2 WHERE id = ?1",
params![artist_id, artist_image.to_string_lossy()],
)
.unwrap();
let stats = library.relocate_managed_music(&old, &new).unwrap();
assert_eq!(stats.tracks, 1);
assert_eq!(stats.images, 2);
let new = std::fs::canonicalize(new).unwrap();
let track = library.tracks_by_ids(&[track_id]).unwrap().remove(0);
assert_eq!(
track.file_path,
new.join("Artist/Release/legacy.flac").to_string_lossy()
);
assert_eq!(
track.cover_path.as_deref(),
Some(
new.join("Artist/Release/cover.jpg")
.to_string_lossy()
.as_ref()
)
);
let image: String = library
.lock()
.query_row(
"SELECT image_path FROM artists WHERE id = ?1",
[artist_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(image, new.join("Artist/artist.png").to_string_lossy());
assert!(!audio.exists());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn music_directory_validation_rejects_a_file_without_touching_it() {
let root = unique_test_dir("music-validation");
std::fs::create_dir_all(&root).unwrap();
let file = root.join("not-a-directory");
std::fs::write(&file, b"keep").unwrap();
assert!(Library::validate_music_directory(&file).is_err());
assert_eq!(std::fs::read(&file).unwrap(), b"keep");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn managed_music_names_are_portable_to_windows() {
assert_eq!(storage_name("Artist/Name", "fallback"), "Artist_Name");
assert_eq!(storage_name("CON", "fallback"), "_CON");
assert_eq!(storage_name("lpt9.live", "fallback"), "_lpt9.live");
assert_eq!(storage_name("...", "fallback"), "fallback");
}
#[test]
fn local_stats_counts_library_rows_and_audio_bytes() {
let lib = test_library();
add_track(&lib, "One", "Artist", "First");
add_track(&lib, "Two", "Artist", "Second");
let stats = lib.local_stats().unwrap();
assert_eq!(stats.artist_count, 1);
assert_eq!(stats.release_count, 2);
assert_eq!(stats.track_count, 2);
assert_eq!(stats.audio_bytes, 2);
assert_eq!(stats.tracks_without_size, 0);
}
#[test]
fn artists_page_prioritizes_releases_then_tracks() {
let lib = test_library();
add_track(&lib, "Solo", "Zed", "Zed Album");
add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album");
add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album");
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
let zed_pos = page
.items
.iter()
.position(|artist| artist.name == "Zed")
.unwrap();
let guest_pos = page
.items
.iter()
.position(|artist| artist.name == "Guest")
.unwrap();
let guest = &page.items[guest_pos];
assert_eq!(guest.release_count, 0);
assert_eq!(guest.track_count, 2);
assert!(zed_pos < guest_pos);
let filtered = lib.artists(1, 10, artist_filters(true)).unwrap();
assert!(filtered.items.iter().all(|artist| artist.release_count > 0));
assert!(!filtered.items.iter().any(|artist| artist.name == "Guest"));
}
#[test]
fn network_artist_image_hint_becomes_local_image_after_fetch() {
let lib = test_library();
let artist_key = music_dht::normalize_name("Remote Artist");
lib.replace_network_artist_cache(
"peer-a",
"personal",
&[NetworkArtistPreview {
artist_key: artist_key.clone(),
name: "Remote Artist".into(),
image_path: Some("peer-local/image.jpg".into()),
release_count: 1,
track_count: 3,
}],
true,
)
.unwrap();
let filters = LibraryFilters {
source_mode: LibrarySourceMode::My,
..Default::default()
};
let page = lib.artists(1, 10, filters).unwrap();
assert_eq!(page.items[0].image_path, None);
let requests = lib
.network_artist_image_requests(filters, &["Remote Artist".into()], 8)
.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].source_id, "peer-a");
assert_eq!(requests[0].artist_key, artist_key);
lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg")
.unwrap();
let page = lib.artists(1, 10, filters).unwrap();
assert_eq!(
page.items[0].image_path.as_deref(),
Some("/tmp/remote-artist.jpg")
);
}
#[test]
fn import_creates_artist_release_track() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].name, "Artist");
assert_eq!(page.items[0].track_count, 1);
let detail = lib.artist(page.items[0].id).unwrap();
assert_eq!(detail.releases.len(), 1);
assert_eq!(detail.top_tracks.len(), 1);
let release = lib.release(detail.releases[0].id).unwrap();
assert_eq!(release.tracks.len(), 1);
assert_eq!(release.tracks[0].id, track_id);
assert_eq!(release.tracks[0].artists[0].name, "Artist");
}
#[test]
fn reimport_updates_instead_of_duplicating() {
let lib = test_library();
let first = add_track(&lib, "Song", "Artist", "Album");
let second = add_track(&lib, "Song", "Artist", "Album");
assert_eq!(first, second);
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
assert_eq!(page.items[0].track_count, 1);
}
#[test]
fn content_id_backfill_hashes_missing_track_ids() {
let lib = test_library();
let path = std::env::temp_dir().join(format!(
"furumi-content-id-test-{}-{}.bin",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, b"portable content id").unwrap();
let file_path = path.to_string_lossy().into_owned();
let import = import::TrackImport {
release_type: None,
file_path: file_path.clone(),
title: "Portable".to_string(),
artists: vec!["Artist".to_string()],
featured_artists: Vec::new(),
album_artists: vec!["Artist".to_string()],
release_title: "Album".to_string(),
year: Some(2026),
track_number: None,
disc_number: None,
duration_seconds: 60.0,
audio_format: Some("bin".into()),
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: Some(19),
cover: None,
};
let track_id = import::upsert_track(&lib, &import).unwrap().0;
let expected = audio_content_id(&file_path).unwrap();
{
let conn = lib.lock();
conn.execute(
"UPDATE tracks SET content_id = NULL WHERE id = ?1",
[track_id],
)
.unwrap();
}
let stats = lib.backfill_missing_content_ids().unwrap();
assert_eq!(stats.hashed, 1);
assert_eq!(stats.updated(), 1);
assert_eq!(
lib.track_content_id_by_id(track_id).unwrap().as_deref(),
Some(expected.as_str())
);
let _ = std::fs::remove_file(path);
}
#[test]
fn search_finds_all_kinds() {
let lib = test_library();
add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album");
let results = lib.search("neon", 10).unwrap();
assert_eq!(results.artists.len(), 1);
assert_eq!(results.releases.len(), 1);
assert_eq!(results.tracks.len(), 1);
// LIKE wildcards in the query must not match everything.
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
}
#[test]
fn search_ranks_exact_names_first() {
let lib = test_library();
add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album");
add_track(&lib, "Needle", "Needle", "Needle");
let results = lib.search("needle", 10).unwrap();
assert_eq!(results.artists[0].name, "Needle");
assert_eq!(results.releases[0].title, "Needle");
assert_eq!(results.tracks[0].title, "Needle");
}
#[test]
fn search_folds_case_beyond_ascii() {
let lib = test_library();
add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом");
// SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script.
assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1);
assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1);
assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1);
assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1);
}
#[test]
fn playlists_and_likes_round_trip() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
let playlist = lib.create_playlist("Mix").unwrap();
lib.add_tracks_to_playlist(playlist.id, &[track_id])
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap();
assert!(lib.toggle_like_by_content_id(&content_id).unwrap());
assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]);
assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1);
assert!(!lib.toggle_like_by_content_id(&content_id).unwrap());
lib.remove_tracks_from_playlist(playlist.id, &[track_id])
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
lib.delete_playlist(playlist.id).unwrap();
// Only the virtual Likes playlist remains.
assert_eq!(lib.playlists().unwrap().len(), 1);
}
#[test]
fn likes_playlist_orders_local_and_federated_by_liked_at() {
let lib = test_library();
let old_id = add_track(&lib, "Old Local", "Artist", "Album");
let new_id = add_track(&lib, "New Local", "Artist", "Album");
let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap();
let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap();
let content_id = format!("b3:{}", "c".repeat(64));
let fed = FederatedTrack {
item_id: "fed_item_order".to_string(),
owner: "fed_owner_order".to_string(),
own: false,
title: "Middle Fed".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: Vec::new(),
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id),
release_title: Some("Remote Release".to_string()),
track_number: Some(1),
disc_number: Some(1),
};
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap());
assert!(lib.toggle_fed_like(&fed).unwrap());
{
let conn = lib.lock();
conn.execute(
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
params![old_id, "2026-01-01 00:00:00"],
)
.unwrap();
conn.execute(
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
params![new_id, "2026-01-02 00:00:00"],
)
.unwrap();
conn.execute(
"UPDATE fed_likes SET liked_at = ?2 WHERE item_id = ?1",
params![fed.item_id, "2026-01-03 00:00:00"],
)
.unwrap();
}
let titles: Vec<String> = 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<String> = lib
.playlist(LIKES_PLAYLIST_ID)
.unwrap()
.tracks
.into_iter()
.map(|track| track.title)
.collect();
assert_eq!(titles, vec!["Old Local", "Middle Fed", "New Local"]);
}
#[test]
fn synced_playlist_can_show_federated_pending_tracks() {
let lib = test_library();
let playlist = lib.create_playlist("Remote Mix").unwrap();
let sync_id = lib.ensure_playlist_sync_id(playlist.id).unwrap();
let content_id = format!("b3:{}", "a".repeat(64));
let fed = FederatedTrack {
item_id: "fed_item_1".to_string(),
owner: "fed_owner_1".to_string(),
own: false,
title: "Remote Song".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: vec!["Remote Guest".to_string()],
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id.clone()),
release_title: Some("Remote Release".to_string()),
track_number: Some(2),
disc_number: Some(1),
};
assert!(lib.upsert_fed_playlist_track(&sync_id, &fed, 4).unwrap());
assert!(
lib.has_playlist_content_reference(&sync_id, &content_id)
.unwrap()
);
let detail = lib.playlist(playlist.id).unwrap();
assert_eq!(detail.tracks.len(), 1);
let track = &detail.tracks[0];
assert!(track.is_fed_pending());
assert_eq!(track.title, "Remote Song");
assert_eq!(track.artist_line(), "Remote Artist feat. Remote Guest");
assert_eq!(track.release_title, "Remote Release");
assert_eq!(track.content_id.as_deref(), Some(content_id.as_str()));
let card = lib
.playlists()
.unwrap()
.into_iter()
.find(|card| card.id == playlist.id)
.unwrap();
assert_eq!(card.track_count, 1);
lib.remove_content_ids_from_playlist(playlist.id, std::slice::from_ref(&content_id))
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
assert!(
lib.fed_playlist_track_by_content_id(&sync_id, &content_id)
.unwrap()
.is_none()
);
}
#[test]
fn add_federated_pending_track_to_playlist_records_position() {
let lib = test_library();
let local_id = add_track(&lib, "Local Song", "Artist", "Album");
let playlist = lib.create_playlist("Remote Mix").unwrap();
let content_id = format!("b3:{}", "b".repeat(64));
let fed = FederatedTrack {
item_id: "fed_item_2".to_string(),
owner: "fed_owner_2".to_string(),
own: false,
title: "Remote Song".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: Vec::new(),
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id.clone()),
release_title: Some("Remote Release".to_string()),
track_number: Some(2),
disc_number: Some(1),
};
lib.add_tracks_to_playlist(playlist.id, &[local_id])
.unwrap();
lib.add_fed_tracks_to_playlist(playlist.id, std::slice::from_ref(&fed))
.unwrap();
let position = lib
.playlist_content_position(playlist.id, &content_id)
.unwrap();
assert_eq!(position, Some(1));
let detail = lib.playlist(playlist.id).unwrap();
assert_eq!(
detail
.tracks
.into_iter()
.map(|track| track.title)
.collect::<Vec<_>>(),
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()
);
}