Compare commits
1
Commits
v0.2.6
...
federation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc0bfa0e5 |
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-42
@@ -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 {
|
||||
|
||||
+1
-59
@@ -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<String>,
|
||||
pub featured_artist_names: Vec<String>,
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
/// Stable audio content id (`b3:<64 hex>`) when the owner published it.
|
||||
pub content_id: Option<String>,
|
||||
/// Release context, known when the track came from an artist card.
|
||||
pub release_title: Option<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
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<String>, 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
|
||||
|
||||
@@ -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<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());
|
||||
}
|
||||
}
|
||||
+2
-3553
File diff suppressed because it is too large
Load Diff
@@ -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<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<crate::federation::FedTrack>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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<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 = 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<_>>(),
|
||||
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()
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user