Compare commits
5
Commits
embed
...
federation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c30cdfcc6 | ||
|
|
cfc0bfa0e5 | ||
|
|
079d87a831 | ||
|
|
add764e51d | ||
|
|
87cb7fe74c |
@@ -123,6 +123,22 @@ The DHT is the distributed index. Nodes publish compact searchable
|
|||||||
descriptions of their local library and query the network without contacting a
|
descriptions of their local library and query the network without contacting a
|
||||||
central search service.
|
central search service.
|
||||||
|
|
||||||
|
Similarity discovery uses a separate schema-independent DHT overlay. A node
|
||||||
|
derives a deterministic 256-bit routing signature from every local embedding,
|
||||||
|
groups them into fixed two-level LSH buckets, and publishes compact summaries
|
||||||
|
containing only fine-bucket representatives, its peer identity, and the ticket
|
||||||
|
needed to dial a previously unknown owner. The owner signs every summary with
|
||||||
|
its existing transport key, so storage peers can relay and cache it but cannot
|
||||||
|
impersonate or modify it. Summaries expire with the ordinary library-record TTL
|
||||||
|
and are replaceable federation cache, never local library authority.
|
||||||
|
|
||||||
|
A similarity search first performs bounded multi-probe LSH lookups to rank
|
||||||
|
likely owners, then sends the existing normalized-vector request directly to
|
||||||
|
at most 16 peers initially and 48 on fallback. Known peers remain a rollout
|
||||||
|
fallback. The model, preprocessing, durable embeddings, exact cosine search,
|
||||||
|
and consent policy remain client-owned; `music-dht` owns only compatible
|
||||||
|
routing math, signed records, replication, and wire bounds.
|
||||||
|
|
||||||
Once a peer is known, communication moves to direct P2P streams provided by
|
Once a peer is known, communication moves to direct P2P streams provided by
|
||||||
iroh through `music-dht`. Furumi defines separate application protocols for
|
iroh through `music-dht`. Furumi defines separate application protocols for
|
||||||
catalog requests, audio transfer, and trusted-device synchronization. This
|
catalog requests, audio transfer, and trusted-device synchronization. This
|
||||||
|
|||||||
+7
-1
@@ -20,12 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Track-seeded similarity search from the track-information popup, including
|
- Track-seeded similarity search from the track-information popup, including
|
||||||
bounded federated queries to compatible known peers.
|
bounded federated queries to compatible known peers.
|
||||||
- The `furumi-fd/similarity/1` protocol in the visible protocol-version status.
|
- The `furumi-fd/similarity/1` protocol in the visible protocol-version status.
|
||||||
|
- Decentralized `similarity_dht` routing with signed anonymous two-level LSH
|
||||||
|
summaries, multi-probe lookup beyond the locally known peer set, and known-
|
||||||
|
peer fallback during gradual network upgrades.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Similarity wire types, bounds, validation, and stream framing now come from
|
- Similarity wire types, bounds, validation, and stream framing now come from
|
||||||
the shared `music-dht 0.3.1` API so native, web, and future clients can
|
the shared `music-dht 0.4.0` API so native, web, and future clients can
|
||||||
interoperate without sharing an embedding implementation.
|
interoperate without sharing an embedding implementation.
|
||||||
|
- Existing SQLite embeddings are backfilled once with compact 256-bit routing
|
||||||
|
signatures; new embeddings store them immediately without changing exact
|
||||||
|
local cosine search.
|
||||||
- A similarity result page keeps the source track first as query context while
|
- A similarity result page keeps the source track first as query context while
|
||||||
excluding it from the actual nearest-neighbor ranking, labels the mode as
|
excluding it from the actual nearest-neighbor ranking, labels the mode as
|
||||||
`Search similar to`, and suppresses near-identical embeddings across releases
|
`Search similar to`, and suppresses near-identical embeddings across releases
|
||||||
|
|||||||
Generated
+35
-4
@@ -1520,7 +1520,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3e690b370c505d153bef214b21a8f2aa55d667367ac1e16bde8bc0de88963c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1646,15 +1648,34 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi_tui"
|
name = "furumi-library"
|
||||||
version = "0.2.5"
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6f15ab98c65d89ea18e55ed8852abd2f029f63876d357270c4647d73601d0a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
"directories",
|
||||||
|
"lofty",
|
||||||
|
"music-dht",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "furumi_tui"
|
||||||
|
version = "0.2.7"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"base64",
|
||||||
|
"blake3",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"crokey",
|
"crokey",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"directories",
|
"directories",
|
||||||
|
"furumi-library",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"image",
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1666,10 +1687,12 @@ dependencies = [
|
|||||||
"rodio",
|
"rodio",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustfft",
|
"rustfft",
|
||||||
|
"rusty-opus",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"souvlaki",
|
"souvlaki",
|
||||||
|
"symphonia",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -3138,7 +3161,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c5b429b90a8f1b0980b3a35a6fa5445d7a275c737eb04db18db4d7f14c81478"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -4969,6 +4994,12 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusty-opus"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa2cbb720cf9bed36712efbae9ffc89d8977bf51b05687b792c1f98a33add8e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.2.6"
|
version = "0.2.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.97"
|
rust-version = "1.97"
|
||||||
description = "A federated P2P player for personal music libraries"
|
description = "A federated P2P player for personal music libraries"
|
||||||
@@ -17,21 +17,24 @@ crokey = "1.4.0"
|
|||||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
futures-util = "0.3.32"
|
futures-util = "0.3.32"
|
||||||
|
furumi-library = "0.1.0"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||||
lofty = "0.22"
|
lofty = "0.22"
|
||||||
# P2P federation: library index in a shared DHT + audio streaming between
|
# P2P federation: library index in a shared DHT + audio streaming between
|
||||||
# peers (same protocol as furumi-fd).
|
# peers (same protocol as furumi-fd).
|
||||||
music-dht = "0.3.1"
|
music-dht = "0.4.0"
|
||||||
ratatui = "0.30.1"
|
ratatui = "0.30.1"
|
||||||
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] }
|
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] }
|
||||||
rhai = { version = "1", features = ["sync"] }
|
rhai = { version = "1", features = ["sync"] }
|
||||||
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
||||||
|
rusty-opus = "0.9.1"
|
||||||
rustfft = "6.4.1"
|
rustfft = "6.4.1"
|
||||||
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
||||||
|
symphonia = { version = "0.5.5", default-features = false, features = ["ogg"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
|
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
|
||||||
toml = "1.1.2"
|
toml = "1.1.2"
|
||||||
@@ -53,3 +56,6 @@ windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_Wi
|
|||||||
|
|
||||||
[target."cfg(unix)".dependencies]
|
[target."cfg(unix)".dependencies]
|
||||||
libc = "0.2.186"
|
libc = "0.2.186"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ without rebuilding the player.
|
|||||||
Optional similarity search calculates versioned embeddings for local tracks
|
Optional similarity search calculates versioned embeddings for local tracks
|
||||||
in the background and keeps them in SQLite. It works offline; after a separate
|
in the background and keeps them in SQLite. It works offline; after a separate
|
||||||
privacy consent it can also ask a bounded set of federation peers for matches.
|
privacy consent it can also ask a bounded set of federation peers for matches.
|
||||||
|
Compatible peers are selected through signed, anonymous LSH summaries in a
|
||||||
|
decentralized DHT; no central recommendation index or shared calibration file
|
||||||
|
is required.
|
||||||
The first selectable model is downloaded on demand and is licensed separately
|
The first selectable model is downloaded on demand and is licensed separately
|
||||||
by MTG under CC BY-NC-SA 4.0 (a proprietary license is also available from
|
by MTG under CC BY-NC-SA 4.0 (a proprietary license is also available from
|
||||||
MTG); Furumi itself remains WTFPL.
|
MTG); Furumi itself remains WTFPL.
|
||||||
|
|||||||
+9
-15
@@ -8,7 +8,6 @@ use crate::app::Runtime;
|
|||||||
use crate::app::command::{self, Command, Parsed};
|
use crate::app::command::{self, Command, Parsed};
|
||||||
use crate::app::event::AppEvent;
|
use crate::app::event::AppEvent;
|
||||||
use crate::app::state::{AppState, GlobalView, SearchState, Tab};
|
use crate::app::state::{AppState, GlobalView, SearchState, Tab};
|
||||||
use crate::library::models::SearchResults;
|
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
|
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
|
||||||
const SEARCH_LIMIT: i64 = 12;
|
const SEARCH_LIMIT: i64 = 12;
|
||||||
@@ -99,6 +98,10 @@ fn set_view_cursor_zero(state: &mut AppState) {
|
|||||||
/// and the receiver drops responses that arrive out of date.
|
/// and the receiver drops responses that arrive out of date.
|
||||||
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||||
state.search.similarity_source = None;
|
state.search.similarity_source = None;
|
||||||
|
state.search.similarity_source_track = None;
|
||||||
|
state.search.similarity_tracks.clear();
|
||||||
|
state.search.similarity_stats = None;
|
||||||
|
state.search.similarity_error = None;
|
||||||
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
let query = state.search.query.clone();
|
let query = state.search.query.clone();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
@@ -160,6 +163,10 @@ pub(super) fn schedule_similarity_search(
|
|||||||
format!("{} — {artist}", track.title)
|
format!("{} — {artist}", track.title)
|
||||||
};
|
};
|
||||||
state.search.similarity_source = Some(track.id);
|
state.search.similarity_source = Some(track.id);
|
||||||
|
state.search.similarity_source_track = Some(track.clone());
|
||||||
|
state.search.similarity_tracks.clear();
|
||||||
|
state.search.similarity_stats = None;
|
||||||
|
state.search.similarity_error = None;
|
||||||
state.search.loading = true;
|
state.search.loading = true;
|
||||||
state.search.results = None;
|
state.search.results = None;
|
||||||
state.search.fed_tracks.clear();
|
state.search.fed_tracks.clear();
|
||||||
@@ -178,23 +185,10 @@ pub(super) fn schedule_similarity_search(
|
|||||||
let similarity = Arc::clone(&runtime.similarity);
|
let similarity = Arc::clone(&runtime.similarity);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
let track_id = track.id;
|
let track_id = track.id;
|
||||||
let source_track = track.clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = similarity
|
let result = similarity
|
||||||
.search_track(track_id, 49)
|
.search_track(track_id, 49)
|
||||||
.map(|(matches, query)| {
|
.map(|(matches, query)| (matches, query));
|
||||||
let mut tracks = Vec::with_capacity(1 + matches.len());
|
|
||||||
tracks.push(source_track);
|
|
||||||
tracks.extend(matches.into_iter().map(|found| found.track));
|
|
||||||
(
|
|
||||||
SearchResults {
|
|
||||||
artists: Vec::new(),
|
|
||||||
releases: Vec::new(),
|
|
||||||
tracks,
|
|
||||||
},
|
|
||||||
query,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let (result, query) = match result {
|
let (result, query) = match result {
|
||||||
Ok((results, query)) => (Ok(results), Some(query)),
|
Ok((results, query)) => (Ok(results), Some(query)),
|
||||||
Err(err) => (Err(format!("{err:#}")), None),
|
Err(err) => (Err(format!("{err:#}")), None),
|
||||||
|
|||||||
+6
-1
@@ -36,9 +36,14 @@ pub enum AppEvent {
|
|||||||
/// text search so stale pages cannot overwrite a newer request.
|
/// text search so stale pages cannot overwrite a newer request.
|
||||||
SimilaritySearchLoaded {
|
SimilaritySearchLoaded {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
result: Result<SearchResults, String>,
|
result: Result<Vec<crate::similarity::SimilarTrack>, String>,
|
||||||
query: Option<crate::similarity::QueryVector>,
|
query: Option<crate::similarity::QueryVector>,
|
||||||
},
|
},
|
||||||
|
/// Federated candidates and diagnostics for a track-seeded search.
|
||||||
|
FedSimilaritySearchLoaded {
|
||||||
|
seq: u64,
|
||||||
|
result: Result<crate::federation::FedSimilaritySearchResults, String>,
|
||||||
|
},
|
||||||
SimilarityStatus(crate::similarity::SimilarityStatus),
|
SimilarityStatus(crate::similarity::SimilarityStatus),
|
||||||
/// `None` is emitted after clearing every stored embedding.
|
/// `None` is emitted after clearing every stored embedding.
|
||||||
SimilarityProfileActivated(Option<String>),
|
SimilarityProfileActivated(Option<String>),
|
||||||
|
|||||||
+225
-13
@@ -44,6 +44,10 @@ pub struct Runtime {
|
|||||||
pub similarity: Arc<crate::similarity::Manager>,
|
pub similarity: Arc<crate::similarity::Manager>,
|
||||||
/// When the last Federation-tab status snapshot was requested.
|
/// When the last Federation-tab status snapshot was requested.
|
||||||
pub fed_status_at: Option<std::time::Instant>,
|
pub fed_status_at: Option<std::time::Instant>,
|
||||||
|
/// Keeps the last successful local-data snapshot visible while a newer
|
||||||
|
/// one is calculated and collapses bursts of library-change events.
|
||||||
|
pub local_library_stats_refreshing: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
pub local_library_stats_refresh_requested: Arc<std::sync::atomic::AtomicBool>,
|
||||||
pub library_network_refresh_at: Option<std::time::Instant>,
|
pub library_network_refresh_at: Option<std::time::Instant>,
|
||||||
pub library_network_refreshing: Arc<std::sync::atomic::AtomicBool>,
|
pub library_network_refreshing: Arc<std::sync::atomic::AtomicBool>,
|
||||||
pub library_network_cursors:
|
pub library_network_cursors:
|
||||||
@@ -102,11 +106,35 @@ fn refresh_local_content_ids(runtime: &Runtime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_local_library_stats(runtime: &Runtime) {
|
fn refresh_local_library_stats(runtime: &Runtime) {
|
||||||
|
runtime
|
||||||
|
.local_library_stats_refresh_requested
|
||||||
|
.store(true, std::sync::atomic::Ordering::Release);
|
||||||
|
if runtime
|
||||||
|
.local_library_stats_refreshing
|
||||||
|
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
|
let refreshing = Arc::clone(&runtime.local_library_stats_refreshing);
|
||||||
|
let requested = Arc::clone(&runtime.local_library_stats_refresh_requested);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = library.local_stats().map_err(err_string);
|
loop {
|
||||||
let _ = tx.send(AppEvent::LocalLibraryStatsLoaded(result));
|
requested.store(false, std::sync::atomic::Ordering::Release);
|
||||||
|
let result = library.local_stats().map_err(err_string);
|
||||||
|
let _ = tx.send(AppEvent::LocalLibraryStatsLoaded(result));
|
||||||
|
if requested.load(std::sync::atomic::Ordering::Acquire) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
refreshing.store(false, std::sync::atomic::Ordering::Release);
|
||||||
|
if requested.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||||
|
&& !refreshing.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +331,8 @@ pub async fn run(
|
|||||||
federation,
|
federation,
|
||||||
similarity,
|
similarity,
|
||||||
fed_status_at: None,
|
fed_status_at: None,
|
||||||
|
local_library_stats_refreshing: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
local_library_stats_refresh_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
library_network_refresh_at: None,
|
library_network_refresh_at: None,
|
||||||
library_network_refreshing: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
library_network_refreshing: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
library_network_cursors: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
library_network_cursors: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
@@ -2746,7 +2776,12 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
// until then.
|
// until then.
|
||||||
state.likes_loaded = false;
|
state.likes_loaded = false;
|
||||||
state.local_content_ids_loaded = false;
|
state.local_content_ids_loaded = false;
|
||||||
state.local_library_stats = None;
|
// Refresh in place: status cards keep the last successful snapshot
|
||||||
|
// instead of flashing `loading` for every background library event.
|
||||||
|
if state.local_library_stats.is_none() {
|
||||||
|
state.local_library_stats = Some(state::Loadable::Loading);
|
||||||
|
}
|
||||||
|
refresh_local_library_stats(runtime);
|
||||||
|
|
||||||
// Fresh copies of whatever sits in the queue. Federated placeholders
|
// Fresh copies of whatever sits in the queue. Federated placeholders
|
||||||
// and ephemeral tracks (negative ids) are not library rows and keep
|
// and ephemeral tracks (negative ids) are not library rows and keep
|
||||||
@@ -3313,6 +3348,39 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
Err(message) => tracing::warn!(%message, "federated search failed"),
|
Err(message) => tracing::warn!(%message, "federated search failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEvent::FedSimilaritySearchLoaded { seq, result } => {
|
||||||
|
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.search.fed_loading = false;
|
||||||
|
let selected_key = state.global.stack.last().and_then(|view| match view {
|
||||||
|
state::GlobalView::Search { cursor } => state.search.similarity_key(*cursor),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(results) => {
|
||||||
|
let remote = results.tracks.into_iter().map(|hit| {
|
||||||
|
state::SimilaritySearchHit::Federated {
|
||||||
|
track: hit.track,
|
||||||
|
score: hit.score,
|
||||||
|
embedding_signature: hit.embedding_signature,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.search.similarity_tracks.extend(remote);
|
||||||
|
rank_similarity_search_tracks(
|
||||||
|
&mut state.search.similarity_tracks,
|
||||||
|
state.similarity.settings.max_tracks_per_artist,
|
||||||
|
);
|
||||||
|
state.search.similarity_stats = Some(results.stats);
|
||||||
|
state.search.similarity_error = None;
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
tracing::warn!(%message, "federated similarity search failed");
|
||||||
|
state.search.similarity_error = Some(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restore_similarity_cursor(state, selected_key.as_deref());
|
||||||
|
}
|
||||||
AppEvent::FedTrackResolved {
|
AppEvent::FedTrackResolved {
|
||||||
placeholder_id,
|
placeholder_id,
|
||||||
resolve_key,
|
resolve_key,
|
||||||
@@ -3661,7 +3729,20 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
state.search.loading = false;
|
state.search.loading = false;
|
||||||
match result {
|
match result {
|
||||||
Ok(results) => state.search.results = Some(results),
|
Ok(results) => {
|
||||||
|
state.search.similarity_tracks = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|hit| state::SimilaritySearchHit::Local {
|
||||||
|
track: hit.track,
|
||||||
|
score: hit.score,
|
||||||
|
embedding_signature: hit.embedding_signature,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rank_similarity_search_tracks(
|
||||||
|
&mut state.search.similarity_tracks,
|
||||||
|
state.similarity.settings.max_tracks_per_artist,
|
||||||
|
);
|
||||||
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
state.status_message = Some(format!("similarity search failed: {message}"));
|
state.status_message = Some(format!("similarity search failed: {message}"));
|
||||||
return;
|
return;
|
||||||
@@ -3679,7 +3760,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
.search_similar(query, 50)
|
.search_similar(query, 50)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| format!("{err:#}"));
|
.map_err(|err| format!("{err:#}"));
|
||||||
let _ = tx.send(AppEvent::FedSearchLoaded { seq, result });
|
let _ = tx.send(AppEvent::FedSimilaritySearchLoaded { seq, result });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3792,15 +3873,17 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
tracing::warn!(%message, "local content id load failed");
|
tracing::warn!(%message, "local content id load failed");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
AppEvent::LocalLibraryStatsLoaded(result) => {
|
AppEvent::LocalLibraryStatsLoaded(result) => match result {
|
||||||
state.local_library_stats = Some(match result {
|
Ok(stats) => {
|
||||||
Ok(stats) => state::Loadable::Ready(stats),
|
state.local_library_stats = Some(state::Loadable::Ready(stats));
|
||||||
Err(message) => {
|
}
|
||||||
tracing::warn!(%message, "local library stats load failed");
|
Err(message) => {
|
||||||
state::Loadable::Failed(message)
|
tracing::warn!(%message, "local library stats load failed");
|
||||||
|
if !matches!(state.local_library_stats, Some(state::Loadable::Ready(_))) {
|
||||||
|
state.local_library_stats = Some(state::Loadable::Failed(message));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
},
|
||||||
AppEvent::LocalContentAvailable { content_id } => {
|
AppEvent::LocalContentAvailable { content_id } => {
|
||||||
if let Some(content_id) = music_dht::normalize_content_id(&content_id) {
|
if let Some(content_id) = music_dht::normalize_content_id(&content_id) {
|
||||||
state.local_content_ids.insert(content_id);
|
state.local_content_ids.insert(content_id);
|
||||||
@@ -3970,6 +4053,135 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rank_similarity_search_tracks(
|
||||||
|
tracks: &mut Vec<state::SimilaritySearchHit>,
|
||||||
|
max_tracks_per_artist: usize,
|
||||||
|
) {
|
||||||
|
const RESULT_LIMIT: usize = 49;
|
||||||
|
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||||
|
|
||||||
|
tracks.sort_by(|left, right| right.score().total_cmp(&left.score()));
|
||||||
|
let candidates = std::mem::take(tracks);
|
||||||
|
let mut content = std::collections::HashSet::new();
|
||||||
|
let mut signatures = Vec::new();
|
||||||
|
let mut artist_counts: std::collections::HashMap<String, usize> = Default::default();
|
||||||
|
for hit in candidates {
|
||||||
|
if !content.insert(hit.content_key()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hit.embedding_signature().is_some_and(|candidate| {
|
||||||
|
signatures.iter().any(|existing| {
|
||||||
|
music_dht::similarity::signature_distance(&candidate, existing)
|
||||||
|
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let artist = hit.primary_artist_key();
|
||||||
|
let count = artist_counts.entry(artist.clone()).or_default();
|
||||||
|
if !artist.is_empty() && *count >= max_tracks_per_artist.clamp(1, RESULT_LIMIT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*count += 1;
|
||||||
|
if let Some(signature) = hit.embedding_signature() {
|
||||||
|
signatures.push(signature);
|
||||||
|
}
|
||||||
|
tracks.push(hit);
|
||||||
|
if tracks.len() >= RESULT_LIMIT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_similarity_cursor(state: &mut AppState, selected_key: Option<&str>) {
|
||||||
|
let selected_index = selected_key.and_then(|key| state.search.similarity_index_for_key(key));
|
||||||
|
let len = state.search.similarity_len();
|
||||||
|
if let Some(state::GlobalView::Search { cursor }) = state.global.stack.last_mut() {
|
||||||
|
*cursor = selected_index.unwrap_or(*cursor).min(len.saturating_sub(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod similarity_search_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::library::models::{ArtistRef, TrackItem};
|
||||||
|
|
||||||
|
fn local_hit(id: i64, artist: &str, score: f32, signature: u8) -> state::SimilaritySearchHit {
|
||||||
|
state::SimilaritySearchHit::Local {
|
||||||
|
track: TrackItem {
|
||||||
|
id,
|
||||||
|
title: format!("local {id}"),
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
|
duration_seconds: 1.0,
|
||||||
|
artists: vec![ArtistRef {
|
||||||
|
id,
|
||||||
|
name: artist.to_string(),
|
||||||
|
}],
|
||||||
|
featured_artists: Vec::new(),
|
||||||
|
release_id: id,
|
||||||
|
release_title: "release".to_string(),
|
||||||
|
release_year: None,
|
||||||
|
file_path: format!("/music/{id}"),
|
||||||
|
content_id: Some(format!("local-{id}")),
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
score,
|
||||||
|
embedding_signature: [signature; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_hit(artist: &str, score: f32, signature: u8) -> state::SimilaritySearchHit {
|
||||||
|
state::SimilaritySearchHit::Federated {
|
||||||
|
track: crate::federation::FedTrack {
|
||||||
|
item_id: format!("remote-{signature}"),
|
||||||
|
owner: "peer".to_string(),
|
||||||
|
own: false,
|
||||||
|
title: format!("remote {signature}"),
|
||||||
|
artist_names: vec![artist.to_string()],
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
|
year: None,
|
||||||
|
duration_seconds: Some(1),
|
||||||
|
content_id: Some(format!("remote-{signature}")),
|
||||||
|
release_title: None,
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
|
},
|
||||||
|
score,
|
||||||
|
embedding_signature: Some(
|
||||||
|
[signature; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similarity_results_rank_local_and_remote_together_with_one_artist_cap() {
|
||||||
|
let mut tracks = vec![
|
||||||
|
local_hit(1, "same artist", 0.70, 1),
|
||||||
|
remote_hit("other artist", 0.90, 2),
|
||||||
|
remote_hit("same artist", 0.80, 3),
|
||||||
|
local_hit(2, "same artist", 0.60, 4),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_similarity_search_tracks(&mut tracks, 1);
|
||||||
|
|
||||||
|
assert_eq!(tracks.len(), 2);
|
||||||
|
assert_eq!(tracks[0].score(), 0.90);
|
||||||
|
assert_eq!(tracks[1].score(), 0.80);
|
||||||
|
assert!(matches!(
|
||||||
|
tracks[0],
|
||||||
|
state::SimilaritySearchHit::Federated { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mirror the playback state to the OS now-playing surface. `force` skips
|
/// Mirror the playback state to the OS now-playing surface. `force` skips
|
||||||
/// the position throttle (track switches, pauses).
|
/// the position throttle (track switches, pauses).
|
||||||
fn push_media_update(state: &AppState, runtime: &mut Runtime, force: bool) {
|
fn push_media_update(state: &AppState, runtime: &mut Runtime, force: bool) {
|
||||||
|
|||||||
@@ -592,6 +592,36 @@ fn handle_fed_input(
|
|||||||
state.popup = Some(Popup::FedInput { field, input });
|
state.popup = Some(Popup::FedInput { field, input });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
FedInputField::SimilarityMinimumScore => match value.parse::<f32>() {
|
||||||
|
Ok(score) if score.is_finite() && (0.0..=1.0).contains(&score) => {
|
||||||
|
state.similarity.settings.minimum_score = score;
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::SimilarityApplySettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.status_message =
|
||||||
|
Some("minimum similarity must be a number from 0.00 to 1.00".into());
|
||||||
|
state.popup = Some(Popup::FedInput { field, input });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FedInputField::SimilarityMaxTracksPerArtist => match value.parse::<usize>() {
|
||||||
|
Ok(limit @ 1..=50) => {
|
||||||
|
state.similarity.settings.max_tracks_per_artist = limit;
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::SimilarityApplySettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.status_message =
|
||||||
|
Some("tracks per artist must be a number from 1 to 50".into());
|
||||||
|
state.popup = Some(Popup::FedInput { field, input });
|
||||||
|
}
|
||||||
|
},
|
||||||
FedInputField::ConnectTicket => {
|
FedInputField::ConnectTicket => {
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
state.status_message = Some("ticket is empty".into());
|
state.status_message = Some("ticket is empty".into());
|
||||||
|
|||||||
+149
-1
@@ -509,6 +509,8 @@ pub enum TrackSelectionScope {
|
|||||||
Release(i64),
|
Release(i64),
|
||||||
Playlist(i64),
|
Playlist(i64),
|
||||||
Queue,
|
Queue,
|
||||||
|
/// The unified local + federated similar-track result list.
|
||||||
|
SimilaritySearch,
|
||||||
/// The federated section of the search results (its tracks).
|
/// The federated section of the search results (its tracks).
|
||||||
FedSearch,
|
FedSearch,
|
||||||
/// The tracklist of the open federated release view.
|
/// The tracklist of the open federated release view.
|
||||||
@@ -817,6 +819,8 @@ impl StatusDetailFocus {
|
|||||||
pub enum FedInputField {
|
pub enum FedInputField {
|
||||||
MusicDirectory,
|
MusicDirectory,
|
||||||
SimilarityWorkers,
|
SimilarityWorkers,
|
||||||
|
SimilarityMinimumScore,
|
||||||
|
SimilarityMaxTracksPerArtist,
|
||||||
NetworkId,
|
NetworkId,
|
||||||
ConnectTicket,
|
ConnectTicket,
|
||||||
DeviceName,
|
DeviceName,
|
||||||
@@ -829,6 +833,8 @@ impl FedInputField {
|
|||||||
match self {
|
match self {
|
||||||
FedInputField::MusicDirectory => "Music save directory",
|
FedInputField::MusicDirectory => "Music save directory",
|
||||||
FedInputField::SimilarityWorkers => "Similarity background workers",
|
FedInputField::SimilarityWorkers => "Similarity background workers",
|
||||||
|
FedInputField::SimilarityMinimumScore => "Minimum similarity score",
|
||||||
|
FedInputField::SimilarityMaxTracksPerArtist => "Tracks per artist",
|
||||||
FedInputField::NetworkId => "Network ID",
|
FedInputField::NetworkId => "Network ID",
|
||||||
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
||||||
FedInputField::DeviceName => "Device name",
|
FedInputField::DeviceName => "Device name",
|
||||||
@@ -845,6 +851,12 @@ impl FedInputField {
|
|||||||
FedInputField::SimilarityWorkers => {
|
FedInputField::SimilarityWorkers => {
|
||||||
"Enter the maximum number of tracks processed in parallel, from 1 to 16. The change takes effect immediately."
|
"Enter the maximum number of tracks processed in parallel, from 1 to 16. The change takes effect immediately."
|
||||||
}
|
}
|
||||||
|
FedInputField::SimilarityMinimumScore => {
|
||||||
|
"Enter the minimum cosine similarity from 0.00 to 1.00. Lower values show broader matches; higher values hide weak matches. Embeddings are not recalculated."
|
||||||
|
}
|
||||||
|
FedInputField::SimilarityMaxTracksPerArtist => {
|
||||||
|
"Enter how many tracks by one primary artist may appear in similarity results, from 1 to 50. Embeddings are not recalculated."
|
||||||
|
}
|
||||||
FedInputField::NetworkId => {
|
FedInputField::NetworkId => {
|
||||||
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
|
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
|
||||||
}
|
}
|
||||||
@@ -880,15 +892,19 @@ pub enum SimilarityRow {
|
|||||||
Toggle,
|
Toggle,
|
||||||
Model,
|
Model,
|
||||||
Profile,
|
Profile,
|
||||||
|
MinimumScore,
|
||||||
|
MaxTracksPerArtist,
|
||||||
Workers,
|
Workers,
|
||||||
Clear,
|
Clear,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SimilarityRow {
|
impl SimilarityRow {
|
||||||
pub const ALL: [SimilarityRow; 5] = [
|
pub const ALL: [SimilarityRow; 7] = [
|
||||||
SimilarityRow::Toggle,
|
SimilarityRow::Toggle,
|
||||||
SimilarityRow::Model,
|
SimilarityRow::Model,
|
||||||
SimilarityRow::Profile,
|
SimilarityRow::Profile,
|
||||||
|
SimilarityRow::MinimumScore,
|
||||||
|
SimilarityRow::MaxTracksPerArtist,
|
||||||
SimilarityRow::Workers,
|
SimilarityRow::Workers,
|
||||||
SimilarityRow::Clear,
|
SimilarityRow::Clear,
|
||||||
];
|
];
|
||||||
@@ -1211,6 +1227,85 @@ mod cmdline_history_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Live search state driven by the `:/query` command.
|
/// Live search state driven by the `:/query` command.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SimilaritySearchHit {
|
||||||
|
Local {
|
||||||
|
track: TrackItem,
|
||||||
|
score: f32,
|
||||||
|
embedding_signature: [u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES],
|
||||||
|
},
|
||||||
|
Federated {
|
||||||
|
track: crate::federation::FedTrack,
|
||||||
|
score: f32,
|
||||||
|
embedding_signature: Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimilaritySearchHit {
|
||||||
|
pub fn score(&self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Local { score, .. } | Self::Federated { score, .. } => *score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn embedding_signature(
|
||||||
|
&self,
|
||||||
|
) -> Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]> {
|
||||||
|
match self {
|
||||||
|
Self::Local {
|
||||||
|
embedding_signature,
|
||||||
|
..
|
||||||
|
} => Some(*embedding_signature),
|
||||||
|
Self::Federated {
|
||||||
|
embedding_signature,
|
||||||
|
..
|
||||||
|
} => *embedding_signature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_item(&self) -> TrackItem {
|
||||||
|
match self {
|
||||||
|
Self::Local { track, .. } => track.clone(),
|
||||||
|
Self::Federated { track, .. } => crate::federation::pending_track(track),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn federated_track(&self) -> Option<&crate::federation::FedTrack> {
|
||||||
|
match self {
|
||||||
|
Self::Federated { track, .. } => Some(track),
|
||||||
|
Self::Local { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn primary_artist_key(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Local { track, .. } => track
|
||||||
|
.artists
|
||||||
|
.first()
|
||||||
|
.map(|artist| music_dht::normalize_name(&artist.name))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Self::Federated { track, .. } => track
|
||||||
|
.artist_names
|
||||||
|
.first()
|
||||||
|
.map(|artist| music_dht::normalize_name(artist))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_key(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Local { track, .. } => track
|
||||||
|
.content_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("local:{}", track.id)),
|
||||||
|
Self::Federated { track, .. } => track
|
||||||
|
.content_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("remote:{}:{}", track.owner, track.item_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SearchState {
|
pub struct SearchState {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
@@ -1226,6 +1321,59 @@ pub struct SearchState {
|
|||||||
/// Present only for a track-seeded search; text-search refreshes must not
|
/// Present only for a track-seeded search; text-search refreshes must not
|
||||||
/// replace this page with a title query.
|
/// replace this page with a title query.
|
||||||
pub similarity_source: Option<i64>,
|
pub similarity_source: Option<i64>,
|
||||||
|
/// The source is pinned at row zero; candidates below it are globally
|
||||||
|
/// ranked across the local library and federation.
|
||||||
|
pub similarity_source_track: Option<TrackItem>,
|
||||||
|
pub similarity_tracks: Vec<SimilaritySearchHit>,
|
||||||
|
pub similarity_stats: Option<crate::federation::SimilaritySearchStats>,
|
||||||
|
pub similarity_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchState {
|
||||||
|
pub fn similarity_len(&self) -> usize {
|
||||||
|
usize::from(self.similarity_source_track.is_some()) + self.similarity_tracks.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_track(&self, index: usize) -> Option<TrackItem> {
|
||||||
|
if index == 0 {
|
||||||
|
return self.similarity_source_track.clone();
|
||||||
|
}
|
||||||
|
self.similarity_tracks
|
||||||
|
.get(index.checked_sub(1)?)
|
||||||
|
.map(SimilaritySearchHit::track_item)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_fed_track(&self, index: usize) -> Option<&crate::federation::FedTrack> {
|
||||||
|
self.similarity_tracks
|
||||||
|
.get(index.checked_sub(1)?)?
|
||||||
|
.federated_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_key(&self, index: usize) -> Option<String> {
|
||||||
|
if index == 0 {
|
||||||
|
return self
|
||||||
|
.similarity_source_track
|
||||||
|
.as_ref()
|
||||||
|
.map(|track| format!("source:{}", track.id));
|
||||||
|
}
|
||||||
|
self.similarity_tracks
|
||||||
|
.get(index.checked_sub(1)?)
|
||||||
|
.map(SimilaritySearchHit::content_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_index_for_key(&self, key: &str) -> Option<usize> {
|
||||||
|
if self
|
||||||
|
.similarity_source_track
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|track| key == format!("source:{}", track.id))
|
||||||
|
{
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
self.similarity_tracks
|
||||||
|
.iter()
|
||||||
|
.position(|hit| hit.content_key() == key)
|
||||||
|
.map(|index| index + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
|||||||
+113
-29
@@ -905,6 +905,9 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
|||||||
TrackSelectionScope::Queue => {
|
TrackSelectionScope::Queue => {
|
||||||
state.queue_tab.cursor = value;
|
state.queue_tab.cursor = value;
|
||||||
}
|
}
|
||||||
|
TrackSelectionScope::SimilaritySearch => {
|
||||||
|
set_view_cursor(state, value);
|
||||||
|
}
|
||||||
TrackSelectionScope::FedSearch => {
|
TrackSelectionScope::FedSearch => {
|
||||||
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
||||||
+ state.search.fed_artists.len();
|
+ state.search.fed_artists.len();
|
||||||
@@ -952,6 +955,14 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
|||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
GlobalView::Search { cursor } => {
|
GlobalView::Search { cursor } => {
|
||||||
|
if state.search.similarity_source.is_some() {
|
||||||
|
let len = state.search.similarity_len();
|
||||||
|
return (*cursor < len).then_some((
|
||||||
|
TrackSelectionScope::SimilaritySearch,
|
||||||
|
*cursor,
|
||||||
|
len,
|
||||||
|
));
|
||||||
|
}
|
||||||
// Only the federated tracks section is selectable here.
|
// Only the federated tracks section is selectable here.
|
||||||
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
let base = state.search.results.as_ref().map_or(0, |r| r.len())
|
||||||
+ state.search.fed_artists.len();
|
+ state.search.fed_artists.len();
|
||||||
@@ -1044,6 +1055,20 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, V
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
||||||
|
if state.active_tab == Tab::Global
|
||||||
|
&& state.search.similarity_source.is_some()
|
||||||
|
&& let Some(GlobalView::Search { cursor }) = state.global.stack.last()
|
||||||
|
{
|
||||||
|
let len = state.search.similarity_len();
|
||||||
|
let indices = state
|
||||||
|
.track_selection
|
||||||
|
.indices(&TrackSelectionScope::SimilaritySearch, len)
|
||||||
|
.unwrap_or_else(|| vec![(*cursor).min(len.saturating_sub(1))]);
|
||||||
|
return indices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|index| state.search.similarity_track(index))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
// Federated contexts produce queueable placeholders that behave like
|
// Federated contexts produce queueable placeholders that behave like
|
||||||
// regular tracks (queue, info, playback-on-demand).
|
// regular tracks (queue, info, playback-on-demand).
|
||||||
{
|
{
|
||||||
@@ -1276,6 +1301,9 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
GlobalView::Search { cursor } => {
|
GlobalView::Search { cursor } => {
|
||||||
|
if state.search.similarity_source.is_some() {
|
||||||
|
return state.search.similarity_track(*cursor);
|
||||||
|
}
|
||||||
let results = state.search.results.as_ref()?;
|
let results = state.search.results.as_ref()?;
|
||||||
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
|
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
|
||||||
match results.tracks.get(offset) {
|
match results.tracks.get(offset) {
|
||||||
@@ -1877,10 +1905,13 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
refresh_track_selection_cursor(state);
|
refresh_track_selection_cursor(state);
|
||||||
}
|
}
|
||||||
Some(GlobalView::Search { cursor }) => {
|
Some(GlobalView::Search { cursor }) => {
|
||||||
// Local results plus the federated section below them.
|
let total = if state.search.similarity_source.is_some() {
|
||||||
let total = (state.search.results.as_ref().map_or(0, |r| r.len())
|
state.search.similarity_len()
|
||||||
+ state.search.fed_artists.len()
|
} else {
|
||||||
+ state.search.fed_tracks.len()) as isize;
|
state.search.results.as_ref().map_or(0, |r| r.len())
|
||||||
|
+ state.search.fed_artists.len()
|
||||||
|
+ state.search.fed_tracks.len()
|
||||||
|
} as isize;
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2000,9 +2031,13 @@ fn current_view_len(state: &AppState) -> usize {
|
|||||||
_ => 0,
|
_ => 0,
|
||||||
},
|
},
|
||||||
Some(GlobalView::Search { .. }) => {
|
Some(GlobalView::Search { .. }) => {
|
||||||
state.search.results.as_ref().map_or(0, |r| r.len())
|
if state.search.similarity_source.is_some() {
|
||||||
+ state.search.fed_artists.len()
|
state.search.similarity_len()
|
||||||
+ state.search.fed_tracks.len()
|
} else {
|
||||||
|
state.search.results.as_ref().map_or(0, |r| r.len())
|
||||||
|
+ state.search.fed_artists.len()
|
||||||
|
+ state.search.fed_tracks.len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
|
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
|
||||||
Some(GlobalView::FedRelease { index, .. }) => fed_card_release(state, *index)
|
Some(GlobalView::FedRelease { index, .. }) => fed_card_release(state, *index)
|
||||||
@@ -2292,31 +2327,47 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
},
|
},
|
||||||
_ => Outcome::Nothing,
|
_ => Outcome::Nothing,
|
||||||
},
|
},
|
||||||
Some(GlobalView::Search { cursor }) => match &state.search.results {
|
Some(GlobalView::Search { cursor }) => {
|
||||||
Some(results) => {
|
if state.search.similarity_source.is_some() {
|
||||||
let artists = results.artists.len();
|
let tracks = (0..state.search.similarity_len())
|
||||||
let releases = results.releases.len();
|
.filter_map(|index| state.search.similarity_track(index))
|
||||||
if cursor < artists {
|
.collect::<Vec<_>>();
|
||||||
Outcome::Push(GlobalView::Artist {
|
if tracks.is_empty() {
|
||||||
id: results.artists[cursor].id,
|
Outcome::Nothing
|
||||||
cursor: 0,
|
|
||||||
})
|
|
||||||
} else if cursor < artists + releases {
|
|
||||||
Outcome::Push(GlobalView::Release {
|
|
||||||
id: results.releases[cursor - artists].id,
|
|
||||||
cursor: 0,
|
|
||||||
})
|
|
||||||
} else if results.tracks.get(cursor - artists - releases).is_some() {
|
|
||||||
Outcome::Play {
|
|
||||||
tracks: results.tracks.clone(),
|
|
||||||
start: cursor - artists - releases,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
fed_outcome(state, cursor - artists - releases - results.tracks.len())
|
Outcome::Play {
|
||||||
|
start: cursor.min(tracks.len() - 1),
|
||||||
|
tracks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match &state.search.results {
|
||||||
|
Some(results) => {
|
||||||
|
let artists = results.artists.len();
|
||||||
|
let releases = results.releases.len();
|
||||||
|
if cursor < artists {
|
||||||
|
Outcome::Push(GlobalView::Artist {
|
||||||
|
id: results.artists[cursor].id,
|
||||||
|
cursor: 0,
|
||||||
|
})
|
||||||
|
} else if cursor < artists + releases {
|
||||||
|
Outcome::Push(GlobalView::Release {
|
||||||
|
id: results.releases[cursor - artists].id,
|
||||||
|
cursor: 0,
|
||||||
|
})
|
||||||
|
} else if results.tracks.get(cursor - artists - releases).is_some() {
|
||||||
|
Outcome::Play {
|
||||||
|
tracks: results.tracks.clone(),
|
||||||
|
start: cursor - artists - releases,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fed_outcome(state, cursor - artists - releases - results.tracks.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => fed_outcome(state, cursor),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => fed_outcome(state, cursor),
|
}
|
||||||
},
|
|
||||||
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
||||||
Some((_, Loadable::Ready(card))) => {
|
Some((_, Loadable::Ready(card))) => {
|
||||||
let release_indices = fed_artist_visible_release_indices(state, card);
|
let release_indices = fed_artist_visible_release_indices(state, card);
|
||||||
@@ -2492,6 +2543,20 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
|||||||
if state.active_tab != Tab::Global {
|
if state.active_tab != Tab::Global {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
if state.search.similarity_source.is_some()
|
||||||
|
&& let Some(GlobalView::Search { cursor }) = state.global.stack.last()
|
||||||
|
{
|
||||||
|
let scope = TrackSelectionScope::SimilaritySearch;
|
||||||
|
let len = state.search.similarity_len();
|
||||||
|
let indices = state
|
||||||
|
.track_selection
|
||||||
|
.indices(&scope, len)
|
||||||
|
.unwrap_or_else(|| vec![(*cursor).min(len.saturating_sub(1))]);
|
||||||
|
return indices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|index| state.search.similarity_fed_track(index).cloned())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
// An active Shift-V range in a federated scope.
|
// An active Shift-V range in a federated scope.
|
||||||
if let Some(scope) = state.track_selection.scope.clone() {
|
if let Some(scope) = state.track_selection.scope.clone() {
|
||||||
match scope {
|
match scope {
|
||||||
@@ -2796,6 +2861,25 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
});
|
});
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::MinimumScore) => {
|
||||||
|
state.popup = Some(Popup::FedInput {
|
||||||
|
field: FedInputField::SimilarityMinimumScore,
|
||||||
|
input: crate::app::input::LineEdit::new(format!(
|
||||||
|
"{:.2}",
|
||||||
|
state.similarity.settings.minimum_score
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::MaxTracksPerArtist) => {
|
||||||
|
state.popup = Some(Popup::FedInput {
|
||||||
|
field: FedInputField::SimilarityMaxTracksPerArtist,
|
||||||
|
input: crate::app::input::LineEdit::new(
|
||||||
|
state.similarity.settings.max_tracks_per_artist.to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
SettingsRow::Similarity(SimilarityRow::Workers) => {
|
SettingsRow::Similarity(SimilarityRow::Workers) => {
|
||||||
state.popup = Some(Popup::FedInput {
|
state.popup = Some(Popup::FedInput {
|
||||||
field: FedInputField::SimilarityWorkers,
|
field: FedInputField::SimilarityWorkers,
|
||||||
|
|||||||
+42
-44
@@ -2,50 +2,9 @@ use anyhow::{Context as _, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
pub use furumi_library::{LibraryFilters, LibrarySourceMode};
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum LibrarySourceMode {
|
|
||||||
Local,
|
|
||||||
My,
|
|
||||||
#[default]
|
|
||||||
Global,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LibrarySourceMode {
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SimilaritySettings {
|
pub struct SimilaritySettings {
|
||||||
/// Local embedding/search master switch. Network participation follows
|
/// Local embedding/search master switch. Network participation follows
|
||||||
/// federation and additionally requires the explicit privacy consent.
|
/// federation and additionally requires the explicit privacy consent.
|
||||||
@@ -57,6 +16,14 @@ pub struct SimilaritySettings {
|
|||||||
pub profile: String,
|
pub profile: String,
|
||||||
#[serde(default = "default_similarity_workers")]
|
#[serde(default = "default_similarity_workers")]
|
||||||
pub workers: usize,
|
pub workers: usize,
|
||||||
|
/// Requester-side cosine score floor. This is search policy, not part of
|
||||||
|
/// the embedding profile, so changing it never invalidates vectors.
|
||||||
|
#[serde(default = "default_similarity_minimum_score")]
|
||||||
|
pub minimum_score: f32,
|
||||||
|
/// Requester-side diversity cap applied independently to local and
|
||||||
|
/// federated candidates.
|
||||||
|
#[serde(default = "default_similarity_max_tracks_per_artist")]
|
||||||
|
pub max_tracks_per_artist: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub federation_consent: bool,
|
pub federation_consent: bool,
|
||||||
/// Exact fingerprint of the last fully usable profile. Keeping this
|
/// Exact fingerprint of the last fully usable profile. Keeping this
|
||||||
@@ -73,13 +40,15 @@ impl Default for SimilaritySettings {
|
|||||||
model: default_similarity_model(),
|
model: default_similarity_model(),
|
||||||
profile: default_similarity_profile(),
|
profile: default_similarity_profile(),
|
||||||
workers: default_similarity_workers(),
|
workers: default_similarity_workers(),
|
||||||
|
minimum_score: default_similarity_minimum_score(),
|
||||||
|
max_tracks_per_artist: default_similarity_max_tracks_per_artist(),
|
||||||
federation_consent: false,
|
federation_consent: false,
|
||||||
active_profile: None,
|
active_profile: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
#[serde(default = "default_volume")]
|
#[serde(default = "default_volume")]
|
||||||
pub volume: u8,
|
pub volume: u8,
|
||||||
@@ -116,6 +85,11 @@ impl AppSettings {
|
|||||||
self.similarity.profile = default_similarity_profile();
|
self.similarity.profile = default_similarity_profile();
|
||||||
}
|
}
|
||||||
self.similarity.workers = self.similarity.workers.clamp(1, 16);
|
self.similarity.workers = self.similarity.workers.clamp(1, 16);
|
||||||
|
if !self.similarity.minimum_score.is_finite() {
|
||||||
|
self.similarity.minimum_score = default_similarity_minimum_score();
|
||||||
|
}
|
||||||
|
self.similarity.minimum_score = self.similarity.minimum_score.clamp(0.0, 1.0);
|
||||||
|
self.similarity.max_tracks_per_artist = self.similarity.max_tracks_per_artist.clamp(1, 50);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +112,14 @@ fn default_similarity_workers() -> usize {
|
|||||||
.unwrap_or(1)
|
.unwrap_or(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_similarity_minimum_score() -> f32 {
|
||||||
|
0.70
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_similarity_max_tracks_per_artist() -> usize {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
/// The historical permanent-download location, kept as the default for
|
/// The historical permanent-download location, kept as the default for
|
||||||
/// backward compatibility with existing installations.
|
/// backward compatibility with existing installations.
|
||||||
pub fn default_music_dir() -> PathBuf {
|
pub fn default_music_dir() -> PathBuf {
|
||||||
@@ -204,5 +186,21 @@ hide_featured_only = true
|
|||||||
assert!(settings.library.hide_featured_only);
|
assert!(settings.library.hide_featured_only);
|
||||||
assert_eq!(settings.library.source_mode, LibrarySourceMode::Global);
|
assert_eq!(settings.library.source_mode, LibrarySourceMode::Global);
|
||||||
assert_eq!(settings.music_dir, default_music_dir());
|
assert_eq!(settings.music_dir, default_music_dir());
|
||||||
|
assert_eq!(settings.similarity.minimum_score, 0.70);
|
||||||
|
assert_eq!(settings.similarity.max_tracks_per_artist, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similarity_search_policy_is_normalized_without_changing_the_profile() {
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
let profile = settings.similarity.profile.clone();
|
||||||
|
settings.similarity.minimum_score = f32::NAN;
|
||||||
|
settings.similarity.max_tracks_per_artist = 0;
|
||||||
|
|
||||||
|
let settings = settings.normalized();
|
||||||
|
|
||||||
|
assert_eq!(settings.similarity.minimum_score, 0.70);
|
||||||
|
assert_eq!(settings.similarity.max_tracks_per_artist, 1);
|
||||||
|
assert_eq!(settings.similarity.profile, profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ mod tests {
|
|||||||
"catalog",
|
"catalog",
|
||||||
"audio",
|
"audio",
|
||||||
"similarity",
|
"similarity",
|
||||||
|
"similarity_dht",
|
||||||
"device_sync",
|
"device_sync",
|
||||||
"jam",
|
"jam",
|
||||||
] {
|
] {
|
||||||
|
|||||||
+150
-64
@@ -25,6 +25,8 @@ use std::sync::atomic::{AtomicI64, Ordering};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::similarity_dht::SimilarityDht;
|
||||||
|
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
|
||||||
use music_dht::{
|
use music_dht::{
|
||||||
ByteStream, ByteStreamConnectionStats, EndpointId, ItemKind, ItemSpec, LibraryItem,
|
ByteStream, ByteStreamConnectionStats, EndpointId, ItemKind, ItemSpec, LibraryItem,
|
||||||
MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats, RendezvousConfig,
|
MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats, RendezvousConfig,
|
||||||
@@ -336,44 +338,29 @@ pub struct FedSearchResults {
|
|||||||
pub tracks: Vec<FedTrack>,
|
pub tracks: Vec<FedTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScoredFedTrack {
|
||||||
|
pub track: FedTrack,
|
||||||
|
pub score: f32,
|
||||||
|
pub embedding_signature: Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct SimilaritySearchStats {
|
||||||
|
pub tracks: usize,
|
||||||
|
pub artists: usize,
|
||||||
|
pub peers_queried: usize,
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FedSimilaritySearchResults {
|
||||||
|
pub tracks: Vec<ScoredFedTrack>,
|
||||||
|
pub stats: SimilaritySearchStats,
|
||||||
|
}
|
||||||
|
|
||||||
/// A track found through federated search.
|
/// A track found through federated search.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
pub use furumi_library::FederatedTrack as FedTrack;
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live status snapshot rendered on the Federation tab.
|
/// Live status snapshot rendered on the Federation tab.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -413,6 +400,7 @@ pub struct NetworkLibrarySource {
|
|||||||
|
|
||||||
struct Running {
|
struct Running {
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
|
similarity_dht: Arc<SimilarityDht>,
|
||||||
network_name: String,
|
network_name: String,
|
||||||
network_id: NetworkId,
|
network_id: NetworkId,
|
||||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
@@ -697,12 +685,15 @@ impl Federation {
|
|||||||
.stream_protocol(AUDIO_ALPN)
|
.stream_protocol(AUDIO_ALPN)
|
||||||
// ...and browse each other's per-artist catalogs over this one.
|
// ...and browse each other's per-artist catalogs over this one.
|
||||||
.stream_protocol(CATALOG_ALPN)
|
.stream_protocol(CATALOG_ALPN)
|
||||||
// Anonymous, bounded direct embedding queries.
|
// Anonymous, bounded direct embedding queries have their own
|
||||||
.stream_protocol(SIMILARITY_ALPN)
|
// versioned contract and survive catalog-schema upgrades.
|
||||||
|
.schema_independent_stream_protocol(SIMILARITY_ALPN)
|
||||||
// Personal-device sync (likes, playlists, trusted devices).
|
// Personal-device sync (likes, playlists, trusted devices).
|
||||||
.stream_protocol(crate::devices::SYNC_ALPN)
|
.stream_protocol(crate::devices::SYNC_ALPN)
|
||||||
// Capability-scoped shared playback control.
|
// Capability-scoped shared playback control.
|
||||||
.stream_protocol(crate::jam::JAM_ALPN)
|
.stream_protocol(crate::jam::JAM_ALPN)
|
||||||
|
// Signed LSH summaries form their own upgrade-safe DHT overlay.
|
||||||
|
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
|
||||||
// Informational application/protocol versions.
|
// Informational application/protocol versions.
|
||||||
.schema_independent_stream_protocol(capabilities::CAPABILITIES_ALPN)
|
.schema_independent_stream_protocol(capabilities::CAPABILITIES_ALPN)
|
||||||
.build()
|
.build()
|
||||||
@@ -717,6 +708,25 @@ impl Federation {
|
|||||||
"federation started"
|
"federation started"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let similarity_dht = SimilarityDht::open(
|
||||||
|
Arc::clone(&service),
|
||||||
|
self.data_dir.join("similarity-routing.sqlite3"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to start the similarity DHT: {err}"))?;
|
||||||
|
let similarity_dht_acceptor = service
|
||||||
|
.stream_acceptor(SIMILARITY_DHT_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take similarity DHT acceptor: {err}"))?;
|
||||||
|
let similarity_dht_serve_task =
|
||||||
|
tokio::spawn(Arc::clone(&similarity_dht).serve(similarity_dht_acceptor));
|
||||||
|
let similarity_dht_maintenance_task =
|
||||||
|
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
|
||||||
|
let similarity_dht_sync_task = tokio::spawn(similarity_route_sync_loop(
|
||||||
|
Arc::clone(&similarity_dht),
|
||||||
|
Arc::clone(&self.similarity),
|
||||||
|
Arc::clone(&self.library),
|
||||||
|
));
|
||||||
|
|
||||||
// Drain DHT events into the log; the channel is bounded.
|
// Drain DHT events into the log; the channel is bounded.
|
||||||
let event_task = tokio::spawn(async move {
|
let event_task = tokio::spawn(async move {
|
||||||
while let Some(event) = events.recv().await {
|
while let Some(event) = events.recv().await {
|
||||||
@@ -797,6 +807,7 @@ impl Federation {
|
|||||||
|
|
||||||
*guard = Some(Running {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
|
similarity_dht,
|
||||||
network_name,
|
network_name,
|
||||||
network_id,
|
network_id,
|
||||||
tasks: vec![
|
tasks: vec![
|
||||||
@@ -811,6 +822,9 @@ impl Federation {
|
|||||||
jam_poll_task,
|
jam_poll_task,
|
||||||
capabilities_serve_task,
|
capabilities_serve_task,
|
||||||
capabilities_probe_task,
|
capabilities_probe_task,
|
||||||
|
similarity_dht_serve_task,
|
||||||
|
similarity_dht_maintenance_task,
|
||||||
|
similarity_dht_sync_task,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
self.set_error(None);
|
self.set_error(None);
|
||||||
@@ -835,6 +849,20 @@ impl Federation {
|
|||||||
.context("federation is not running")
|
.context("federation is not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn similarity_services(&self) -> Result<(Arc<MusicDhtService>, Arc<SimilarityDht>)> {
|
||||||
|
self.running
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|running| {
|
||||||
|
(
|
||||||
|
Arc::clone(&running.service),
|
||||||
|
Arc::clone(&running.similarity_dht),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.context("federation is not running")
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_connected_devices_enabled(&self) -> Result<()> {
|
fn ensure_connected_devices_enabled(&self) -> Result<()> {
|
||||||
let settings = self.settings();
|
let settings = self.settings();
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@@ -1170,13 +1198,22 @@ impl Federation {
|
|||||||
&self,
|
&self,
|
||||||
query: crate::similarity::QueryVector,
|
query: crate::similarity::QueryVector,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<FedSearchResults> {
|
) -> Result<FedSimilaritySearchResults> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
self.similarity.network_allowed(),
|
self.similarity.network_allowed(),
|
||||||
"similarity federation has no consent"
|
"similarity federation has no consent"
|
||||||
);
|
);
|
||||||
let service = self.service().await?;
|
let (service, similarity_dht) = self.similarity_services().await?;
|
||||||
similarity::search(service, query, limit, Arc::clone(&self.transport_stats)).await
|
let settings = self.similarity.settings();
|
||||||
|
similarity::search(
|
||||||
|
service,
|
||||||
|
similarity_dht,
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
settings.minimum_score,
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a share-link content id to one playable federated track.
|
/// Resolves a share-link content id to one playable federated track.
|
||||||
@@ -2527,28 +2564,6 @@ fn push_artist_once(names: &mut Vec<String>, name: &str) {
|
|||||||
names.push(name.to_string());
|
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]) {
|
fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) {
|
||||||
appearances.sort_by(|a, b| {
|
appearances.sort_by(|a, b| {
|
||||||
b.year
|
b.year
|
||||||
@@ -2565,6 +2580,77 @@ fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn similarity_route_sync_loop(
|
||||||
|
routing: Arc<SimilarityDht>,
|
||||||
|
similarity: Arc<crate::similarity::Manager>,
|
||||||
|
library: Arc<Library>,
|
||||||
|
) {
|
||||||
|
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
let mut published_marker: Option<(String, blake3::Hash)> = None;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if !similarity.network_allowed() {
|
||||||
|
if published_marker.take().is_some() {
|
||||||
|
routing.clear_local_signatures();
|
||||||
|
tracing::info!("local similarity DHT publication disabled");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let status = similarity.status();
|
||||||
|
let Some(profile_id) = status.active_profile else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if status.phase != crate::similarity::Phase::Ready {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let library = Arc::clone(&library);
|
||||||
|
let profile_for_task = profile_id.clone();
|
||||||
|
let loaded = tokio::task::spawn_blocking(move || {
|
||||||
|
let signatures = library.similarity_routing_signatures(&profile_for_task)?;
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
for signature in &signatures {
|
||||||
|
hasher.update(signature);
|
||||||
|
}
|
||||||
|
Ok::<_, anyhow::Error>((signatures, hasher.finalize()))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (signatures, fingerprint) = match loaded {
|
||||||
|
Ok(Ok(loaded)) => loaded,
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
tracing::warn!(%error, %profile_id, "similarity routing signatures unavailable");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "similarity routing signature task failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let marker = (profile_id.clone(), fingerprint);
|
||||||
|
if published_marker.as_ref() == Some(&marker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match routing
|
||||||
|
.sync_local_signatures(profile_id.clone(), signatures)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stats) => {
|
||||||
|
tracing::info!(
|
||||||
|
profile = %profile_id,
|
||||||
|
records = stats.records,
|
||||||
|
keys = stats.keys,
|
||||||
|
remote_nodes = stats.remote_nodes,
|
||||||
|
"local similarity DHT index synchronized"
|
||||||
|
);
|
||||||
|
published_marker = Some(marker);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, %profile_id, "similarity DHT synchronization failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn stop_running(running: Option<Running>) {
|
async fn stop_running(running: Option<Running>) {
|
||||||
let Some(running) = running else { return };
|
let Some(running) = running else { return };
|
||||||
for task in &running.tasks {
|
for task in &running.tasks {
|
||||||
|
|||||||
+146
-56
@@ -4,24 +4,30 @@
|
|||||||
//! module owns application policy: consent, peer fan-out, local index access,
|
//! module owns application policy: consent, peer fan-out, local index access,
|
||||||
//! result conversion, deduplication, and ranking limits.
|
//! result conversion, deduplication, and ranking limits.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use futures_util::stream::{self, StreamExt as _};
|
use futures_util::stream::{self, StreamExt as _};
|
||||||
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
use music_dht::similarity_dht::SimilarityDht;
|
||||||
|
use music_dht::{
|
||||||
|
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::federation::{FedSearchResults, FedTrack, TransportStats};
|
use crate::federation::{
|
||||||
|
FedSimilaritySearchResults, FedTrack, ScoredFedTrack, SimilaritySearchStats, TransportStats,
|
||||||
|
};
|
||||||
use crate::similarity::{Manager, QueryVector};
|
use crate::similarity::{Manager, QueryVector};
|
||||||
|
|
||||||
pub use music_dht::similarity::SIMILARITY_ALPN;
|
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||||
|
|
||||||
const MAX_QUERY_PEERS: usize = 16;
|
const INITIAL_QUERY_PEERS: usize = 16;
|
||||||
const QUERY_CONCURRENCY: usize = 6;
|
const MAX_QUERY_PEERS: usize = 48;
|
||||||
|
const QUERY_CONCURRENCY: usize = 8;
|
||||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const MAX_PER_ARTIST: usize = 3;
|
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||||
|
|
||||||
pub async fn serve_peers(
|
pub async fn serve_peers(
|
||||||
@@ -57,7 +63,7 @@ async fn serve_one(
|
|||||||
let vector = request.vector;
|
let vector = request.vector;
|
||||||
let limit = request.limit;
|
let limit = request.limit;
|
||||||
let matches = tokio::task::spawn_blocking(move || {
|
let matches = tokio::task::spawn_blocking(move || {
|
||||||
similarity.search_vector(&profile, &vector, None, None, limit)
|
similarity.search_vector_for_peer(&profile, &vector, limit)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.context("local similarity task failed")
|
.context("local similarity task failed")
|
||||||
@@ -122,20 +128,51 @@ async fn serve_one(
|
|||||||
|
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
|
routing: Arc<SimilarityDht>,
|
||||||
query: QueryVector,
|
query: QueryVector,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
minimum_score: f32,
|
||||||
transport: Arc<TransportStats>,
|
transport: Arc<TransportStats>,
|
||||||
) -> Result<FedSearchResults> {
|
) -> Result<FedSimilaritySearchResults> {
|
||||||
|
let started = Instant::now();
|
||||||
let own = service.endpoint_id();
|
let own = service.endpoint_id();
|
||||||
let mut peers = Vec::new();
|
let routed = match tokio::time::timeout(
|
||||||
|
ROUTING_TIMEOUT,
|
||||||
|
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(peers)) => peers,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!("similarity DHT lookup timed out; using known peers");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
tracing::debug!(%error, "similarity DHT lookup unavailable; using known peers");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
|
let mut peers: Vec<QueryPeer> = routed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|ticket| {
|
||||||
|
let owner = ticket.endpoint_id();
|
||||||
|
(owner != own && seen.insert(owner)).then_some(QueryPeer {
|
||||||
|
owner,
|
||||||
|
ticket: Some(ticket),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
for peer in service
|
for peer in service
|
||||||
.connected_peers()
|
.connected_peers()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||||
{
|
{
|
||||||
if peer != own && seen.insert(peer) {
|
if peer != own && seen.insert(peer) {
|
||||||
peers.push(peer);
|
peers.push(QueryPeer {
|
||||||
|
owner: peer,
|
||||||
|
ticket: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if peers.len() >= MAX_QUERY_PEERS {
|
if peers.len() >= MAX_QUERY_PEERS {
|
||||||
break;
|
break;
|
||||||
@@ -148,36 +185,50 @@ pub async fn search(
|
|||||||
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||||
)?);
|
)?);
|
||||||
|
|
||||||
let responses = stream::iter(peers.into_iter().map(|peer| {
|
|
||||||
let service = Arc::clone(&service);
|
|
||||||
let request = Arc::clone(&request);
|
|
||||||
let transport = Arc::clone(&transport);
|
|
||||||
async move {
|
|
||||||
tokio::time::timeout(
|
|
||||||
QUERY_TIMEOUT,
|
|
||||||
query_peer(service, peer, &request, transport),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.buffer_unordered(QUERY_CONCURRENCY)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
|
let initial = peers.len().min(INITIAL_QUERY_PEERS);
|
||||||
|
let responses = query_peers(
|
||||||
|
Arc::clone(&service),
|
||||||
|
&peers[..initial],
|
||||||
|
Arc::clone(&request),
|
||||||
|
Arc::clone(&transport),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut successful = 0usize;
|
||||||
|
let mut peers_queried = initial;
|
||||||
for response in responses {
|
for response in responses {
|
||||||
match response {
|
match response {
|
||||||
Ok(peer_hits) => hits.extend(peer_hits),
|
Ok(peer_hits) => {
|
||||||
|
successful += 1;
|
||||||
|
hits.extend(peer_hits);
|
||||||
|
}
|
||||||
Err(err) => tracing::debug!(%err, "similarity peer query skipped"),
|
Err(err) => tracing::debug!(%err, "similarity peer query skipped"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
|
||||||
|
peers_queried = peers.len();
|
||||||
|
for response in query_peers(
|
||||||
|
Arc::clone(&service),
|
||||||
|
&peers[initial..],
|
||||||
|
Arc::clone(&request),
|
||||||
|
Arc::clone(&transport),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
match response {
|
||||||
|
Ok(peer_hits) => hits.extend(peer_hits),
|
||||||
|
Err(err) => tracing::debug!(%err, "fallback similarity peer query skipped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||||
let mut dedup = HashSet::new();
|
let mut dedup = HashSet::new();
|
||||||
let mut embedding_signatures = vec![query_signature];
|
let mut embedding_signatures = vec![query_signature];
|
||||||
let mut artist_counts: HashMap<String, usize> = HashMap::new();
|
|
||||||
let mut tracks = Vec::new();
|
let mut tracks = Vec::new();
|
||||||
for (track, _, embedding_signature) in hits {
|
for (track, score, embedding_signature) in hits {
|
||||||
|
if score < minimum_score {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if query
|
if query
|
||||||
.source_content_id
|
.source_content_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -200,46 +251,85 @@ pub async fn search(
|
|||||||
}) {
|
}) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let artist = track
|
|
||||||
.artist_names
|
|
||||||
.first()
|
|
||||||
.map(|name| music_dht::normalize_name(name))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let count = artist_counts.entry(artist.clone()).or_default();
|
|
||||||
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
*count += 1;
|
|
||||||
if let Some(signature) = embedding_signature {
|
if let Some(signature) = embedding_signature {
|
||||||
embedding_signatures.push(signature);
|
embedding_signatures.push(signature);
|
||||||
}
|
}
|
||||||
tracks.push(track);
|
tracks.push(ScoredFedTrack {
|
||||||
|
track,
|
||||||
|
score,
|
||||||
|
embedding_signature,
|
||||||
|
});
|
||||||
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(FedSearchResults {
|
let artists = tracks
|
||||||
artists: Vec::new(),
|
.iter()
|
||||||
|
.filter_map(|hit| hit.track.artist_names.first())
|
||||||
|
.map(|name| music_dht::normalize_name(name))
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.len();
|
||||||
|
let elapsed_ms = started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
|
||||||
|
Ok(FedSimilaritySearchResults {
|
||||||
|
stats: SimilaritySearchStats {
|
||||||
|
tracks: tracks.len(),
|
||||||
|
artists,
|
||||||
|
peers_queried,
|
||||||
|
elapsed_ms,
|
||||||
|
},
|
||||||
tracks,
|
tracks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PeerHits = Vec<(
|
||||||
|
FedTrack,
|
||||||
|
f32,
|
||||||
|
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||||
|
)>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct QueryPeer {
|
||||||
|
owner: EndpointId,
|
||||||
|
ticket: Option<PeerTicket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_peers(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
peers: &[QueryPeer],
|
||||||
|
request: Arc<SimilarityRequest>,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Vec<Result<PeerHits>> {
|
||||||
|
stream::iter(peers.iter().cloned().map(|peer| {
|
||||||
|
let service = Arc::clone(&service);
|
||||||
|
let request = Arc::clone(&request);
|
||||||
|
let transport = Arc::clone(&transport);
|
||||||
|
async move {
|
||||||
|
tokio::time::timeout(
|
||||||
|
QUERY_TIMEOUT,
|
||||||
|
query_peer(service, peer, &request, transport),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffer_unordered(QUERY_CONCURRENCY)
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn query_peer(
|
async fn query_peer(
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
owner: EndpointId,
|
peer: QueryPeer,
|
||||||
request: &SimilarityRequest,
|
request: &SimilarityRequest,
|
||||||
transport: Arc<TransportStats>,
|
transport: Arc<TransportStats>,
|
||||||
) -> Result<
|
) -> Result<PeerHits> {
|
||||||
Vec<(
|
let owner = peer.owner;
|
||||||
FedTrack,
|
let mut stream = match peer.ticket {
|
||||||
f32,
|
Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
|
||||||
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
None => service.open_stream(owner, SIMILARITY_ALPN).await,
|
||||||
)>,
|
}
|
||||||
> {
|
.map_err(|err| anyhow::anyhow!("cannot reach similarity peer: {err}"))?;
|
||||||
let mut stream = service
|
|
||||||
.open_stream(owner, SIMILARITY_ALPN)
|
|
||||||
.await
|
|
||||||
.map_err(|err| anyhow::anyhow!("cannot reach similarity peer: {err}"))?;
|
|
||||||
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
||||||
let response = wire::exchange(&mut stream, request).await?;
|
let response = wire::exchange(&mut stream, request).await?;
|
||||||
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||||
|
|||||||
@@ -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
-3480
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,763 +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();
|
|
||||||
lib.store_similarity_embedding(&track, "profile-a", &[0.1, 0.2, 0.3])
|
|
||||||
.unwrap();
|
|
||||||
lib.store_similarity_embedding(&track, "profile-b", &[0.3, 0.2, 0.1])
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
lib.similarity_embedding(track_id, "profile-a").unwrap(),
|
|
||||||
Some(vec![0.1, 0.2, 0.3])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
lib.similarity_embedding(track_id, "profile-b").unwrap(),
|
|
||||||
Some(vec![0.3, 0.2, 0.1])
|
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+43
-24
@@ -5,13 +5,14 @@
|
|||||||
//! the UI or app state.
|
//! the UI or app state.
|
||||||
|
|
||||||
mod analyzer;
|
mod analyzer;
|
||||||
|
mod opus;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
|
use rodio::{Decoder, DeviceSinkBuilder, Player, Source, stream::MixerDeviceSink};
|
||||||
|
|
||||||
pub use analyzer::AudioAnalysisSnapshot;
|
pub use analyzer::AudioAnalysisSnapshot;
|
||||||
|
|
||||||
@@ -226,23 +227,13 @@ fn handle(
|
|||||||
}
|
}
|
||||||
let out = output.as_ref().expect("output opened above");
|
let out = output.as_ref().expect("output opened above");
|
||||||
|
|
||||||
let mut builder = Decoder::builder()
|
match decode_source(reader, byte_len, mime_type.as_deref(), seekable) {
|
||||||
.with_data(reader)
|
Ok(source) => {
|
||||||
.with_seekable(seekable)
|
|
||||||
.with_gapless(true);
|
|
||||||
if let Some(len) = byte_len {
|
|
||||||
builder = builder.with_byte_len(len);
|
|
||||||
}
|
|
||||||
if let Some(mime_type) = mime_type.as_deref() {
|
|
||||||
builder = builder.with_mime_type(mime_type);
|
|
||||||
}
|
|
||||||
match builder.build() {
|
|
||||||
Ok(decoder) => {
|
|
||||||
shared.analysis.clear();
|
shared.analysis.clear();
|
||||||
out.player.stop();
|
out.player.stop();
|
||||||
out.player.set_volume(volume);
|
out.player.set_volume(volume);
|
||||||
out.player.append(analyzer::AnalyzedSource::new(
|
out.player.append(analyzer::AnalyzedSource::new(
|
||||||
decoder,
|
source,
|
||||||
Arc::clone(&shared.analysis),
|
Arc::clone(&shared.analysis),
|
||||||
));
|
));
|
||||||
out.player.play();
|
out.player.play();
|
||||||
@@ -258,16 +249,9 @@ fn handle(
|
|||||||
let Some(out) = output.as_ref() else {
|
let Some(out) = output.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut builder = Decoder::builder()
|
match decode_source(reader, byte_len, None, true) {
|
||||||
.with_data(reader)
|
Ok(source) => out.player.append(analyzer::AnalyzedSource::new(
|
||||||
.with_seekable(true)
|
source,
|
||||||
.with_gapless(true);
|
|
||||||
if let Some(len) = byte_len {
|
|
||||||
builder = builder.with_byte_len(len);
|
|
||||||
}
|
|
||||||
match builder.build() {
|
|
||||||
Ok(decoder) => out.player.append(analyzer::AnalyzedSource::new(
|
|
||||||
decoder,
|
|
||||||
Arc::clone(&shared.analysis),
|
Arc::clone(&shared.analysis),
|
||||||
)),
|
)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -309,3 +293,38 @@ fn handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) type DecodedSource = Box<dyn Source + Send>;
|
||||||
|
|
||||||
|
pub(crate) fn decode_source(
|
||||||
|
mut reader: TrackReader,
|
||||||
|
byte_len: Option<u64>,
|
||||||
|
mime_type: Option<&str>,
|
||||||
|
seekable: bool,
|
||||||
|
) -> Result<DecodedSource, String> {
|
||||||
|
let mime_is_opus = mime_type.is_some_and(|mime| {
|
||||||
|
let mime = mime.to_ascii_lowercase();
|
||||||
|
mime == "audio/opus" || mime.contains("codecs=opus") || mime.contains("codecs=\"opus\"")
|
||||||
|
});
|
||||||
|
let ogg_is_opus = opus::is_ogg_opus(&mut reader)
|
||||||
|
.map_err(|error| format!("cannot inspect audio stream: {error}"))?;
|
||||||
|
if mime_is_opus || ogg_is_opus {
|
||||||
|
return opus::OggOpusSource::new(reader, byte_len, seekable)
|
||||||
|
.map(|source| Box::new(source) as DecodedSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = Decoder::builder()
|
||||||
|
.with_data(reader)
|
||||||
|
.with_seekable(seekable)
|
||||||
|
.with_gapless(true);
|
||||||
|
if let Some(len) = byte_len {
|
||||||
|
builder = builder.with_byte_len(len);
|
||||||
|
}
|
||||||
|
if let Some(mime_type) = mime_type {
|
||||||
|
builder = builder.with_mime_type(mime_type);
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.map(|decoder| Box::new(decoder) as DecodedSource)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
//! Ogg/Opus decoding for rodio.
|
||||||
|
//!
|
||||||
|
//! Rodio 0.22 can demux Ogg and decode Vorbis, but its Symphonia version does
|
||||||
|
//! not include an Opus decoder. This source keeps Symphonia's mature Ogg
|
||||||
|
//! demuxing and feeds the packets into the pure-Rust `rusty-opus` decoder.
|
||||||
|
|
||||||
|
use std::io::{self, Read, Seek, SeekFrom};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use rodio::source::SeekError;
|
||||||
|
use rodio::{ChannelCount, SampleRate, Source};
|
||||||
|
use rusty_opus::OpusDecoder;
|
||||||
|
use symphonia::core::codecs::{CODEC_TYPE_OPUS, CodecParameters};
|
||||||
|
use symphonia::core::errors::Error as SymphoniaError;
|
||||||
|
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
|
||||||
|
use symphonia::core::io::{MediaSource, MediaSourceStream};
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
use symphonia::core::probe::Hint;
|
||||||
|
|
||||||
|
use super::TrackReader;
|
||||||
|
|
||||||
|
const OPUS_SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const OPUS_SEEK_PREROLL_FRAMES: u64 = OPUS_SAMPLE_RATE as u64 * 80 / 1_000;
|
||||||
|
const SNIFF_BYTES: usize = 512;
|
||||||
|
|
||||||
|
/// Inspect the Ogg identification page without changing the reader position.
|
||||||
|
pub(super) fn is_ogg_opus(reader: &mut TrackReader) -> io::Result<bool> {
|
||||||
|
let position = reader.stream_position()?;
|
||||||
|
let mut header = [0; SNIFF_BYTES];
|
||||||
|
let read_result = reader.read(&mut header);
|
||||||
|
let rewind_result = reader.seek(SeekFrom::Start(position));
|
||||||
|
|
||||||
|
let read = read_result?;
|
||||||
|
rewind_result?;
|
||||||
|
Ok(header[..read].starts_with(b"OggS")
|
||||||
|
&& header[..read]
|
||||||
|
.windows(b"OpusHead".len())
|
||||||
|
.any(|window| window == b"OpusHead"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReaderMediaSource {
|
||||||
|
reader: TrackReader,
|
||||||
|
byte_len: Option<u64>,
|
||||||
|
seekable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for ReaderMediaSource {
|
||||||
|
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||||
|
self.reader.read(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for ReaderMediaSource {
|
||||||
|
fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
|
||||||
|
self.reader.seek(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSource for ReaderMediaSource {
|
||||||
|
fn is_seekable(&self) -> bool {
|
||||||
|
self.seekable
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_len(&self) -> Option<u64> {
|
||||||
|
self.byte_len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct OggOpusSource {
|
||||||
|
format: Box<dyn FormatReader>,
|
||||||
|
decoder: OpusDecoder,
|
||||||
|
track_id: u32,
|
||||||
|
channels: u16,
|
||||||
|
pre_skip: u64,
|
||||||
|
playable_frames: Option<u64>,
|
||||||
|
output_position_frames: u64,
|
||||||
|
discard_frames: u64,
|
||||||
|
output_gain: f32,
|
||||||
|
buffer: Vec<f32>,
|
||||||
|
scratch: Vec<f32>,
|
||||||
|
buffer_position: usize,
|
||||||
|
seekable: bool,
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OggOpusSource {
|
||||||
|
pub(super) fn new(
|
||||||
|
reader: TrackReader,
|
||||||
|
byte_len: Option<u64>,
|
||||||
|
seekable: bool,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let source = ReaderMediaSource {
|
||||||
|
reader,
|
||||||
|
byte_len,
|
||||||
|
seekable,
|
||||||
|
};
|
||||||
|
let stream = MediaSourceStream::new(Box::new(source), Default::default());
|
||||||
|
let mut hint = Hint::new();
|
||||||
|
hint.with_extension("ogg");
|
||||||
|
hint.mime_type("audio/ogg");
|
||||||
|
let format_options = FormatOptions {
|
||||||
|
enable_gapless: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let probed = symphonia::default::get_probe()
|
||||||
|
.format(&hint, stream, &format_options, &MetadataOptions::default())
|
||||||
|
.map_err(|error| format!("cannot read Ogg container: {error}"))?;
|
||||||
|
let format = probed.format;
|
||||||
|
|
||||||
|
let (track_id, channels, pre_skip, playable_frames, output_gain) = {
|
||||||
|
let track = format
|
||||||
|
.default_track()
|
||||||
|
.ok_or_else(|| "Ogg container has no audio track".to_string())?;
|
||||||
|
if track.codec_params.codec != CODEC_TYPE_OPUS {
|
||||||
|
return Err("Ogg track is not Opus".to_string());
|
||||||
|
}
|
||||||
|
let channels = track
|
||||||
|
.codec_params
|
||||||
|
.channels
|
||||||
|
.map(|channels| channels.count() as u16)
|
||||||
|
.ok_or_else(|| "Opus track has no channel layout".to_string())?;
|
||||||
|
if !(1..=2).contains(&channels) {
|
||||||
|
return Err(format!(
|
||||||
|
"Opus track has {channels} channels; only mono and stereo are supported"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let extra_data = track.codec_params.extra_data.as_deref();
|
||||||
|
let pre_skip = opus_pre_skip(extra_data);
|
||||||
|
let playable_frames = opus_playable_frames(&track.codec_params, pre_skip);
|
||||||
|
let output_gain = opus_output_gain(extra_data);
|
||||||
|
(track.id, channels, pre_skip, playable_frames, output_gain)
|
||||||
|
};
|
||||||
|
let decoder = OpusDecoder::new(OPUS_SAMPLE_RATE as i32, usize::from(channels))
|
||||||
|
.map_err(|error| format!("cannot initialize Opus decoder: {error}"))?;
|
||||||
|
|
||||||
|
let mut source = Self {
|
||||||
|
format,
|
||||||
|
decoder,
|
||||||
|
track_id,
|
||||||
|
channels,
|
||||||
|
pre_skip,
|
||||||
|
playable_frames,
|
||||||
|
output_position_frames: 0,
|
||||||
|
discard_frames: pre_skip,
|
||||||
|
output_gain,
|
||||||
|
buffer: Vec::new(),
|
||||||
|
scratch: Vec::new(),
|
||||||
|
buffer_position: 0,
|
||||||
|
seekable,
|
||||||
|
done: false,
|
||||||
|
};
|
||||||
|
source.fill_buffer();
|
||||||
|
if source.buffer.is_empty() {
|
||||||
|
return Err("Opus track contains no decodable audio".to_string());
|
||||||
|
}
|
||||||
|
Ok(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_buffer(&mut self) {
|
||||||
|
self.buffer.clear();
|
||||||
|
self.buffer_position = 0;
|
||||||
|
|
||||||
|
while self.buffer.is_empty() && !self.done {
|
||||||
|
let packet = match self.format.next_packet() {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(SymphoniaError::IoError(error))
|
||||||
|
if error.kind() == io::ErrorKind::UnexpectedEof =>
|
||||||
|
{
|
||||||
|
self.done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "Ogg/Opus demux failed");
|
||||||
|
self.done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if packet.track_id() != self.track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(decoded_frames) = usize::try_from(packet.dur) else {
|
||||||
|
tracing::warn!("Ogg/Opus packet duration is too large");
|
||||||
|
self.done = true;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if decoded_frames == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sample_count = match decoded_frames.checked_mul(usize::from(self.channels)) {
|
||||||
|
Some(sample_count) => sample_count,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Ogg/Opus packet sample count overflow");
|
||||||
|
self.done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.scratch.resize(sample_count, 0.0);
|
||||||
|
let frames = match self
|
||||||
|
.decoder
|
||||||
|
.decode(&packet.data, decoded_frames, &mut self.scratch)
|
||||||
|
{
|
||||||
|
Ok(frames) => frames,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "Opus packet decode failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let discarded = self.discard_frames.min(frames as u64) as usize;
|
||||||
|
self.discard_frames -= discarded as u64;
|
||||||
|
let start_frame = discarded;
|
||||||
|
let remaining = self
|
||||||
|
.playable_frames
|
||||||
|
.map(|total| total.saturating_sub(self.output_position_frames))
|
||||||
|
.unwrap_or(u64::MAX);
|
||||||
|
let end_frame = frames
|
||||||
|
.min(start_frame.saturating_add(usize::try_from(remaining).unwrap_or(usize::MAX)));
|
||||||
|
if start_frame >= end_frame {
|
||||||
|
if self
|
||||||
|
.playable_frames
|
||||||
|
.is_some_and(|total| self.output_position_frames >= total)
|
||||||
|
{
|
||||||
|
self.done = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channels = usize::from(self.channels);
|
||||||
|
let start = start_frame * channels;
|
||||||
|
let end = end_frame.saturating_mul(channels).min(self.scratch.len());
|
||||||
|
self.output_position_frames = self
|
||||||
|
.output_position_frames
|
||||||
|
.saturating_add((end_frame - start_frame) as u64);
|
||||||
|
self.buffer.extend(
|
||||||
|
self.scratch[start..end]
|
||||||
|
.iter()
|
||||||
|
.map(|sample| (sample * self.output_gain).clamp(-1.0, 1.0)),
|
||||||
|
);
|
||||||
|
if self
|
||||||
|
.playable_frames
|
||||||
|
.is_some_and(|total| self.output_position_frames >= total)
|
||||||
|
{
|
||||||
|
self.done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_decoder(&mut self) -> Result<(), SeekError> {
|
||||||
|
self.decoder = OpusDecoder::new(OPUS_SAMPLE_RATE as i32, usize::from(self.channels))
|
||||||
|
.map_err(|error| seek_error(format!("cannot reset Opus decoder: {error}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for OggOpusSource {
|
||||||
|
type Item = f32;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
loop {
|
||||||
|
if self.buffer_position < self.buffer.len() {
|
||||||
|
let sample = self.buffer[self.buffer_position];
|
||||||
|
self.buffer_position += 1;
|
||||||
|
return Some(sample);
|
||||||
|
}
|
||||||
|
if self.done {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.fill_buffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||||
|
(self.buffer.len().saturating_sub(self.buffer_position), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source for OggOpusSource {
|
||||||
|
fn current_span_len(&self) -> Option<usize> {
|
||||||
|
Some(self.buffer.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channels(&self) -> ChannelCount {
|
||||||
|
ChannelCount::new(self.channels).expect("Opus channel count was validated")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_rate(&self) -> SampleRate {
|
||||||
|
SampleRate::new(OPUS_SAMPLE_RATE).expect("Opus sample rate is non-zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_duration(&self) -> Option<Duration> {
|
||||||
|
self.playable_frames
|
||||||
|
.map(|frames| Duration::from_secs_f64(frames as f64 / f64::from(OPUS_SAMPLE_RATE)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> {
|
||||||
|
if !self.seekable {
|
||||||
|
return Err(SeekError::NotSupported {
|
||||||
|
underlying_source: std::any::type_name::<Self>(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested = duration_frames(position);
|
||||||
|
let target = self
|
||||||
|
.playable_frames
|
||||||
|
.map_or(requested, |total| requested.min(total));
|
||||||
|
let raw_target = target.saturating_add(self.pre_skip);
|
||||||
|
let preroll = raw_target.saturating_sub(OPUS_SEEK_PREROLL_FRAMES);
|
||||||
|
let seeked = self
|
||||||
|
.format
|
||||||
|
.seek(
|
||||||
|
SeekMode::Accurate,
|
||||||
|
SeekTo::TimeStamp {
|
||||||
|
ts: preroll,
|
||||||
|
track_id: self.track_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| seek_error(format!("Ogg seek failed: {error}")))?;
|
||||||
|
self.reset_decoder()?;
|
||||||
|
self.buffer.clear();
|
||||||
|
self.buffer_position = 0;
|
||||||
|
self.output_position_frames = target;
|
||||||
|
self.discard_frames = raw_target.saturating_sub(seeked.actual_ts);
|
||||||
|
self.done = false;
|
||||||
|
self.fill_buffer();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_frames(duration: Duration) -> u64 {
|
||||||
|
let frames = duration.as_secs_f64() * f64::from(OPUS_SAMPLE_RATE);
|
||||||
|
frames.round().clamp(0.0, u64::MAX as f64) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opus_pre_skip(extra_data: Option<&[u8]>) -> u64 {
|
||||||
|
extra_data
|
||||||
|
.filter(|header| header.len() >= 12)
|
||||||
|
.map(|header| u64::from(u16::from_le_bytes([header[10], header[11]])))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opus_playable_frames(params: &CodecParameters, pre_skip: u64) -> Option<u64> {
|
||||||
|
let encoded_frames = params.n_frames?;
|
||||||
|
let padding = u64::from(params.padding.unwrap_or(0));
|
||||||
|
|
||||||
|
// Symphonia normally leaves the OpusHead pre-skip in `delay`. For a very
|
||||||
|
// short stream whose first audio page is also its last page, it instead
|
||||||
|
// reports the page's trailing padding there. Preserve that information so
|
||||||
|
// both one-page and ordinary Ogg/Opus streams end on the correct sample.
|
||||||
|
let one_page_padding = params
|
||||||
|
.delay
|
||||||
|
.map(u64::from)
|
||||||
|
.filter(|delay| *delay != pre_skip)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Some(
|
||||||
|
encoded_frames
|
||||||
|
.saturating_sub(pre_skip)
|
||||||
|
.saturating_sub(padding)
|
||||||
|
.saturating_sub(one_page_padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opus_output_gain(extra_data: Option<&[u8]>) -> f32 {
|
||||||
|
let Some(header) = extra_data.filter(|header| header.len() >= 18) else {
|
||||||
|
return 1.0;
|
||||||
|
};
|
||||||
|
let gain_q8 = i16::from_le_bytes([header[16], header[17]]);
|
||||||
|
10.0_f32.powf(f32::from(gain_q8) / (20.0 * 256.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seek_error(message: String) -> SeekError {
|
||||||
|
SeekError::Other(Arc::new(io::Error::other(message)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// 30 ms mono Ogg/Opus sine, encoded by FFmpeg/libopus. Keeping a real
|
||||||
|
// interoperable stream here catches container and codec regressions.
|
||||||
|
const OGG_OPUS: &str = "T2dnUwACAAAAAAAAAABkiBtVAAAAAFZb1toBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAAAAAAAAAAAGSIG1UBAAAAq+b2jQE+T3B1c1RhZ3MNAAAATGF2ZjYyLjEyLjEwMgEAAAAdAAAAZW5jb2Rlcj1MYXZjNjIuMjguMTAyIGxpYm9wdXNPZ2dTAATYBgAAAAAAAGSIG1UCAAAAEkNnrgT/NP8x+HJJRycQ5MhbeCeLhvfY79vodePsNuYR8hAn2iaItTjKzvL0UULTvai7BFd4FJ2BZdtZQn4K2vCS9rjadnLmp+u4QMCAJiL3MXbVJBVHjtDhATQq5rg5ZlscpBXWk/NqnQE/QaT/nhMi/VqnLKwRCFcpFnH/NqPx7RhjpvzicAu/blC/sRygALOf6blR6HYkOb7BLn/Vn3ijqdTVwCzwtcpWU2hbCXUOWnTEuXUOWaOmQKy8zmqYgzM7aOueo/2siHPpomLeiHPp4NH0cpD63qkSv4/XxX8HWdBtg9IvLpQ42ch0PrqI7BR4ZhvJoDmg45q/177KfOUxlfoG/8GpJWKTCpaN1u64it+1vkpE7sfaLIuSVNbz6BDOIREzM5pPkdIyBzqThkbRkGJr8KDGTyQyJfi0JXhIR5fGM2RptXd5mTRUZHmCz9dCMyKay/nzCir7oSDyEbWNlfaV7qzrxPXTNJRpJ/6GgfI3Ht2Y9jKPV8WkGzGO/Pyz+hznJTgJdGOpxRmzAWXmcwcSdI0TjPVLIs8SIBtipLhr0R+yDe2ar7ZQR3hxQzoIn5ydLO7mk8QZvr+4gAAAAAAAAAAAAAAAAAAAAAAAAE+e11+HmY5uncnz1m153WYm1urhAnMTa3qa3G/MIezSH+urKDn4eZAnEgz8PMnPgAOL22PlLS7647fYrYbJfnfYvLgwLpXSlHeh7VHf+GQd99vAzM/086lHsksCvwXkd12GFX2BTQbtW/3wPMmi4zL6VsKJUH/Q5ZQ00cvMtFDgSw5CLIg8nHONgH2/XVeZC+VwngKBeuzHHK4=";
|
||||||
|
|
||||||
|
fn fixture() -> Vec<u8> {
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(OGG_OPUS)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_and_decodes_ogg_opus() {
|
||||||
|
let bytes = fixture();
|
||||||
|
let len = bytes.len() as u64;
|
||||||
|
let mut reader: TrackReader = Box::new(Cursor::new(bytes));
|
||||||
|
assert!(is_ogg_opus(&mut reader).unwrap());
|
||||||
|
|
||||||
|
let source = OggOpusSource::new(reader, Some(len), true).unwrap();
|
||||||
|
assert_eq!(source.channels().get(), 1);
|
||||||
|
assert_eq!(source.sample_rate().get(), OPUS_SAMPLE_RATE);
|
||||||
|
let samples: Vec<_> = source.collect();
|
||||||
|
assert_eq!(samples.len(), 1_440);
|
||||||
|
assert!(samples.iter().any(|sample| sample.abs() > 0.001));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seeks_within_ogg_opus() {
|
||||||
|
let bytes = fixture();
|
||||||
|
let len = bytes.len() as u64;
|
||||||
|
let reader: TrackReader = Box::new(Cursor::new(bytes));
|
||||||
|
let mut source = OggOpusSource::new(reader, Some(len), true).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(source.by_ref().count(), 1_440);
|
||||||
|
source.try_seek(Duration::from_millis(15)).unwrap();
|
||||||
|
let samples: Vec<_> = source.collect();
|
||||||
|
assert_eq!(samples.len(), 720);
|
||||||
|
assert!(samples.iter().any(|sample| sample.abs() > 0.001));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ogg_vorbis_header_is_not_misclassified_as_opus() {
|
||||||
|
let mut bytes =
|
||||||
|
b"OggS\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x01\x1e\x01vorbis".to_vec();
|
||||||
|
bytes.resize(SNIFF_BYTES, 0);
|
||||||
|
let mut reader: TrackReader = Box::new(Cursor::new(bytes));
|
||||||
|
assert!(!is_ogg_opus(&mut reader).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
-41
@@ -7,13 +7,13 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use futures_util::StreamExt as _;
|
use futures_util::StreamExt as _;
|
||||||
use rodio::{Decoder, Source as _};
|
use rodio::Source as _;
|
||||||
use rustfft::FftPlanner;
|
use rustfft::FftPlanner;
|
||||||
use rustfft::num_complex::Complex;
|
use rustfft::num_complex::Complex;
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
@@ -36,7 +36,7 @@ const EMBEDDING_DIMENSIONS: usize = 1280;
|
|||||||
const MODEL_BATCH: usize = 8;
|
const MODEL_BATCH: usize = 8;
|
||||||
const MAX_MODEL_BYTES: usize = 64 * 1024 * 1024;
|
const MAX_MODEL_BYTES: usize = 64 * 1024 * 1024;
|
||||||
const RESULT_LIMIT: usize = 50;
|
const RESULT_LIMIT: usize = 50;
|
||||||
const MAX_PER_ARTIST: usize = 3;
|
const PEER_CANDIDATE_MAX_PER_ARTIST: usize = 10;
|
||||||
const NEAR_DUPLICATE_COSINE: f32 = 0.995;
|
const NEAR_DUPLICATE_COSINE: f32 = 0.995;
|
||||||
const FULL_TRACK_MAX_SECONDS: u32 = 5 * 60;
|
const FULL_TRACK_MAX_SECONDS: u32 = 5 * 60;
|
||||||
const LONG_TRACK_WINDOW_SECONDS: u32 = 60;
|
const LONG_TRACK_WINDOW_SECONDS: u32 = 60;
|
||||||
@@ -101,7 +101,7 @@ impl Phase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct SimilarityStatus {
|
pub struct SimilarityStatus {
|
||||||
pub phase: Phase,
|
pub phase: Phase,
|
||||||
pub active_profile: Option<String>,
|
pub active_profile: Option<String>,
|
||||||
@@ -144,6 +144,8 @@ pub struct Manager {
|
|||||||
settings: Mutex<SimilaritySettings>,
|
settings: Mutex<SimilaritySettings>,
|
||||||
workers: AtomicUsize,
|
workers: AtomicUsize,
|
||||||
generation: AtomicU64,
|
generation: AtomicU64,
|
||||||
|
pipeline_running: AtomicBool,
|
||||||
|
rescan_requested: AtomicBool,
|
||||||
status: Mutex<SimilarityStatus>,
|
status: Mutex<SimilarityStatus>,
|
||||||
index: RwLock<Index>,
|
index: RwLock<Index>,
|
||||||
model: Mutex<Option<(String, RunnableModel)>>,
|
model: Mutex<Option<(String, RunnableModel)>>,
|
||||||
@@ -169,13 +171,20 @@ impl Manager {
|
|||||||
Err(err) => tracing::warn!(%err, "similarity index restore failed"),
|
Err(err) => tracing::warn!(%err, "similarity index restore failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let target_profile = model_by_id(&settings.model)
|
||||||
|
.filter(|_| profile_by_id(&settings.profile).is_some())
|
||||||
|
.map(|model| profile_fingerprint(model, &settings.profile));
|
||||||
|
let restored_profile_is_current = index.profile_id == target_profile;
|
||||||
let status = SimilarityStatus {
|
let status = SimilarityStatus {
|
||||||
phase: if settings.enabled {
|
phase: if !settings.enabled {
|
||||||
Phase::Loading
|
|
||||||
} else {
|
|
||||||
Phase::Disabled
|
Phase::Disabled
|
||||||
|
} else if restored_profile_is_current {
|
||||||
|
Phase::Ready
|
||||||
|
} else {
|
||||||
|
Phase::Loading
|
||||||
},
|
},
|
||||||
active_profile: index.profile_id.clone(),
|
active_profile: index.profile_id.clone(),
|
||||||
|
target_profile,
|
||||||
model: settings.model.clone(),
|
model: settings.model.clone(),
|
||||||
..SimilarityStatus::default()
|
..SimilarityStatus::default()
|
||||||
};
|
};
|
||||||
@@ -184,6 +193,8 @@ impl Manager {
|
|||||||
event_tx,
|
event_tx,
|
||||||
workers: AtomicUsize::new(settings.workers.clamp(1, 16)),
|
workers: AtomicUsize::new(settings.workers.clamp(1, 16)),
|
||||||
generation: AtomicU64::new(0),
|
generation: AtomicU64::new(0),
|
||||||
|
pipeline_running: AtomicBool::new(false),
|
||||||
|
rescan_requested: AtomicBool::new(false),
|
||||||
settings: Mutex::new(settings),
|
settings: Mutex::new(settings),
|
||||||
status: Mutex::new(status),
|
status: Mutex::new(status),
|
||||||
index: RwLock::new(index),
|
index: RwLock::new(index),
|
||||||
@@ -223,23 +234,51 @@ impl Manager {
|
|||||||
|| previous.model != settings.model
|
|| previous.model != settings.model
|
||||||
|| previous.profile != settings.profile
|
|| previous.profile != settings.profile
|
||||||
{
|
{
|
||||||
|
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||||
self.start();
|
self.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requests a scan without cancelling useful work already in progress.
|
||||||
|
/// Bursts of library-change notifications collapse into one follow-up
|
||||||
|
/// pass, so metadata refreshes cannot repeatedly restart the model.
|
||||||
pub fn start(self: &Arc<Self>) {
|
pub fn start(self: &Arc<Self>) {
|
||||||
let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
|
self.rescan_requested.store(true, Ordering::Release);
|
||||||
|
if self.pipeline_running.swap(true, Ordering::AcqRel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let this = Arc::clone(self);
|
let this = Arc::clone(self);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(err) = this.run_pipeline(generation).await
|
loop {
|
||||||
&& this.generation.load(Ordering::Acquire) == generation
|
// This pass covers every notification received before it
|
||||||
{
|
// starts. A notification during the pass requests one more.
|
||||||
tracing::error!(%err, "similarity pipeline failed");
|
this.rescan_requested.store(false, Ordering::Release);
|
||||||
this.update_status(|status| {
|
let generation = this.generation.load(Ordering::Acquire);
|
||||||
status.phase = Phase::Error;
|
if let Err(err) = this.run_pipeline(generation).await
|
||||||
status.current_track = None;
|
&& this.generation.load(Ordering::Acquire) == generation
|
||||||
status.last_error = Some(format!("{err:#}"));
|
{
|
||||||
});
|
tracing::error!(%err, "similarity pipeline failed");
|
||||||
|
this.update_status(|status| {
|
||||||
|
status.phase = Phase::Error;
|
||||||
|
status.current_track = None;
|
||||||
|
status.last_error = Some(format!("{err:#}"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if this.rescan_requested.load(Ordering::Acquire) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pipeline_running.store(false, Ordering::Release);
|
||||||
|
// Close the small race between checking the request flag and
|
||||||
|
// releasing ownership of the worker. If another worker has
|
||||||
|
// already claimed it, that worker owns the pending pass.
|
||||||
|
if this.rescan_requested.swap(false, Ordering::AcqRel)
|
||||||
|
&& !this.pipeline_running.swap(true, Ordering::AcqRel)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -329,6 +368,52 @@ impl Manager {
|
|||||||
exclude_track_id: Option<i64>,
|
exclude_track_id: Option<i64>,
|
||||||
exclude_content_id: Option<&str>,
|
exclude_content_id: Option<&str>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
) -> Result<Vec<SimilarTrack>> {
|
||||||
|
let settings = lock(&self.settings);
|
||||||
|
let minimum_score = settings.minimum_score;
|
||||||
|
let max_tracks_per_artist = settings.max_tracks_per_artist;
|
||||||
|
drop(settings);
|
||||||
|
self.search_vector_with_policy(
|
||||||
|
profile_id,
|
||||||
|
vector,
|
||||||
|
exclude_track_id,
|
||||||
|
exclude_content_id,
|
||||||
|
limit,
|
||||||
|
minimum_score,
|
||||||
|
max_tracks_per_artist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a wider, policy-neutral candidate set to a remote requester.
|
||||||
|
/// The requester applies its own score threshold and artist diversity
|
||||||
|
/// limit; neither value is part of embedding compatibility.
|
||||||
|
pub(crate) fn search_vector_for_peer(
|
||||||
|
&self,
|
||||||
|
profile_id: &str,
|
||||||
|
vector: &[f32],
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<SimilarTrack>> {
|
||||||
|
self.search_vector_with_policy(
|
||||||
|
profile_id,
|
||||||
|
vector,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
limit,
|
||||||
|
-1.0,
|
||||||
|
PEER_CANDIDATE_MAX_PER_ARTIST,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn search_vector_with_policy(
|
||||||
|
&self,
|
||||||
|
profile_id: &str,
|
||||||
|
vector: &[f32],
|
||||||
|
exclude_track_id: Option<i64>,
|
||||||
|
exclude_content_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
minimum_score: f32,
|
||||||
|
max_tracks_per_artist: usize,
|
||||||
) -> Result<Vec<SimilarTrack>> {
|
) -> Result<Vec<SimilarTrack>> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
!vector.is_empty() && vector.len() <= 4096,
|
!vector.is_empty() && vector.len() <= 4096,
|
||||||
@@ -357,7 +442,7 @@ impl Manager {
|
|||||||
entry.vector.as_slice(),
|
entry.vector.as_slice(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.filter(|(_, score, _, _)| score.is_finite())
|
.filter(|(_, score, _, _)| score.is_finite() && *score >= minimum_score)
|
||||||
.collect();
|
.collect();
|
||||||
scores.sort_by(|left, right| right.1.total_cmp(&left.1));
|
scores.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||||
|
|
||||||
@@ -371,7 +456,7 @@ impl Manager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let count = artist_counts.entry(artist.to_string()).or_default();
|
let count = artist_counts.entry(artist.to_string()).or_default();
|
||||||
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
|
if !artist.is_empty() && *count >= max_tracks_per_artist {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
*count += 1;
|
*count += 1;
|
||||||
@@ -431,7 +516,6 @@ impl Manager {
|
|||||||
)?;
|
)?;
|
||||||
let stats = self.library.similarity_storage_stats(&profile_id)?;
|
let stats = self.library.similarity_storage_stats(&profile_id)?;
|
||||||
self.update_status(|status| {
|
self.update_status(|status| {
|
||||||
status.phase = Phase::Downloading;
|
|
||||||
status.target_profile = Some(profile_id.clone());
|
status.target_profile = Some(profile_id.clone());
|
||||||
status.model = spec.id.to_string();
|
status.model = spec.id.to_string();
|
||||||
status.total_tracks = stats.total_tracks;
|
status.total_tracks = stats.total_tracks;
|
||||||
@@ -442,21 +526,18 @@ impl Manager {
|
|||||||
status.current_track = None;
|
status.current_track = None;
|
||||||
status.last_error = None;
|
status.last_error = None;
|
||||||
});
|
});
|
||||||
let model_path = self.ensure_model(spec, generation).await?;
|
|
||||||
self.ensure_generation(generation)?;
|
|
||||||
self.update_status(|status| status.phase = Phase::Loading);
|
|
||||||
let model = self.load_model(&profile_id, &model_path).await?;
|
|
||||||
self.ensure_generation(generation)?;
|
|
||||||
|
|
||||||
let mut pending: VecDeque<_> = self.library.pending_similarity_tracks(&profile_id)?.into();
|
let mut pending: VecDeque<_> = self.library.pending_similarity_tracks(&profile_id)?.into();
|
||||||
let pending_total = pending.len();
|
if pending.is_empty() {
|
||||||
self.update_status(|status| {
|
self.ensure_generation(generation)?;
|
||||||
status.phase = if pending_total == 0 {
|
return self.activate_profile(profile_id);
|
||||||
Phase::Loading
|
}
|
||||||
} else {
|
|
||||||
Phase::Processing
|
let model_path = self.ensure_model(spec, generation).await?;
|
||||||
};
|
self.ensure_generation(generation)?;
|
||||||
});
|
let model = self.load_model(&profile_id, &model_path).await?;
|
||||||
|
self.ensure_generation(generation)?;
|
||||||
|
self.update_status(|status| status.phase = Phase::Processing);
|
||||||
|
|
||||||
let mut jobs = tokio::task::JoinSet::new();
|
let mut jobs = tokio::task::JoinSet::new();
|
||||||
while !pending.is_empty() || !jobs.is_empty() {
|
while !pending.is_empty() || !jobs.is_empty() {
|
||||||
@@ -506,6 +587,10 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.ensure_generation(generation)?;
|
self.ensure_generation(generation)?;
|
||||||
|
self.activate_profile(profile_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate_profile(&self, profile_id: String) -> Result<()> {
|
||||||
let entries = self.library.load_similarity_index(&profile_id)?;
|
let entries = self.library.load_similarity_index(&profile_id)?;
|
||||||
let total_tracks = self
|
let total_tracks = self
|
||||||
.library
|
.library
|
||||||
@@ -519,7 +604,12 @@ impl Manager {
|
|||||||
profile_id: Some(profile_id.clone()),
|
profile_id: Some(profile_id.clone()),
|
||||||
entries,
|
entries,
|
||||||
};
|
};
|
||||||
lock(&self.settings).active_profile = Some(profile_id.clone());
|
let profile_changed = {
|
||||||
|
let mut settings = lock(&self.settings);
|
||||||
|
let changed = settings.active_profile.as_deref() != Some(&profile_id);
|
||||||
|
settings.active_profile = Some(profile_id.clone());
|
||||||
|
changed
|
||||||
|
};
|
||||||
let stats = self.library.similarity_storage_stats(&profile_id)?;
|
let stats = self.library.similarity_storage_stats(&profile_id)?;
|
||||||
self.update_status(|status| {
|
self.update_status(|status| {
|
||||||
status.phase = Phase::Ready;
|
status.phase = Phase::Ready;
|
||||||
@@ -531,9 +621,11 @@ impl Manager {
|
|||||||
status.stored_bytes = stats.stored_bytes;
|
status.stored_bytes = stats.stored_bytes;
|
||||||
status.current_track = None;
|
status.current_track = None;
|
||||||
});
|
});
|
||||||
let _ = self
|
if profile_changed {
|
||||||
.event_tx
|
let _ = self
|
||||||
.send(AppEvent::SimilarityProfileActivated(Some(profile_id)));
|
.event_tx
|
||||||
|
.send(AppEvent::SimilarityProfileActivated(Some(profile_id)));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +653,7 @@ impl Manager {
|
|||||||
tokio::fs::remove_file(&path).await?;
|
tokio::fs::remove_file(&path).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.update_status(|status| status.phase = Phase::Downloading);
|
||||||
let response = reqwest::get(spec.url).await?.error_for_status()?;
|
let response = reqwest::get(spec.url).await?.error_for_status()?;
|
||||||
let tmp = path.with_extension(format!("part-{}-{generation}", std::process::id()));
|
let tmp = path.with_extension(format!("part-{}-{generation}", std::process::id()));
|
||||||
let mut file = tokio::fs::File::create(&tmp).await?;
|
let mut file = tokio::fs::File::create(&tmp).await?;
|
||||||
@@ -603,6 +696,7 @@ impl Manager {
|
|||||||
{
|
{
|
||||||
return Ok(Arc::clone(model));
|
return Ok(Arc::clone(model));
|
||||||
}
|
}
|
||||||
|
self.update_status(|status| status.phase = Phase::Loading);
|
||||||
let path = path.to_path_buf();
|
let path = path.to_path_buf();
|
||||||
let model = tokio::task::spawn_blocking(move || load_onnx(&path))
|
let model = tokio::task::spawn_blocking(move || load_onnx(&path))
|
||||||
.await
|
.await
|
||||||
@@ -614,10 +708,13 @@ impl Manager {
|
|||||||
fn update_status(&self, update: impl FnOnce(&mut SimilarityStatus)) {
|
fn update_status(&self, update: impl FnOnce(&mut SimilarityStatus)) {
|
||||||
let snapshot = {
|
let snapshot = {
|
||||||
let mut status = lock(&self.status);
|
let mut status = lock(&self.status);
|
||||||
|
let previous = status.clone();
|
||||||
update(&mut status);
|
update(&mut status);
|
||||||
status.clone()
|
(*status != previous).then(|| status.clone())
|
||||||
};
|
};
|
||||||
let _ = self.event_tx.send(AppEvent::SimilarityStatus(snapshot));
|
if let Some(snapshot) = snapshot {
|
||||||
|
let _ = self.event_tx.send(AppEvent::SimilarityStatus(snapshot));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,8 +847,15 @@ fn decode_mono_window(
|
|||||||
length_seconds: Option<f64>,
|
length_seconds: Option<f64>,
|
||||||
) -> Result<Vec<f32>> {
|
) -> Result<Vec<f32>> {
|
||||||
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
||||||
let mut decoder =
|
let byte_len = file.metadata().ok().map(|metadata| metadata.len());
|
||||||
Decoder::try_from(file).with_context(|| format!("decoding {}", path.display()))?;
|
let mut decoder = crate::player::decode_source(
|
||||||
|
Box::new(std::io::BufReader::new(file)),
|
||||||
|
byte_len,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)
|
||||||
|
.with_context(|| format!("decoding {}", path.display()))?;
|
||||||
let channels = decoder.channels().get() as usize;
|
let channels = decoder.channels().get() as usize;
|
||||||
let source_rate = decoder.sample_rate().get() as usize;
|
let source_rate = decoder.sample_rate().get() as usize;
|
||||||
if start_seconds > 0.0 {
|
if start_seconds > 0.0 {
|
||||||
@@ -977,6 +1081,14 @@ fn write<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn unique_test_dir(label: &str) -> 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]
|
#[test]
|
||||||
fn profile_fingerprint_changes_with_contract() {
|
fn profile_fingerprint_changes_with_contract() {
|
||||||
let model = &MODELS[0];
|
let model = &MODELS[0];
|
||||||
@@ -1010,6 +1122,43 @@ mod tests {
|
|||||||
assert!(!is_near_duplicate(&distinct, &[&query]));
|
assert!(!is_near_duplicate(&distinct, &[&query]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repeated_rescans_keep_an_up_to_date_profile_ready() {
|
||||||
|
let directory = unique_test_dir("similarity-stable-status");
|
||||||
|
let library = Arc::new(Library::open(&directory.join("library.db")).unwrap());
|
||||||
|
let profile_id = profile_fingerprint(&MODELS[0], DEFAULT_PROFILE_ID);
|
||||||
|
let settings = SimilaritySettings {
|
||||||
|
enabled: true,
|
||||||
|
active_profile: Some(profile_id),
|
||||||
|
..SimilaritySettings::default()
|
||||||
|
};
|
||||||
|
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let manager = Manager::new(Arc::clone(&library), event_tx, settings);
|
||||||
|
|
||||||
|
assert_eq!(manager.status().phase, Phase::Ready);
|
||||||
|
for _ in 0..32 {
|
||||||
|
manager.start();
|
||||||
|
}
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||||
|
while manager.pipeline_running.load(Ordering::Acquire) {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manager.status().phase, Phase::Ready);
|
||||||
|
while let Ok(event) = event_rx.try_recv() {
|
||||||
|
if let AppEvent::SimilarityStatus(status) = event {
|
||||||
|
assert_eq!(status.phase, Phase::Ready);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(manager);
|
||||||
|
drop(library);
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resampling_keeps_a_constant_signal() {
|
fn resampling_keeps_a_constant_signal() {
|
||||||
let output = resample_sinc(&vec![0.25; 441], 44_100, 16_000);
|
let output = resample_sinc(&vec![0.25; 441], 44_100, 16_000);
|
||||||
|
|||||||
+30
-5
@@ -101,11 +101,30 @@ impl Read for GrowingFileReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Seek for GrowingFileReader {
|
impl Seek for GrowingFileReader {
|
||||||
fn seek(&mut self, _: SeekFrom) -> io::Result<u64> {
|
fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
|
||||||
Err(io::Error::new(
|
// Random access beyond the downloaded prefix is intentionally not
|
||||||
io::ErrorKind::Unsupported,
|
// exposed, but decoders may inspect and rewind the available header.
|
||||||
"streaming playback is not seekable yet",
|
let available = self
|
||||||
))
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.available;
|
||||||
|
let target = match position {
|
||||||
|
SeekFrom::Start(position) => i128::from(position),
|
||||||
|
SeekFrom::Current(offset) => i128::from(self.pos) + i128::from(offset),
|
||||||
|
SeekFrom::End(offset) => i128::from(available) + i128::from(offset),
|
||||||
|
};
|
||||||
|
if target < 0 || target > i128::from(available) {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::Unsupported,
|
||||||
|
"cannot seek outside the downloaded audio prefix",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let target = target as u64;
|
||||||
|
self.file.seek(SeekFrom::Start(target))?;
|
||||||
|
self.pos = target;
|
||||||
|
Ok(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +148,12 @@ mod tests {
|
|||||||
reader.read_exact(&mut first).unwrap();
|
reader.read_exact(&mut first).unwrap();
|
||||||
assert_eq!(&first, b"fur");
|
assert_eq!(&first, b"fur");
|
||||||
|
|
||||||
|
reader.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
let mut rewind = [0u8; 3];
|
||||||
|
reader.read_exact(&mut rewind).unwrap();
|
||||||
|
assert_eq!(&rewind, b"fur");
|
||||||
|
assert!(reader.seek(SeekFrom::Start(4)).is_err());
|
||||||
|
|
||||||
output.write_all(b"umi").unwrap();
|
output.write_all(b"umi").unwrap();
|
||||||
writer.add_available(3);
|
writer.add_available(3);
|
||||||
writer.finish();
|
writer.finish();
|
||||||
|
|||||||
+126
-105
@@ -103,6 +103,14 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
"Preprocessing profile",
|
"Preprocessing profile",
|
||||||
format!("{} (enter for details)", similarity.profile),
|
format!("{} (enter for details)", similarity.profile),
|
||||||
),
|
),
|
||||||
|
SimilarityRow::MinimumScore => (
|
||||||
|
"Minimum similarity",
|
||||||
|
format!("{:.2}", similarity.minimum_score),
|
||||||
|
),
|
||||||
|
SimilarityRow::MaxTracksPerArtist => (
|
||||||
|
"Tracks per artist",
|
||||||
|
similarity.max_tracks_per_artist.to_string(),
|
||||||
|
),
|
||||||
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
|
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
|
||||||
SimilarityRow::Clear => ("Clear all stored embeddings", "↵".to_string()),
|
SimilarityRow::Clear => ("Clear all stored embeddings", "↵".to_string()),
|
||||||
};
|
};
|
||||||
@@ -400,6 +408,7 @@ fn protocol_label(id: &str) -> &str {
|
|||||||
"catalog" => "Catalog",
|
"catalog" => "Catalog",
|
||||||
"audio" => "Audio transfer",
|
"audio" => "Audio transfer",
|
||||||
"similarity" => "Similarity search",
|
"similarity" => "Similarity search",
|
||||||
|
"similarity_dht" => "Similarity DHT",
|
||||||
"device_sync" => "Device sync",
|
"device_sync" => "Device sync",
|
||||||
"jam" => "Jam",
|
"jam" => "Jam",
|
||||||
other => other,
|
other => other,
|
||||||
@@ -540,21 +549,20 @@ fn short_id(id: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_status_column(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_status_column(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
if area.height < 12 {
|
draw_status(frame, area, state);
|
||||||
draw_status(frame, area, state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let [similarity_area, _, federation_area] = Layout::vertical([
|
|
||||||
Constraint::Length(8),
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Min(0),
|
|
||||||
])
|
|
||||||
.areas(area);
|
|
||||||
draw_similarity_status(frame, similarity_area, state);
|
|
||||||
draw_status(frame, federation_area, state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
draw_summary_card(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
state,
|
||||||
|
" Similarity Processing ",
|
||||||
|
similarity_summary_lines(state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn similarity_summary_lines(state: &AppState) -> Vec<Line<'static>> {
|
||||||
let status = &state.similarity.status;
|
let status = &state.similarity.status;
|
||||||
let progress = if status.total_tracks == 0 {
|
let progress = if status.total_tracks == 0 {
|
||||||
"0 / 0".to_string()
|
"0 / 0".to_string()
|
||||||
@@ -571,34 +579,28 @@ fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(short_id)
|
.map(short_id)
|
||||||
.unwrap_or_else(|| "—".to_string());
|
.unwrap_or_else(|| "—".to_string());
|
||||||
draw_summary_card(
|
vec![
|
||||||
frame,
|
summary_line("State", status.phase.label().to_string()),
|
||||||
area,
|
summary_line("Progress", progress),
|
||||||
state,
|
summary_line("Active", active),
|
||||||
" Similarity Processing ",
|
summary_line("Processing", target),
|
||||||
vec![
|
summary_line(
|
||||||
status_line("State", status.phase.label().to_string()),
|
"Stored",
|
||||||
status_line("Progress", progress),
|
format!(
|
||||||
status_line("Active", active),
|
"{} vectors / {}",
|
||||||
status_line("Processing", target),
|
status.stored_vectors,
|
||||||
status_line(
|
short_bytes_label(status.stored_bytes)
|
||||||
"Stored",
|
|
||||||
format!(
|
|
||||||
"{} vectors / {}",
|
|
||||||
status.stored_vectors,
|
|
||||||
short_bytes_label(status.stored_bytes)
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
status_line(
|
),
|
||||||
"Current / errors",
|
summary_line(
|
||||||
status
|
"Current",
|
||||||
.current_track
|
status
|
||||||
.clone()
|
.current_track
|
||||||
.or_else(|| status.last_error.clone())
|
.clone()
|
||||||
.unwrap_or_else(|| format!("{} errors", status.failed_tracks)),
|
.or_else(|| status.last_error.clone())
|
||||||
),
|
.unwrap_or_else(|| format!("{} errors", status.failed_tracks)),
|
||||||
],
|
),
|
||||||
);
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
@@ -615,69 +617,81 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if area.width >= 60 && area.height >= 20 {
|
if area.width >= 60 {
|
||||||
let protocols_height =
|
let paired_width = area.width.saturating_sub(1) / 2;
|
||||||
protocol_card_height(state, area.width.saturating_sub(2), area.height);
|
let paired_height =
|
||||||
let [top_area, _, bottom_area, _, protocols_area, _] = Layout::vertical([
|
protocol_card_height(state, paired_width.saturating_sub(2), area.height).max(8);
|
||||||
Constraint::Length(7),
|
if area.height >= 16 + paired_height {
|
||||||
Constraint::Length(1),
|
let [top_area, _, middle_area, _, paired_area, _] = Layout::vertical([
|
||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(protocols_height),
|
Constraint::Length(7),
|
||||||
Constraint::Min(0),
|
Constraint::Length(1),
|
||||||
])
|
Constraint::Length(paired_height),
|
||||||
.areas(area);
|
Constraint::Min(0),
|
||||||
let [status_area, _, local_area] = Layout::horizontal([
|
])
|
||||||
Constraint::Percentage(50),
|
.areas(area);
|
||||||
Constraint::Length(1),
|
let [status_area, _, local_area] = Layout::horizontal([
|
||||||
Constraint::Percentage(50),
|
Constraint::Percentage(50),
|
||||||
])
|
Constraint::Length(1),
|
||||||
.areas(top_area);
|
Constraint::Percentage(50),
|
||||||
let [transport_area, _, devices_area] = Layout::horizontal([
|
])
|
||||||
Constraint::Percentage(50),
|
.areas(top_area);
|
||||||
Constraint::Length(1),
|
let [transport_area, _, devices_area] = Layout::horizontal([
|
||||||
Constraint::Percentage(50),
|
Constraint::Percentage(50),
|
||||||
])
|
Constraint::Length(1),
|
||||||
.areas(bottom_area);
|
Constraint::Percentage(50),
|
||||||
draw_summary_card(
|
])
|
||||||
frame,
|
.areas(middle_area);
|
||||||
status_area,
|
let [similarity_area, _, protocols_area] = Layout::horizontal([
|
||||||
state,
|
Constraint::Percentage(50),
|
||||||
" Status ",
|
Constraint::Length(1),
|
||||||
node_summary_lines(state),
|
Constraint::Percentage(50),
|
||||||
);
|
])
|
||||||
draw_summary_card(
|
.areas(paired_area);
|
||||||
frame,
|
draw_summary_card(
|
||||||
local_area,
|
frame,
|
||||||
state,
|
status_area,
|
||||||
" Local Data ",
|
state,
|
||||||
local_data_summary_lines(state),
|
" Status ",
|
||||||
);
|
node_summary_lines(state),
|
||||||
draw_summary_card(
|
);
|
||||||
frame,
|
draw_summary_card(
|
||||||
transport_area,
|
frame,
|
||||||
state,
|
local_area,
|
||||||
" Iroh Transport ",
|
state,
|
||||||
transport_summary_lines(state),
|
" Local Data ",
|
||||||
);
|
local_data_summary_lines(state),
|
||||||
draw_summary_card(
|
);
|
||||||
frame,
|
draw_summary_card(
|
||||||
devices_area,
|
frame,
|
||||||
state,
|
transport_area,
|
||||||
" Connected Devices ",
|
state,
|
||||||
device_summary_lines(state),
|
" Iroh Transport ",
|
||||||
);
|
transport_summary_lines(state),
|
||||||
draw_summary_card(
|
);
|
||||||
frame,
|
draw_summary_card(
|
||||||
protocols_area,
|
frame,
|
||||||
state,
|
devices_area,
|
||||||
" Protocol Versions ",
|
state,
|
||||||
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
|
" Connected Devices ",
|
||||||
);
|
device_summary_lines(state),
|
||||||
return;
|
);
|
||||||
|
draw_similarity_status(frame, similarity_area, state);
|
||||||
|
draw_summary_card(
|
||||||
|
frame,
|
||||||
|
protocols_area,
|
||||||
|
state,
|
||||||
|
" Protocol Versions ",
|
||||||
|
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if area.height < 39 {
|
let protocols_height = protocol_card_height(state, area.width.saturating_sub(2), area.height);
|
||||||
|
let required_vertical_height = 41u16.saturating_add(protocols_height);
|
||||||
|
if area.height < required_vertical_height {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(compact_status_lines(state))
|
Paragraph::new(compact_status_lines(state))
|
||||||
.wrap(ratatui::widgets::Wrap { trim: false }),
|
.wrap(ratatui::widgets::Wrap { trim: false }),
|
||||||
@@ -695,6 +709,8 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
_,
|
_,
|
||||||
local_area,
|
local_area,
|
||||||
_,
|
_,
|
||||||
|
similarity_area,
|
||||||
|
_,
|
||||||
protocols_area,
|
protocols_area,
|
||||||
_,
|
_,
|
||||||
] = Layout::vertical([
|
] = Layout::vertical([
|
||||||
@@ -706,11 +722,9 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(7),
|
Constraint::Length(7),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(protocol_card_height(
|
Constraint::Length(8),
|
||||||
state,
|
Constraint::Length(1),
|
||||||
area.width.saturating_sub(2),
|
Constraint::Length(protocols_height),
|
||||||
area.height,
|
|
||||||
)),
|
|
||||||
Constraint::Min(0),
|
Constraint::Min(0),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
@@ -743,6 +757,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
" Local Data ",
|
" Local Data ",
|
||||||
local_data_summary_lines(state),
|
local_data_summary_lines(state),
|
||||||
);
|
);
|
||||||
|
draw_similarity_status(frame, similarity_area, state);
|
||||||
draw_summary_card(
|
draw_summary_card(
|
||||||
frame,
|
frame,
|
||||||
protocols_area,
|
protocols_area,
|
||||||
@@ -766,6 +781,12 @@ fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
|
|||||||
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
|
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
|
||||||
lines.extend(device_summary_lines(state).into_iter().take(2));
|
lines.extend(device_summary_lines(state).into_iter().take(2));
|
||||||
lines.push(Line::default());
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::styled(
|
||||||
|
"Similarity Processing",
|
||||||
|
theme::header_for(state),
|
||||||
|
));
|
||||||
|
lines.extend(similarity_summary_lines(state).into_iter().take(3));
|
||||||
|
lines.push(Line::default());
|
||||||
lines.push(Line::styled("Protocol Versions", theme::header_for(state)));
|
lines.push(Line::styled("Protocol Versions", theme::header_for(state)));
|
||||||
lines.extend(protocol_summary_lines(state, 0).into_iter().take(3));
|
lines.extend(protocol_summary_lines(state, 0).into_iter().take(3));
|
||||||
lines
|
lines
|
||||||
|
|||||||
+154
-1
@@ -900,6 +900,9 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
|||||||
title.push_str("· searching… ");
|
title.push_str("· searching… ");
|
||||||
}
|
}
|
||||||
let inner = bordered(frame, area, state, title);
|
let inner = bordered(frame, area, state, title);
|
||||||
|
if search.similarity_source.is_some() {
|
||||||
|
return draw_similarity_search(frame, inner, state, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
let empty_results = SearchResults::default();
|
let empty_results = SearchResults::default();
|
||||||
let results = match &search.results {
|
let results = match &search.results {
|
||||||
@@ -924,7 +927,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
|||||||
return centered_line(frame, inner, line);
|
return centered_line(frame, inner, line);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if results.len() == 0
|
if results.is_empty()
|
||||||
&& state.search.fed_tracks.is_empty()
|
&& state.search.fed_tracks.is_empty()
|
||||||
&& state.search.fed_artists.is_empty()
|
&& state.search.fed_artists.is_empty()
|
||||||
&& !state.search.fed_loading
|
&& !state.search.fed_loading
|
||||||
@@ -1102,6 +1105,156 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_similarity_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||||
|
let search = &state.search;
|
||||||
|
let status = if search.fed_loading {
|
||||||
|
super::loading_line(state, "searching federation…")
|
||||||
|
} else if let Some(stats) = &search.similarity_stats {
|
||||||
|
let elapsed = if stats.elapsed_ms < 1_000 {
|
||||||
|
format!("{} ms", stats.elapsed_ms)
|
||||||
|
} else {
|
||||||
|
format!("{:.2} s", stats.elapsed_ms as f64 / 1_000.0)
|
||||||
|
};
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Federation · ", theme::accent_for(state)),
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"{} tracks · {} artists · {} peers queried · {elapsed}",
|
||||||
|
stats.tracks, stats.artists, stats.peers_queried
|
||||||
|
),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
} else if search.similarity_error.is_some() {
|
||||||
|
Line::styled(
|
||||||
|
"Federation search failed · showing local results",
|
||||||
|
error_style(),
|
||||||
|
)
|
||||||
|
} else if search.loading {
|
||||||
|
super::loading_line(state, "preparing local similarity search…")
|
||||||
|
} else {
|
||||||
|
Line::styled("Federation disabled · showing local results", theme::dim())
|
||||||
|
};
|
||||||
|
let [status_area, content] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
|
||||||
|
frame.render_widget(Paragraph::new(status), status_area);
|
||||||
|
|
||||||
|
let mut rows: Vec<(Line, Option<String>, Option<usize>)> = Vec::new();
|
||||||
|
rows.push((Line::styled("Tracks", theme::header_for(state)), None, None));
|
||||||
|
if let Some(track) = &search.similarity_source_track {
|
||||||
|
let heart = if state.track_liked(track) {
|
||||||
|
Span::styled("♥ ", theme::accent_for(state))
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
rows.push((
|
||||||
|
Line::from(vec![
|
||||||
|
heart,
|
||||||
|
Span::raw(track.title.clone()),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} · {}", track.artist_line(), track.release_title),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Some(super::track_meta_suffix(track, true)),
|
||||||
|
Some(0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (offset, hit) in search.similarity_tracks.iter().enumerate() {
|
||||||
|
let index = offset + 1;
|
||||||
|
match hit {
|
||||||
|
crate::app::state::SimilaritySearchHit::Local { track, .. } => {
|
||||||
|
let heart = if state.track_liked(track) {
|
||||||
|
Span::styled("♥ ", theme::accent_for(state))
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
rows.push((
|
||||||
|
Line::from(vec![
|
||||||
|
heart,
|
||||||
|
Span::raw(track.title.clone()),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} · {}", track.artist_line(), track.release_title),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Some(super::track_meta_suffix(track, true)),
|
||||||
|
Some(index),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
crate::app::state::SimilaritySearchHit::Federated { track, .. } => {
|
||||||
|
let heart = if state.fed_track_liked(track) {
|
||||||
|
Span::styled("♥ ", theme::accent_for(state))
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
let origin = if track.own {
|
||||||
|
"your library".to_string()
|
||||||
|
} else {
|
||||||
|
format!("peer {}…", track.owner_short())
|
||||||
|
};
|
||||||
|
let mut meta = track.duration_label();
|
||||||
|
if let Some(year) = track.year {
|
||||||
|
if !meta.is_empty() {
|
||||||
|
meta.push_str(" · ");
|
||||||
|
}
|
||||||
|
meta.push_str(&year.to_string());
|
||||||
|
}
|
||||||
|
rows.push((
|
||||||
|
Line::from(vec![
|
||||||
|
heart,
|
||||||
|
fed_track_availability_prefix(state, track),
|
||||||
|
Span::raw(track.title.clone()),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} · {origin}", track.artist_line()),
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Some(meta),
|
||||||
|
Some(index),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scope = crate::app::state::TrackSelectionScope::SimilaritySearch;
|
||||||
|
let mut selected = std::collections::HashSet::new();
|
||||||
|
if state.track_selection.is_active_for(&scope)
|
||||||
|
&& let Some(indices) = state
|
||||||
|
.track_selection
|
||||||
|
.indices(&scope, search.similarity_len())
|
||||||
|
{
|
||||||
|
selected.extend(indices);
|
||||||
|
}
|
||||||
|
let cursor_row = rows
|
||||||
|
.iter()
|
||||||
|
.position(|(_, _, row_cursor)| *row_cursor == Some(cursor))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let visible = usize::from(content.height.max(1));
|
||||||
|
let first = cursor_row
|
||||||
|
.saturating_sub(visible / 2)
|
||||||
|
.min(rows.len().saturating_sub(visible));
|
||||||
|
for (offset, (line, right, row_cursor)) in
|
||||||
|
rows.into_iter().enumerate().skip(first).take(visible)
|
||||||
|
{
|
||||||
|
let rect = Rect {
|
||||||
|
x: content.x,
|
||||||
|
y: content.y + (offset - first) as u16,
|
||||||
|
width: content.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
if let Some(row_index) = row_cursor
|
||||||
|
&& selected.contains(&row_index)
|
||||||
|
&& row_index != cursor
|
||||||
|
{
|
||||||
|
frame
|
||||||
|
.buffer_mut()
|
||||||
|
.set_style(rect, theme::selection_for(state));
|
||||||
|
}
|
||||||
|
draw_row(frame, rect, state, line, right, row_cursor == Some(cursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Federated artist card (assembled from peer catalogs)
|
// Federated artist card (assembled from peer catalogs)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user