Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
079d87a831 | ||
|
|
add764e51d | ||
|
|
87cb7fe74c | ||
|
|
e95d2e7fe1 | ||
|
|
03f74cc91e |
@@ -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
|
||||||
@@ -282,6 +298,31 @@ intended role: transforming current audio features into drawing commands. It
|
|||||||
is not a plugin mechanism for accessing the library, network, or player
|
is not a plugin mechanism for accessing the library, network, or player
|
||||||
controls.
|
controls.
|
||||||
|
|
||||||
|
## Music-similarity indexing
|
||||||
|
|
||||||
|
Similarity search is an optional local capability and is disabled by default.
|
||||||
|
When enabled, a background pipeline downloads a selected ONNX model, verifies
|
||||||
|
its pinned SHA-256 digest, decodes durable local tracks, and stores normalized
|
||||||
|
embeddings in the library SQLite database. Embeddings are keyed by an exact
|
||||||
|
fingerprint of the model artifact and preprocessing profile. Old profile rows
|
||||||
|
remain available while a new profile is calculated, and the in-memory exact
|
||||||
|
cosine index switches only after the replacement profile is usable.
|
||||||
|
|
||||||
|
The SQLite rows are the canonical derived store. The in-memory index can be
|
||||||
|
discarded and rebuilt, and neither is required for import, browsing, or
|
||||||
|
playback. Remote/cache-only tracks are never scheduled for local embedding.
|
||||||
|
|
||||||
|
Federated similarity uses a separate versioned direct-stream protocol. With
|
||||||
|
explicit privacy consent, the requester sends only a normalized embedding and
|
||||||
|
its profile fingerprint to a bounded set of known peers. It does not publish
|
||||||
|
queries to the DHT. Each peer searches its own active local index and returns a
|
||||||
|
bounded metadata result with a compact embedding SimHash. The requester uses
|
||||||
|
that signature to suppress near-duplicate recordings across peers without
|
||||||
|
receiving every result vector. Fan-out, concurrency, message sizes, and
|
||||||
|
timeouts are bounded; incompatible profiles are rejected. This direct
|
||||||
|
peer-selection layer can later be replaced by DHT routing without changing
|
||||||
|
local storage or ranking.
|
||||||
|
|
||||||
## Persistence boundaries
|
## Persistence boundaries
|
||||||
|
|
||||||
Furumi stores different kinds of state according to their lifetime:
|
Furumi stores different kinds of state according to their lifetime:
|
||||||
@@ -290,6 +331,7 @@ Furumi stores different kinds of state according to their lifetime:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Library, playlists, likes, history | SQLite | Durable local source of truth |
|
| Library, playlists, likes, history | SQLite | Durable local source of truth |
|
||||||
| Device operation log and replicas | SQLite | Offline synchronization |
|
| Device operation log and replicas | SQLite | Offline synchronization |
|
||||||
|
| Versioned track embeddings | SQLite | Durable, locally rebuildable similarity data |
|
||||||
| Federation catalog cache | SQLite/cache | Faster network browsing |
|
| Federation catalog cache | SQLite/cache | Faster network browsing |
|
||||||
| Audio and artwork cache | Filesystem cache | Reusable fetched data |
|
| Audio and artwork cache | Filesystem cache | Reusable fetched data |
|
||||||
| Settings, keymap, identity | Platform config/data dirs | Node configuration |
|
| Settings, keymap, identity | Platform config/data dirs | Node configuration |
|
||||||
@@ -321,6 +363,8 @@ The source tree follows the architectural responsibilities:
|
|||||||
|
|
||||||
- `library/` owns the local catalog and import pipeline;
|
- `library/` owns the local catalog and import pipeline;
|
||||||
- `player/` owns audio playback and analysis;
|
- `player/` owns audio playback and analysis;
|
||||||
|
- `similarity.rs` owns model acquisition, preprocessing, background indexing,
|
||||||
|
and the replaceable exact in-memory index;
|
||||||
- `federation/` owns DHT-facing search, peer catalogs, and audio exchange;
|
- `federation/` owns DHT-facing search, peer catalogs, and audio exchange;
|
||||||
- `devices.rs` owns trusted-device replication and playback coordination;
|
- `devices.rs` owns trusted-device replication and playback coordination;
|
||||||
- `app/` owns state transitions and runtime orchestration;
|
- `app/` owns state transitions and runtime orchestration;
|
||||||
|
|||||||
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Optional offline music-similarity search for local tracks, backed by
|
||||||
|
versioned SQLite embeddings and a replaceable exact in-memory cosine index.
|
||||||
|
- Automatic SHA-256-verified download and local inference for the first ONNX
|
||||||
|
embedding model, with retained model/profile generations and background
|
||||||
|
backfilling of existing library tracks.
|
||||||
|
- Similarity settings for enablement, model/profile information, numeric worker
|
||||||
|
count, derived-data cleanup, processing progress, and federation privacy
|
||||||
|
consent.
|
||||||
|
- Track-seeded similarity search from the track-information popup, including
|
||||||
|
bounded federated queries to compatible known peers.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Similarity wire types, bounds, validation, and stream framing now come from
|
||||||
|
the shared `music-dht 0.4.0` API so native, web, and future clients can
|
||||||
|
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
|
||||||
|
excluding it from the actual nearest-neighbor ranking, labels the mode as
|
||||||
|
`Search similar to`, and suppresses near-identical embeddings across releases
|
||||||
|
and federated peer responses.
|
||||||
|
- Preprocessing profiles now open a read-only details window describing their
|
||||||
|
audio selection, resampling, spectrogram, patching, and aggregation contract.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Current-track information (`Shift+I`) now uses the enriched queue entry, so
|
||||||
|
it shows the same complete metadata and similarity action as `I` on that
|
||||||
|
track in the queue.
|
||||||
|
|
||||||
## [0.2.5] - 2026-08-02
|
## [0.2.5] - 2026-08-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+834
-134
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
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"
|
||||||
@@ -21,19 +21,23 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn
|
|||||||
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"
|
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"] }
|
||||||
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"] }
|
||||||
|
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"
|
||||||
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
||||||
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"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
tract-onnx = "0.23.4"
|
||||||
unicode-width = "0.2.2"
|
unicode-width = "0.2.2"
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ library management are included. Visualizations are runtime-loadable Rhai
|
|||||||
scripts executed in a resource-limited sandbox, so they can be added or edited
|
scripts executed in a resource-limited sandbox, so they can be added or edited
|
||||||
without rebuilding the player.
|
without rebuilding the player.
|
||||||
|
|
||||||
|
Optional similarity search calculates versioned embeddings for local tracks
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
by MTG under CC BY-NC-SA 4.0 (a proprietary license is also available from
|
||||||
|
MTG); Furumi itself remains WTFPL.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
@@ -144,6 +154,7 @@ Furumi is a Rust application built with:
|
|||||||
- `ratatui` and `crossterm` for the cross-platform TUI;
|
- `ratatui` and `crossterm` for the cross-platform TUI;
|
||||||
- `rodio` for local audio playback;
|
- `rodio` for local audio playback;
|
||||||
- SQLite for the personal library and synchronization state;
|
- SQLite for the personal library and synchronization state;
|
||||||
|
- tract ONNX inference for optional local music embeddings;
|
||||||
- a dedicated DHT for decentralized discovery;
|
- a dedicated DHT for decentralized discovery;
|
||||||
- iroh-based P2P streams for client-to-client communication;
|
- iroh-based P2P streams for client-to-client communication;
|
||||||
- an offline-tolerant operation log for trusted-device synchronization;
|
- an offline-tolerant operation log for trusted-device synchronization;
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ fn set_view_cursor_zero(state: &mut AppState) {
|
|||||||
/// spawned task only queries if it is still the latest after the debounce,
|
/// spawned task only queries if it is still the latest after the debounce,
|
||||||
/// 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_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() {
|
||||||
@@ -145,6 +150,53 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn schedule_similarity_search(
|
||||||
|
state: &mut AppState,
|
||||||
|
runtime: &Runtime,
|
||||||
|
track: &crate::library::models::TrackItem,
|
||||||
|
) {
|
||||||
|
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let artist = track.artist_line();
|
||||||
|
state.search.query = if artist.is_empty() {
|
||||||
|
track.title.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} — {artist}", track.title)
|
||||||
|
};
|
||||||
|
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.results = None;
|
||||||
|
state.search.fed_tracks.clear();
|
||||||
|
state.search.fed_artists.clear();
|
||||||
|
state.search.fed_loading = false;
|
||||||
|
state.active_tab = crate::app::state::Tab::Global;
|
||||||
|
if let Some(crate::app::state::GlobalView::Search { cursor }) = state.global.stack.last_mut() {
|
||||||
|
*cursor = 0;
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.global
|
||||||
|
.stack
|
||||||
|
.push(crate::app::state::GlobalView::Search { cursor: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let similarity = Arc::clone(&runtime.similarity);
|
||||||
|
let tx = runtime.event_tx.clone();
|
||||||
|
let track_id = track.id;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let result = similarity
|
||||||
|
.search_track(track_id, 49)
|
||||||
|
.map(|(matches, query)| (matches, query));
|
||||||
|
let (result, query) = match result {
|
||||||
|
Ok((results, query)) => (Ok(results), Some(query)),
|
||||||
|
Err(err) => (Err(format!("{err:#}")), None),
|
||||||
|
};
|
||||||
|
let _ = tx.send(AppEvent::SimilaritySearchLoaded { seq, result, query });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Refresh only the local-library half of an already open search.
|
/// Refresh only the local-library half of an already open search.
|
||||||
///
|
///
|
||||||
/// Library/device sync notifications can arrive while federated search
|
/// Library/device sync notifications can arrive while federated search
|
||||||
@@ -152,6 +204,9 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
|||||||
/// federation rows and bump the shared sequence, causing valid network
|
/// federation rows and bump the shared sequence, causing valid network
|
||||||
/// responses to be dropped or flicker away.
|
/// responses to be dropped or flicker away.
|
||||||
pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
|
pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
|
||||||
|
if state.search.similarity_source.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let query = state.search.query.clone();
|
let query = state.search.query.clone();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ pub enum AppEvent {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
result: Result<SearchResults, String>,
|
result: Result<SearchResults, String>,
|
||||||
},
|
},
|
||||||
|
/// Local similar-track search completed. It uses the same sequence as
|
||||||
|
/// text search so stale pages cannot overwrite a newer request.
|
||||||
|
SimilaritySearchLoaded {
|
||||||
|
seq: u64,
|
||||||
|
result: Result<Vec<crate::similarity::SimilarTrack>, String>,
|
||||||
|
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),
|
||||||
|
/// `None` is emitted after clearing every stored embedding.
|
||||||
|
SimilarityProfileActivated(Option<String>),
|
||||||
/// Artwork loaded and decoded for the shared art cache.
|
/// Artwork loaded and decoded for the shared art cache.
|
||||||
ArtLoaded {
|
ArtLoaded {
|
||||||
key: String,
|
key: String,
|
||||||
|
|||||||
+283
-11
@@ -41,8 +41,13 @@ pub struct Runtime {
|
|||||||
pub devices: Arc<crate::devices::DeviceSync>,
|
pub devices: Arc<crate::devices::DeviceSync>,
|
||||||
pub jam: Arc<crate::jam::JamManager>,
|
pub jam: Arc<crate::jam::JamManager>,
|
||||||
pub federation: Arc<crate::federation::Federation>,
|
pub federation: Arc<crate::federation::Federation>,
|
||||||
|
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:
|
||||||
@@ -101,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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +291,7 @@ pub async fn run(
|
|||||||
state.player.volume = settings.volume;
|
state.player.volume = settings.volume;
|
||||||
state.global.filters = settings.library;
|
state.global.filters = settings.library;
|
||||||
state.music_dir = settings.music_dir.clone();
|
state.music_dir = settings.music_dir.clone();
|
||||||
|
state.similarity.settings = settings.similarity.clone();
|
||||||
if let Err(err) = state.visualizer.load_library() {
|
if let Err(err) = state.visualizer.load_library() {
|
||||||
state.status_message = Some(format!("visualizations disabled: {err:#}"));
|
state.status_message = Some(format!("visualizations disabled: {err:#}"));
|
||||||
}
|
}
|
||||||
@@ -269,10 +299,17 @@ pub async fn run(
|
|||||||
let devices = crate::devices::DeviceSync::new(Arc::clone(&library))?;
|
let devices = crate::devices::DeviceSync::new(Arc::clone(&library))?;
|
||||||
devices.set_event_tx(event_tx.clone());
|
devices.set_event_tx(event_tx.clone());
|
||||||
let jam = crate::jam::JamManager::new(event_tx.clone());
|
let jam = crate::jam::JamManager::new(event_tx.clone());
|
||||||
|
let similarity = crate::similarity::Manager::new(
|
||||||
|
Arc::clone(&library),
|
||||||
|
event_tx.clone(),
|
||||||
|
settings.similarity.clone(),
|
||||||
|
);
|
||||||
|
state.similarity.status = similarity.status();
|
||||||
let federation = crate::federation::Federation::new(
|
let federation = crate::federation::Federation::new(
|
||||||
Arc::clone(&library),
|
Arc::clone(&library),
|
||||||
Arc::clone(&devices),
|
Arc::clone(&devices),
|
||||||
Arc::clone(&jam),
|
Arc::clone(&jam),
|
||||||
|
Arc::clone(&similarity),
|
||||||
settings.music_dir.clone(),
|
settings.music_dir.clone(),
|
||||||
);
|
);
|
||||||
state.music_dir = federation.media_dir();
|
state.music_dir = federation.media_dir();
|
||||||
@@ -292,7 +329,10 @@ pub async fn run(
|
|||||||
devices,
|
devices,
|
||||||
jam,
|
jam,
|
||||||
federation,
|
federation,
|
||||||
|
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())),
|
||||||
@@ -319,6 +359,9 @@ pub async fn run(
|
|||||||
status_publisher: crate::status::Publisher::spawn(),
|
status_publisher: crate::status::Publisher::spawn(),
|
||||||
};
|
};
|
||||||
spawn_content_id_backfill(&runtime);
|
spawn_content_id_backfill(&runtime);
|
||||||
|
if state.similarity.settings.enabled {
|
||||||
|
runtime.similarity.start();
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
@@ -1581,6 +1624,15 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
let _ = tx.send(event);
|
let _ = tx.send(event);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Effect::SimilarityApplySettings => {
|
||||||
|
save_app_settings(state);
|
||||||
|
runtime.similarity.apply(state.similarity.settings.clone());
|
||||||
|
state.similarity.status = runtime.similarity.status();
|
||||||
|
}
|
||||||
|
Effect::SimilarityClear => {
|
||||||
|
state.status_message = Some("clearing stored embeddings…".to_string());
|
||||||
|
runtime.similarity.clear();
|
||||||
|
}
|
||||||
Effect::FedApplySettings => fed_apply_settings(state, runtime),
|
Effect::FedApplySettings => fed_apply_settings(state, runtime),
|
||||||
Effect::FedSyncNow => {
|
Effect::FedSyncNow => {
|
||||||
state.federation.publishing = true;
|
state.federation.publishing = true;
|
||||||
@@ -2724,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
|
||||||
@@ -2785,6 +2842,7 @@ fn save_app_settings(state: &AppState) {
|
|||||||
volume: state.player.volume,
|
volume: state.player.volume,
|
||||||
library: state.global.filters,
|
library: state.global.filters,
|
||||||
music_dir: state.music_dir.clone(),
|
music_dir: state.music_dir.clone(),
|
||||||
|
similarity: state.similarity.settings.clone(),
|
||||||
};
|
};
|
||||||
if let Err(err) = crate::config::settings::save(&settings) {
|
if let Err(err) = crate::config::settings::save(&settings) {
|
||||||
tracing::warn!(%err, "saving app settings failed");
|
tracing::warn!(%err, "saving app settings failed");
|
||||||
@@ -3290,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,
|
||||||
@@ -3632,6 +3723,53 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
Err(message) => state.status_message = Some(message),
|
Err(message) => state.status_message = Some(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEvent::SimilaritySearchLoaded { seq, result, query } => {
|
||||||
|
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.search.loading = false;
|
||||||
|
match result {
|
||||||
|
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) => {
|
||||||
|
state.status_message = Some(format!("similarity search failed: {message}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(query) = query
|
||||||
|
&& state.federation.settings.enabled
|
||||||
|
&& runtime.similarity.network_allowed()
|
||||||
|
{
|
||||||
|
state.search.fed_loading = true;
|
||||||
|
let federation = Arc::clone(&runtime.federation);
|
||||||
|
let tx = runtime.event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = federation
|
||||||
|
.search_similar(query, 50)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("{err:#}"));
|
||||||
|
let _ = tx.send(AppEvent::FedSimilaritySearchLoaded { seq, result });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::SimilarityStatus(status) => state.similarity.status = status,
|
||||||
|
AppEvent::SimilarityProfileActivated(profile_id) => {
|
||||||
|
state.similarity.settings.active_profile = profile_id;
|
||||||
|
state.similarity.status = runtime.similarity.status();
|
||||||
|
save_app_settings(state);
|
||||||
|
}
|
||||||
AppEvent::ArtLoaded { key, art } => {
|
AppEvent::ArtLoaded { key, art } => {
|
||||||
let entry = match art {
|
let entry = match art {
|
||||||
Some(image) => state::ArtState::Ready(image),
|
Some(image) => state::ArtState::Ready(image),
|
||||||
@@ -3735,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);
|
||||||
@@ -3857,6 +3997,9 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
AppEvent::LibraryChanged { message } => {
|
AppEvent::LibraryChanged { message } => {
|
||||||
on_library_changed(state, runtime);
|
on_library_changed(state, runtime);
|
||||||
|
if state.similarity.settings.enabled {
|
||||||
|
runtime.similarity.start();
|
||||||
|
}
|
||||||
if let Some(message) = message {
|
if let Some(message) = message {
|
||||||
state.status_message = Some(message);
|
state.status_message = Some(message);
|
||||||
}
|
}
|
||||||
@@ -3910,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) {
|
||||||
|
|||||||
@@ -115,6 +115,39 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
Popup::ConfirmDelete { target, label } => {
|
Popup::ConfirmDelete { target, label } => {
|
||||||
handle_confirm_delete(state, runtime, target, label, key);
|
handle_confirm_delete(state, runtime, target, label, key);
|
||||||
}
|
}
|
||||||
|
Popup::SimilarityPrivacyConsent { enable_federation } => match key.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('y') => {
|
||||||
|
state.similarity.settings.federation_consent = true;
|
||||||
|
state.similarity.settings.enabled = true;
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::SimilarityApplySettings,
|
||||||
|
);
|
||||||
|
if enable_federation {
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::FedApplySettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {
|
||||||
|
if enable_federation {
|
||||||
|
state.federation.settings.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.popup = Some(Popup::SimilarityPrivacyConsent { enable_federation });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Popup::ConfirmClearEmbeddings => match key.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('y') => {
|
||||||
|
super::perform_effect(state, runtime, crate::app::update::Effect::SimilarityClear)
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {}
|
||||||
|
_ => state.popup = Some(Popup::ConfirmClearEmbeddings),
|
||||||
|
},
|
||||||
Popup::LibraryFilters { cursor } => handle_library_filters(state, runtime, cursor, key),
|
Popup::LibraryFilters { cursor } => handle_library_filters(state, runtime, cursor, key),
|
||||||
Popup::TrackInfo {
|
Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -544,6 +577,51 @@ fn handle_fed_input(
|
|||||||
super::validate_music_directory(state, runtime, value.into());
|
super::validate_music_directory(state, runtime, value.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
FedInputField::SimilarityWorkers => match value.parse::<usize>() {
|
||||||
|
Ok(workers @ 1..=16) => {
|
||||||
|
state.similarity.settings.workers = workers;
|
||||||
|
super::perform_effect(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
crate::app::update::Effect::SimilarityApplySettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.status_message =
|
||||||
|
Some("similarity workers must be a number from 1 to 16".into());
|
||||||
|
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());
|
||||||
@@ -1019,6 +1097,28 @@ fn handle_track_info(
|
|||||||
scroll,
|
scroll,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !state.similarity.settings.enabled {
|
||||||
|
state.status_message = Some("enable Similarity search in Settings first".into());
|
||||||
|
state.popup = Some(Popup::TrackInfo {
|
||||||
|
tracks,
|
||||||
|
cursor,
|
||||||
|
scroll,
|
||||||
|
});
|
||||||
|
} else if track.id < 0 || track.file_path.is_empty() {
|
||||||
|
state.status_message = Some("similarity search starts from a local track".into());
|
||||||
|
state.popup = Some(Popup::TrackInfo {
|
||||||
|
tracks,
|
||||||
|
cursor,
|
||||||
|
scroll,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
super::cmdline::schedule_similarity_search(state, runtime, track);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
state.popup = Some(Popup::TrackInfo {
|
state.popup = Some(Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -687,6 +689,10 @@ pub enum Popup {
|
|||||||
},
|
},
|
||||||
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
|
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
|
||||||
ConfirmDelete { target: DeleteTarget, label: String },
|
ConfirmDelete { target: DeleteTarget, label: String },
|
||||||
|
/// Enabling network similarity reveals an embedding query to peers.
|
||||||
|
SimilarityPrivacyConsent { enable_federation: bool },
|
||||||
|
/// Derived data is safe to recreate but potentially expensive.
|
||||||
|
ConfirmClearEmbeddings,
|
||||||
/// Library-home filters. Cursor is kept for the next filters added here.
|
/// Library-home filters. Cursor is kept for the next filters added here.
|
||||||
LibraryFilters { cursor: usize },
|
LibraryFilters { cursor: usize },
|
||||||
/// Track metadata viewer; left/right switch between selected tracks.
|
/// Track metadata viewer; left/right switch between selected tracks.
|
||||||
@@ -812,6 +818,9 @@ impl StatusDetailFocus {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FedInputField {
|
pub enum FedInputField {
|
||||||
MusicDirectory,
|
MusicDirectory,
|
||||||
|
SimilarityWorkers,
|
||||||
|
SimilarityMinimumScore,
|
||||||
|
SimilarityMaxTracksPerArtist,
|
||||||
NetworkId,
|
NetworkId,
|
||||||
ConnectTicket,
|
ConnectTicket,
|
||||||
DeviceName,
|
DeviceName,
|
||||||
@@ -823,6 +832,9 @@ impl FedInputField {
|
|||||||
pub fn title(self) -> &'static str {
|
pub fn title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
FedInputField::MusicDirectory => "Music save directory",
|
FedInputField::MusicDirectory => "Music save directory",
|
||||||
|
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",
|
||||||
@@ -836,6 +848,15 @@ impl FedInputField {
|
|||||||
FedInputField::MusicDirectory => {
|
FedInputField::MusicDirectory => {
|
||||||
"Federated tracks saved to your library use this directory. The directory is checked for write access before anything changes."
|
"Federated tracks saved to your library use this directory. The directory is checked for write access before anything changes."
|
||||||
}
|
}
|
||||||
|
FedInputField::SimilarityWorkers => {
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
@@ -866,6 +887,29 @@ pub enum FedRow {
|
|||||||
Connect,
|
Connect,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SimilarityRow {
|
||||||
|
Toggle,
|
||||||
|
Model,
|
||||||
|
Profile,
|
||||||
|
MinimumScore,
|
||||||
|
MaxTracksPerArtist,
|
||||||
|
Workers,
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimilarityRow {
|
||||||
|
pub const ALL: [SimilarityRow; 7] = [
|
||||||
|
SimilarityRow::Toggle,
|
||||||
|
SimilarityRow::Model,
|
||||||
|
SimilarityRow::Profile,
|
||||||
|
SimilarityRow::MinimumScore,
|
||||||
|
SimilarityRow::MaxTracksPerArtist,
|
||||||
|
SimilarityRow::Workers,
|
||||||
|
SimilarityRow::Clear,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
impl FedRow {
|
impl FedRow {
|
||||||
pub const ALL: [FedRow; 6] = [
|
pub const ALL: [FedRow; 6] = [
|
||||||
FedRow::Toggle,
|
FedRow::Toggle,
|
||||||
@@ -883,6 +927,7 @@ impl FedRow {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SettingsRow {
|
pub enum SettingsRow {
|
||||||
MusicDirectory,
|
MusicDirectory,
|
||||||
|
Similarity(SimilarityRow),
|
||||||
Federation(FedRow),
|
Federation(FedRow),
|
||||||
StatusDetails,
|
StatusDetails,
|
||||||
DeviceName,
|
DeviceName,
|
||||||
@@ -1021,6 +1066,7 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
|
|||||||
|
|
||||||
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
||||||
let mut rows = vec![SettingsRow::MusicDirectory];
|
let mut rows = vec![SettingsRow::MusicDirectory];
|
||||||
|
rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity));
|
||||||
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
||||||
rows.push(SettingsRow::DeviceName);
|
rows.push(SettingsRow::DeviceName);
|
||||||
rows.push(SettingsRow::DeviceInvite);
|
rows.push(SettingsRow::DeviceInvite);
|
||||||
@@ -1059,6 +1105,12 @@ pub struct FederationTab {
|
|||||||
pub device_syncing: bool,
|
pub device_syncing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SimilarityTab {
|
||||||
|
pub settings: crate::config::settings::SimilaritySettings,
|
||||||
|
pub status: crate::similarity::SimilarityStatus,
|
||||||
|
}
|
||||||
|
|
||||||
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
||||||
/// through likes, not direct adds).
|
/// through likes, not direct adds).
|
||||||
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
|
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
|
||||||
@@ -1175,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,
|
||||||
@@ -1187,6 +1318,62 @@ pub struct SearchState {
|
|||||||
/// from the artist names of matching tracks.
|
/// from the artist names of matching tracks.
|
||||||
pub fed_artists: Vec<crate::federation::FedArtistHit>,
|
pub fed_artists: Vec<crate::federation::FedArtistHit>,
|
||||||
pub fed_loading: bool,
|
pub fed_loading: bool,
|
||||||
|
/// Present only for a track-seeded search; text-search refreshes must not
|
||||||
|
/// replace this page with a title query.
|
||||||
|
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)]
|
||||||
@@ -1423,6 +1610,7 @@ pub struct AppState {
|
|||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
pub queue_tab: QueueTab,
|
pub queue_tab: QueueTab,
|
||||||
pub federation: FederationTab,
|
pub federation: FederationTab,
|
||||||
|
pub similarity: SimilarityTab,
|
||||||
/// The one federated artist card being viewed (name + loading state);
|
/// The one federated artist card being viewed (name + loading state);
|
||||||
/// opening another card replaces it.
|
/// opening another card replaces it.
|
||||||
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
|
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
|
||||||
|
|||||||
+200
-32
@@ -63,6 +63,9 @@ pub enum Effect {
|
|||||||
},
|
},
|
||||||
/// Persist the federation settings and start/stop the node.
|
/// Persist the federation settings and start/stop the node.
|
||||||
FedApplySettings,
|
FedApplySettings,
|
||||||
|
/// Persist/apply embedding model, profile, worker or enable changes.
|
||||||
|
SimilarityApplySettings,
|
||||||
|
SimilarityClear,
|
||||||
/// Force an immediate library publish into the DHT.
|
/// Force an immediate library publish into the DHT.
|
||||||
FedSyncNow,
|
FedSyncNow,
|
||||||
/// Fetch this peer's ticket and show it in a popup.
|
/// Fetch this peer's ticket and show it in a popup.
|
||||||
@@ -379,7 +382,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
open_track_info(state, tracks, "no track selected")
|
open_track_info(state, tracks, "no track selected")
|
||||||
}
|
}
|
||||||
Action::OpenCurrentTrackInfo => {
|
Action::OpenCurrentTrackInfo => {
|
||||||
let tracks = state.player.current.clone().into_iter().collect();
|
let tracks = current_track_for_info(state).into_iter().collect();
|
||||||
open_track_info(state, tracks, "nothing playing")
|
open_track_info(state, tracks, "nothing playing")
|
||||||
}
|
}
|
||||||
Action::RemoveFromQueue => remove_selected_from_queue(state),
|
Action::RemoveFromQueue => remove_selected_from_queue(state),
|
||||||
@@ -457,6 +460,31 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefer the queue's current entry because it may have been enriched or
|
||||||
|
/// replaced with a durable local track after streaming started. The player
|
||||||
|
/// keeps its original lightweight entry while that stream is active.
|
||||||
|
fn current_track_for_info(state: &AppState) -> Option<TrackItem> {
|
||||||
|
let current = state.player.current.as_ref()?;
|
||||||
|
let current_key = super::state::track_key(current);
|
||||||
|
let queued_at_position = state.player.queue.get(state.player.queue_pos);
|
||||||
|
|
||||||
|
queued_at_position
|
||||||
|
.filter(|queued| {
|
||||||
|
super::state::track_key(queued) == current_key
|
||||||
|
|| queued.id == current.id
|
||||||
|
|| current.is_fed_pending()
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
state
|
||||||
|
.player
|
||||||
|
.queue
|
||||||
|
.iter()
|
||||||
|
.find(|queued| super::state::track_key(queued) == current_key)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| Some(current.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
|
fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
|
||||||
track.fed.is_some()
|
track.fed.is_some()
|
||||||
&& (track.featured_artists.is_empty()
|
&& (track.featured_artists.is_empty()
|
||||||
@@ -877,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();
|
||||||
@@ -924,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();
|
||||||
@@ -1016,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).
|
||||||
{
|
{
|
||||||
@@ -1248,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) {
|
||||||
@@ -1849,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;
|
||||||
}
|
}
|
||||||
@@ -1972,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)
|
||||||
@@ -2264,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);
|
||||||
@@ -2464,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 {
|
||||||
@@ -2720,7 +2813,7 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
|
|||||||
/// Enter on Settings: toggle switches, open text inputs, run
|
/// Enter on Settings: toggle switches, open text inputs, run
|
||||||
/// one-shot operations. The heavy lifting happens in perform_effect().
|
/// one-shot operations. The heavy lifting happens in perform_effect().
|
||||||
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||||
use super::state::{FedInputField, FedRow, Popup, SettingsRow};
|
use super::state::{FedInputField, FedRow, Popup, SettingsRow, SimilarityRow};
|
||||||
match settings_rows(state).get(state.settings_cursor).copied()? {
|
match settings_rows(state).get(state.settings_cursor).copied()? {
|
||||||
SettingsRow::MusicDirectory => {
|
SettingsRow::MusicDirectory => {
|
||||||
if state.music_dir_changing {
|
if state.music_dir_changing {
|
||||||
@@ -2735,6 +2828,71 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
});
|
});
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Toggle) => {
|
||||||
|
if !state.similarity.settings.enabled
|
||||||
|
&& state.federation.settings.enabled
|
||||||
|
&& !state.similarity.settings.federation_consent
|
||||||
|
{
|
||||||
|
state.popup = Some(Popup::SimilarityPrivacyConsent {
|
||||||
|
enable_federation: false,
|
||||||
|
});
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
state.similarity.settings.enabled = !state.similarity.settings.enabled;
|
||||||
|
Some(Effect::SimilarityApplySettings)
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Model) => {
|
||||||
|
let models = crate::similarity::MODELS;
|
||||||
|
let current = models
|
||||||
|
.iter()
|
||||||
|
.position(|model| model.id == state.similarity.settings.model)
|
||||||
|
.unwrap_or(0);
|
||||||
|
state.similarity.settings.model = models[(current + 1) % models.len()].id.to_string();
|
||||||
|
Some(Effect::SimilarityApplySettings)
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Profile) => {
|
||||||
|
let profile = state.similarity.settings.profile.clone();
|
||||||
|
let text =
|
||||||
|
crate::similarity::profile_details(&profile, &state.similarity.settings.model)
|
||||||
|
.unwrap_or_else(|| format!("Unknown preprocessing profile: {profile}"));
|
||||||
|
state.popup = Some(Popup::FedText {
|
||||||
|
title: format!("Preprocessing profile: {profile}"),
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
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) => {
|
||||||
|
state.popup = Some(Popup::FedInput {
|
||||||
|
field: FedInputField::SimilarityWorkers,
|
||||||
|
input: crate::app::input::LineEdit::new(
|
||||||
|
state.similarity.settings.workers.to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SettingsRow::Similarity(SimilarityRow::Clear) => {
|
||||||
|
state.popup = Some(Popup::ConfirmClearEmbeddings);
|
||||||
|
None
|
||||||
|
}
|
||||||
SettingsRow::Federation(FedRow::Toggle) => {
|
SettingsRow::Federation(FedRow::Toggle) => {
|
||||||
let settings = &mut state.federation.settings;
|
let settings = &mut state.federation.settings;
|
||||||
if !settings.enabled && settings.network_id.trim().is_empty() {
|
if !settings.enabled && settings.network_id.trim().is_empty() {
|
||||||
@@ -2744,7 +2902,17 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
});
|
});
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
settings.enabled = !settings.enabled;
|
let enabling = !settings.enabled;
|
||||||
|
settings.enabled = enabling;
|
||||||
|
if enabling
|
||||||
|
&& state.similarity.settings.enabled
|
||||||
|
&& !state.similarity.settings.federation_consent
|
||||||
|
{
|
||||||
|
state.popup = Some(Popup::SimilarityPrivacyConsent {
|
||||||
|
enable_federation: true,
|
||||||
|
});
|
||||||
|
return None;
|
||||||
|
}
|
||||||
Some(Effect::FedApplySettings)
|
Some(Effect::FedApplySettings)
|
||||||
}
|
}
|
||||||
SettingsRow::Federation(FedRow::NetworkId) => {
|
SettingsRow::Federation(FedRow::NetworkId) => {
|
||||||
|
|||||||
+11
-2
@@ -571,9 +571,15 @@ fn current_track_info_uses_now_playing_track() {
|
|||||||
active_tab: Tab::Queue,
|
active_tab: Tab::Queue,
|
||||||
..AppState::default()
|
..AppState::default()
|
||||||
};
|
};
|
||||||
state.player.queue = vec![test_track(1), test_track(2)];
|
let mut complete = test_track(2);
|
||||||
|
complete.audio_format = Some("flac".into());
|
||||||
|
complete.audio_bitrate = Some(921);
|
||||||
|
state.player.queue = vec![test_track(1), complete];
|
||||||
|
state.player.queue_pos = 1;
|
||||||
state.queue_tab.cursor = 0;
|
state.queue_tab.cursor = 0;
|
||||||
state.player.current = Some(test_track(2));
|
let mut lightweight = test_track(2);
|
||||||
|
lightweight.file_path.clear();
|
||||||
|
state.player.current = Some(lightweight);
|
||||||
|
|
||||||
assert_eq!(update(&mut state, Action::OpenCurrentTrackInfo), None);
|
assert_eq!(update(&mut state, Action::OpenCurrentTrackInfo), None);
|
||||||
match &state.popup {
|
match &state.popup {
|
||||||
@@ -582,6 +588,9 @@ fn current_track_info_uses_now_playing_track() {
|
|||||||
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
|
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
|
||||||
vec![2]
|
vec![2]
|
||||||
);
|
);
|
||||||
|
assert_eq!(tracks[0].file_path, "/s/2");
|
||||||
|
assert_eq!(tracks[0].audio_format.as_deref(), Some("flac"));
|
||||||
|
assert_eq!(tracks[0].audio_bitrate, Some(921));
|
||||||
}
|
}
|
||||||
other => panic!("expected track info popup, got {other:?}"),
|
other => panic!("expected track info popup, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-1
@@ -45,7 +45,51 @@ pub struct LibraryFilters {
|
|||||||
pub source_mode: LibrarySourceMode,
|
pub source_mode: LibrarySourceMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SimilaritySettings {
|
||||||
|
/// Local embedding/search master switch. Network participation follows
|
||||||
|
/// federation and additionally requires the explicit privacy consent.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_similarity_model")]
|
||||||
|
pub model: String,
|
||||||
|
#[serde(default = "default_similarity_profile")]
|
||||||
|
pub profile: String,
|
||||||
|
#[serde(default = "default_similarity_workers")]
|
||||||
|
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)]
|
||||||
|
pub federation_consent: bool,
|
||||||
|
/// Exact fingerprint of the last fully usable profile. Keeping this
|
||||||
|
/// separate from the selected target lets an old index serve searches
|
||||||
|
/// while a newly selected model/profile is being calculated.
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_profile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SimilaritySettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
model: default_similarity_model(),
|
||||||
|
profile: default_similarity_profile(),
|
||||||
|
workers: default_similarity_workers(),
|
||||||
|
minimum_score: default_similarity_minimum_score(),
|
||||||
|
max_tracks_per_artist: default_similarity_max_tracks_per_artist(),
|
||||||
|
federation_consent: false,
|
||||||
|
active_profile: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
@@ -54,6 +98,8 @@ pub struct AppSettings {
|
|||||||
/// Root used for music materialized from federation peers.
|
/// Root used for music materialized from federation peers.
|
||||||
#[serde(default = "default_music_dir")]
|
#[serde(default = "default_music_dir")]
|
||||||
pub music_dir: PathBuf,
|
pub music_dir: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
|
pub similarity: SimilaritySettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -62,6 +108,7 @@ impl Default for AppSettings {
|
|||||||
volume: default_volume(),
|
volume: default_volume(),
|
||||||
library: LibraryFilters::default(),
|
library: LibraryFilters::default(),
|
||||||
music_dir: default_music_dir(),
|
music_dir: default_music_dir(),
|
||||||
|
similarity: SimilaritySettings::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +119,18 @@ impl AppSettings {
|
|||||||
if self.music_dir.as_os_str().is_empty() {
|
if self.music_dir.as_os_str().is_empty() {
|
||||||
self.music_dir = default_music_dir();
|
self.music_dir = default_music_dir();
|
||||||
}
|
}
|
||||||
|
if self.similarity.model.trim().is_empty() {
|
||||||
|
self.similarity.model = default_similarity_model();
|
||||||
|
}
|
||||||
|
if self.similarity.profile.trim().is_empty() {
|
||||||
|
self.similarity.profile = default_similarity_profile();
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,6 +139,28 @@ fn default_volume() -> u8 {
|
|||||||
80
|
80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_similarity_model() -> String {
|
||||||
|
"discogs-effnet-bsdynamic-1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_similarity_profile() -> String {
|
||||||
|
"furumi-full-track-v1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_similarity_workers() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|count| (count.get() / 2).clamp(1, 4))
|
||||||
|
.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 {
|
||||||
@@ -146,5 +227,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ mod tests {
|
|||||||
"music_dht",
|
"music_dht",
|
||||||
"catalog",
|
"catalog",
|
||||||
"audio",
|
"audio",
|
||||||
|
"similarity",
|
||||||
|
"similarity_dht",
|
||||||
"device_sync",
|
"device_sync",
|
||||||
"jam",
|
"jam",
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod capabilities;
|
mod capabilities;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
|
mod similarity;
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -24,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,
|
||||||
@@ -39,6 +42,7 @@ use crate::library::models::{ArtistRef, TrackItem};
|
|||||||
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
|
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
|
||||||
pub use capabilities::ProtocolVersions;
|
pub use capabilities::ProtocolVersions;
|
||||||
pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease};
|
pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease};
|
||||||
|
pub use similarity::SIMILARITY_ALPN;
|
||||||
|
|
||||||
/// How often the published library is re-synchronized with the local index.
|
/// How often the published library is re-synchronized with the local index.
|
||||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
@@ -334,6 +338,27 @@ 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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FedTrack {
|
pub struct FedTrack {
|
||||||
@@ -411,6 +436,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<()>>,
|
||||||
@@ -420,6 +446,7 @@ pub struct Federation {
|
|||||||
library: Arc<Library>,
|
library: Arc<Library>,
|
||||||
devices: Arc<crate::devices::DeviceSync>,
|
devices: Arc<crate::devices::DeviceSync>,
|
||||||
jam: Arc<crate::jam::JamManager>,
|
jam: Arc<crate::jam::JamManager>,
|
||||||
|
similarity: Arc<crate::similarity::Manager>,
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
cache_dir: PathBuf,
|
cache_dir: PathBuf,
|
||||||
media_dir: std::sync::Mutex<PathBuf>,
|
media_dir: std::sync::Mutex<PathBuf>,
|
||||||
@@ -526,6 +553,7 @@ impl Federation {
|
|||||||
library: Arc<Library>,
|
library: Arc<Library>,
|
||||||
devices: Arc<crate::devices::DeviceSync>,
|
devices: Arc<crate::devices::DeviceSync>,
|
||||||
jam: Arc<crate::jam::JamManager>,
|
jam: Arc<crate::jam::JamManager>,
|
||||||
|
similarity: Arc<crate::similarity::Manager>,
|
||||||
media_dir: PathBuf,
|
media_dir: PathBuf,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
let dirs = crate::config::project_dirs();
|
let dirs = crate::config::project_dirs();
|
||||||
@@ -549,6 +577,7 @@ impl Federation {
|
|||||||
library,
|
library,
|
||||||
devices,
|
devices,
|
||||||
jam,
|
jam,
|
||||||
|
similarity,
|
||||||
data_dir,
|
data_dir,
|
||||||
cache_dir,
|
cache_dir,
|
||||||
media_dir: std::sync::Mutex::new(media_dir),
|
media_dir: std::sync::Mutex::new(media_dir),
|
||||||
@@ -692,10 +721,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 have their own
|
||||||
|
// 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()
|
||||||
@@ -710,6 +744,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 {
|
||||||
@@ -746,6 +799,15 @@ impl Federation {
|
|||||||
service.endpoint_id(),
|
service.endpoint_id(),
|
||||||
Arc::clone(&self.transport_stats),
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
|
let similarity_acceptor = service
|
||||||
|
.stream_acceptor(SIMILARITY_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?;
|
||||||
|
let similarity_task = tokio::spawn(similarity::serve_peers(
|
||||||
|
similarity_acceptor,
|
||||||
|
Arc::clone(&self.similarity),
|
||||||
|
service.endpoint_id(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
let sync_acceptor = service
|
let sync_acceptor = service
|
||||||
.stream_acceptor(crate::devices::SYNC_ALPN)
|
.stream_acceptor(crate::devices::SYNC_ALPN)
|
||||||
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
|
||||||
@@ -781,6 +843,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![
|
||||||
@@ -788,12 +851,16 @@ impl Federation {
|
|||||||
sync_task,
|
sync_task,
|
||||||
audio_task,
|
audio_task,
|
||||||
catalog_task,
|
catalog_task,
|
||||||
|
similarity_task,
|
||||||
device_sync_task,
|
device_sync_task,
|
||||||
device_tick_task,
|
device_tick_task,
|
||||||
jam_serve_task,
|
jam_serve_task,
|
||||||
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);
|
||||||
@@ -818,6 +885,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!(
|
||||||
@@ -1147,6 +1228,30 @@ impl Federation {
|
|||||||
Ok(FedSearchResults { artists, tracks })
|
Ok(FedSearchResults { artists, tracks })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded fan-out to known peers using the exact model/profile
|
||||||
|
/// fingerprint carried with the query. No DHT records are written.
|
||||||
|
pub async fn search_similar(
|
||||||
|
&self,
|
||||||
|
query: crate::similarity::QueryVector,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<FedSimilaritySearchResults> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
self.similarity.network_allowed(),
|
||||||
|
"similarity federation has no consent"
|
||||||
|
);
|
||||||
|
let (service, similarity_dht) = self.similarity_services().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.
|
||||||
///
|
///
|
||||||
/// Resolution order: the in-session metadata cache, the DHT content key,
|
/// Resolution order: the in-session metadata cache, the DHT content key,
|
||||||
@@ -2533,6 +2638,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 {
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
//! Furumi policy and local-index adapter for the shared similarity protocol.
|
||||||
|
//!
|
||||||
|
//! `music_dht::similarity` owns the versioned wire contract and framing. This
|
||||||
|
//! module owns application policy: consent, peer fan-out, local index access,
|
||||||
|
//! result conversion, deduplication, and ranking limits.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use futures_util::stream::{self, StreamExt as _};
|
||||||
|
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||||
|
use music_dht::similarity_dht::SimilarityDht;
|
||||||
|
use music_dht::{
|
||||||
|
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::federation::{
|
||||||
|
FedSimilaritySearchResults, FedTrack, ScoredFedTrack, SimilaritySearchStats, TransportStats,
|
||||||
|
};
|
||||||
|
use crate::similarity::{Manager, QueryVector};
|
||||||
|
|
||||||
|
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||||
|
|
||||||
|
const INITIAL_QUERY_PEERS: usize = 16;
|
||||||
|
const MAX_QUERY_PEERS: usize = 48;
|
||||||
|
const QUERY_CONCURRENCY: usize = 8;
|
||||||
|
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||||
|
|
||||||
|
pub async fn serve_peers(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
similarity: Arc<Manager>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
let similarity = Arc::clone(&similarity);
|
||||||
|
let transport = Arc::clone(&transport);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let peer = stream.peer_id;
|
||||||
|
if let Err(err) = serve_one(stream, similarity, own, transport).await {
|
||||||
|
tracing::warn!(peer = %peer, "similarity request failed: {err:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
similarity: Arc<Manager>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
|
||||||
|
let request = wire::read_request(&mut stream).await?;
|
||||||
|
let response = if !similarity.network_allowed() {
|
||||||
|
SimilarityResponse::refused("similarity federation is disabled or has no privacy consent")?
|
||||||
|
} else {
|
||||||
|
let profile = request.profile_id;
|
||||||
|
let vector = request.vector;
|
||||||
|
let limit = request.limit;
|
||||||
|
let matches = tokio::task::spawn_blocking(move || {
|
||||||
|
similarity.search_vector_for_peer(&profile, &vector, limit)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("local similarity task failed")
|
||||||
|
.and_then(|result| result);
|
||||||
|
match matches {
|
||||||
|
Ok(matches) => {
|
||||||
|
let hits = matches
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|found| {
|
||||||
|
let track = found.track;
|
||||||
|
let hit = SimilarityHit {
|
||||||
|
score: found.score,
|
||||||
|
item_id: super::audio::hex_encode(
|
||||||
|
ItemId::derive(
|
||||||
|
&own,
|
||||||
|
ItemKind::Track,
|
||||||
|
&format!("track:{}", track.id),
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
),
|
||||||
|
title: track.title,
|
||||||
|
artist_names: track
|
||||||
|
.artists
|
||||||
|
.into_iter()
|
||||||
|
.map(|artist| artist.name)
|
||||||
|
.collect(),
|
||||||
|
featured_artist_names: track
|
||||||
|
.featured_artists
|
||||||
|
.into_iter()
|
||||||
|
.map(|artist| artist.name)
|
||||||
|
.collect(),
|
||||||
|
year: track.release_year,
|
||||||
|
duration_seconds: Some(track.duration_seconds.round() as i64),
|
||||||
|
content_id: track.content_id,
|
||||||
|
release_title: Some(track.release_title),
|
||||||
|
track_number: track.track_number,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
embedding_signature: Some(found.embedding_signature),
|
||||||
|
};
|
||||||
|
match hit.validate() {
|
||||||
|
Ok(()) => Some(hit),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::debug!(%err, "invalid local similarity metadata skipped");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
SimilarityResponse::success(hits)?
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
SimilarityResponse::refused(format!("similarity query is unavailable: {err:#}"))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wire::write_response(&mut stream, &response).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
routing: Arc<SimilarityDht>,
|
||||||
|
query: QueryVector,
|
||||||
|
limit: usize,
|
||||||
|
minimum_score: f32,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<FedSimilaritySearchResults> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let own = service.endpoint_id();
|
||||||
|
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 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
|
||||||
|
.connected_peers()
|
||||||
|
.into_iter()
|
||||||
|
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||||
|
{
|
||||||
|
if peer != own && seen.insert(peer) {
|
||||||
|
peers.push(QueryPeer {
|
||||||
|
owner: peer,
|
||||||
|
ticket: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if peers.len() >= MAX_QUERY_PEERS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||||
|
let request = Arc::new(SimilarityRequest::new(
|
||||||
|
query.profile_id,
|
||||||
|
query.vector,
|
||||||
|
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||||
|
)?);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
match response {
|
||||||
|
Ok(peer_hits) => {
|
||||||
|
successful += 1;
|
||||||
|
hits.extend(peer_hits);
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
let mut dedup = HashSet::new();
|
||||||
|
let mut embedding_signatures = vec![query_signature];
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
for (track, score, embedding_signature) in hits {
|
||||||
|
if score < minimum_score {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if query
|
||||||
|
.source_content_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|source| track.content_id.as_deref() == Some(source))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = track
|
||||||
|
.content_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
|
||||||
|
if !dedup.insert(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if embedding_signature.is_some_and(|candidate| {
|
||||||
|
embedding_signatures.iter().any(|existing| {
|
||||||
|
wire::signature_distance(&candidate, existing)
|
||||||
|
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(signature) = embedding_signature {
|
||||||
|
embedding_signatures.push(signature);
|
||||||
|
}
|
||||||
|
tracks.push(ScoredFedTrack {
|
||||||
|
track,
|
||||||
|
score,
|
||||||
|
embedding_signature,
|
||||||
|
});
|
||||||
|
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let artists = tracks
|
||||||
|
.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
peer: QueryPeer,
|
||||||
|
request: &SimilarityRequest,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Result<PeerHits> {
|
||||||
|
let owner = peer.owner;
|
||||||
|
let mut stream = match peer.ticket {
|
||||||
|
Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
|
||||||
|
None => 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);
|
||||||
|
let response = wire::exchange(&mut stream, request).await?;
|
||||||
|
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||||
|
anyhow::ensure!(
|
||||||
|
response.ok,
|
||||||
|
"peer refused similarity query: {}",
|
||||||
|
response.error.unwrap_or_default()
|
||||||
|
);
|
||||||
|
Ok(response
|
||||||
|
.hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|hit| {
|
||||||
|
let score = hit.score;
|
||||||
|
let embedding_signature = hit.embedding_signature;
|
||||||
|
(
|
||||||
|
FedTrack {
|
||||||
|
item_id: hit.item_id,
|
||||||
|
owner: owner.to_string(),
|
||||||
|
own: false,
|
||||||
|
title: hit.title,
|
||||||
|
artist_names: hit.artist_names,
|
||||||
|
featured_artist_names: hit.featured_artist_names,
|
||||||
|
year: hit.year,
|
||||||
|
duration_seconds: hit.duration_seconds,
|
||||||
|
content_id: hit.content_id,
|
||||||
|
release_title: hit.release_title,
|
||||||
|
track_number: hit.track_number,
|
||||||
|
disc_number: hit.disc_number,
|
||||||
|
},
|
||||||
|
score,
|
||||||
|
embedding_signature,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -193,6 +193,27 @@ CREATE INDEX IF NOT EXISTS idx_network_artist_cache_kind
|
|||||||
ON network_artist_cache(source_kind, seen_at_ms);
|
ON network_artist_cache(source_kind, seen_at_ms);
|
||||||
CREATE INDEX IF NOT EXISTS idx_network_artist_cache_artist
|
CREATE INDEX IF NOT EXISTS idx_network_artist_cache_artist
|
||||||
ON network_artist_cache(artist_key);
|
ON network_artist_cache(artist_key);
|
||||||
|
CREATE TABLE IF NOT EXISTS similarity_profiles (
|
||||||
|
profile_id TEXT PRIMARY KEY,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
model_version TEXT NOT NULL,
|
||||||
|
model_sha256 TEXT NOT NULL,
|
||||||
|
preprocessing TEXT NOT NULL,
|
||||||
|
dimensions INTEGER NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS track_embeddings (
|
||||||
|
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
profile_id TEXT NOT NULL REFERENCES similarity_profiles(profile_id) ON DELETE CASCADE,
|
||||||
|
dimensions INTEGER NOT NULL,
|
||||||
|
vector BLOB NOT NULL,
|
||||||
|
routing_signature BLOB,
|
||||||
|
source_content_id TEXT,
|
||||||
|
computed_at_ms INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (track_id, profile_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_track_embeddings_profile
|
||||||
|
ON track_embeddings(profile_id, track_id);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// The SELECT column list every TrackItem row is built from; artist lists
|
/// The SELECT column list every TrackItem row is built from; artist lists
|
||||||
@@ -271,6 +292,32 @@ pub struct NetworkArtistImageRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Minimal durable-track row used by the background embedding pipeline.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SimilarityTrack {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub content_id: Option<String>,
|
||||||
|
pub duration_seconds: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One validated vector loaded from SQLite for the in-memory exact index.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StoredEmbedding {
|
||||||
|
pub track_id: i64,
|
||||||
|
pub vector: Vec<f32>,
|
||||||
|
pub artist_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct SimilarityStorageStats {
|
||||||
|
pub total_tracks: usize,
|
||||||
|
pub embedded_tracks: usize,
|
||||||
|
pub stored_vectors: usize,
|
||||||
|
pub stored_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
@@ -1433,6 +1480,254 @@ impl Library {
|
|||||||
Ok(tracks.pop())
|
Ok(tracks.pop())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Similarity embeddings. SQLite is the canonical store; callers build
|
||||||
|
// replaceable in-memory indexes from these rows.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn ensure_similarity_profile(
|
||||||
|
&self,
|
||||||
|
profile_id: &str,
|
||||||
|
model_id: &str,
|
||||||
|
model_version: &str,
|
||||||
|
model_sha256: &str,
|
||||||
|
preprocessing: &str,
|
||||||
|
dimensions: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO similarity_profiles (
|
||||||
|
profile_id, model_id, model_version, model_sha256,
|
||||||
|
preprocessing, dimensions, created_at_ms
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
ON CONFLICT(profile_id) DO NOTHING",
|
||||||
|
params![
|
||||||
|
profile_id,
|
||||||
|
model_id,
|
||||||
|
model_version,
|
||||||
|
model_sha256,
|
||||||
|
preprocessing,
|
||||||
|
dimensions as i64,
|
||||||
|
now_ms_i64(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_similarity_tracks(&self, profile_id: &str) -> Result<Vec<SimilarityTrack>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT t.id, t.title, t.file_path, t.content_id, t.duration_seconds
|
||||||
|
FROM tracks t
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM track_embeddings e
|
||||||
|
WHERE e.track_id = t.id
|
||||||
|
AND e.profile_id = ?1
|
||||||
|
AND e.source_content_id IS t.content_id
|
||||||
|
)
|
||||||
|
ORDER BY t.id",
|
||||||
|
)?;
|
||||||
|
Ok(statement
|
||||||
|
.query_map([profile_id], |row| {
|
||||||
|
Ok(SimilarityTrack {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
file_path: row.get(2)?,
|
||||||
|
content_id: row.get(3)?,
|
||||||
|
duration_seconds: row.get(4)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_similarity_embedding(
|
||||||
|
&self,
|
||||||
|
track: &SimilarityTrack,
|
||||||
|
profile_id: &str,
|
||||||
|
vector: &[f32],
|
||||||
|
) -> Result<()> {
|
||||||
|
anyhow::ensure!(!vector.is_empty(), "embedding vector is empty");
|
||||||
|
anyhow::ensure!(
|
||||||
|
vector.iter().all(|value| value.is_finite()),
|
||||||
|
"embedding contains a non-finite value"
|
||||||
|
);
|
||||||
|
let bytes = embedding_to_bytes(vector);
|
||||||
|
let routing_signature = music_dht::similarity_lsh::routing_signature(vector)?;
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO track_embeddings (
|
||||||
|
track_id, profile_id, dimensions, vector, routing_signature,
|
||||||
|
source_content_id, computed_at_ms
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
ON CONFLICT(track_id, profile_id) DO UPDATE SET
|
||||||
|
dimensions = excluded.dimensions,
|
||||||
|
vector = excluded.vector,
|
||||||
|
routing_signature = excluded.routing_signature,
|
||||||
|
source_content_id = excluded.source_content_id,
|
||||||
|
computed_at_ms = excluded.computed_at_ms",
|
||||||
|
params![
|
||||||
|
track.id,
|
||||||
|
profile_id,
|
||||||
|
vector.len() as i64,
|
||||||
|
bytes,
|
||||||
|
routing_signature.as_slice(),
|
||||||
|
track.content_id.as_deref(),
|
||||||
|
now_ms_i64(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_embedding(
|
||||||
|
&self,
|
||||||
|
track_id: i64,
|
||||||
|
profile_id: &str,
|
||||||
|
) -> Result<Option<Vec<f32>>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let row = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT e.dimensions, e.vector
|
||||||
|
FROM track_embeddings e
|
||||||
|
JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.track_id = ?1
|
||||||
|
AND e.profile_id = ?2
|
||||||
|
AND e.source_content_id IS t.content_id",
|
||||||
|
params![track_id, profile_id],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
row.map(|(dimensions, bytes)| embedding_from_bytes(dimensions, &bytes))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_similarity_index(&self, profile_id: &str) -> Result<Vec<StoredEmbedding>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT e.track_id, e.dimensions, e.vector,
|
||||||
|
COALESCE((
|
||||||
|
SELECT norm(a.name)
|
||||||
|
FROM track_artists ta
|
||||||
|
JOIN artists a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = e.track_id AND ta.role = 'main'
|
||||||
|
ORDER BY ta.position LIMIT 1
|
||||||
|
), '')
|
||||||
|
FROM track_embeddings e
|
||||||
|
JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.profile_id = ?1
|
||||||
|
AND e.source_content_id IS t.content_id
|
||||||
|
ORDER BY e.track_id",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map([profile_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, i64>(1)?,
|
||||||
|
row.get::<_, Vec<u8>>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut embeddings = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (track_id, dimensions, bytes, artist_key) = row?;
|
||||||
|
embeddings.push(StoredEmbedding {
|
||||||
|
track_id,
|
||||||
|
vector: embedding_from_bytes(dimensions, &bytes)?,
|
||||||
|
artist_key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(embeddings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the compact DHT-routing signatures for every current local
|
||||||
|
/// embedding. Rows created before similarity routing existed are
|
||||||
|
/// backfilled in place from their durable vectors.
|
||||||
|
pub fn similarity_routing_signatures(&self, profile_id: &str) -> Result<Vec<[u8; 32]>> {
|
||||||
|
let mut conn = self.lock();
|
||||||
|
let transaction = conn.transaction()?;
|
||||||
|
let missing = {
|
||||||
|
let mut statement = transaction.prepare(
|
||||||
|
"SELECT e.track_id, e.dimensions, e.vector
|
||||||
|
FROM track_embeddings e
|
||||||
|
JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.profile_id = ?1
|
||||||
|
AND e.source_content_id IS t.content_id
|
||||||
|
AND (e.routing_signature IS NULL OR length(e.routing_signature) != 32)
|
||||||
|
ORDER BY e.track_id",
|
||||||
|
)?;
|
||||||
|
statement
|
||||||
|
.query_map([profile_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, i64>(1)?,
|
||||||
|
row.get::<_, Vec<u8>>(2)?,
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||||
|
};
|
||||||
|
for (track_id, dimensions, vector_bytes) in missing {
|
||||||
|
let vector = embedding_from_bytes(dimensions, &vector_bytes)?;
|
||||||
|
let signature = music_dht::similarity_lsh::routing_signature(&vector)?;
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE track_embeddings
|
||||||
|
SET routing_signature = ?3
|
||||||
|
WHERE track_id = ?1 AND profile_id = ?2",
|
||||||
|
params![track_id, profile_id, signature.as_slice()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
transaction.commit()?;
|
||||||
|
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT e.routing_signature
|
||||||
|
FROM track_embeddings e
|
||||||
|
JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.profile_id = ?1
|
||||||
|
AND e.source_content_id IS t.content_id
|
||||||
|
ORDER BY e.track_id",
|
||||||
|
)?;
|
||||||
|
let stored = statement
|
||||||
|
.query_map([profile_id], |row| row.get::<_, Vec<u8>>(0))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
let signatures = stored
|
||||||
|
.into_iter()
|
||||||
|
.map(|signature| {
|
||||||
|
<[u8; 32]>::try_from(signature)
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid similarity routing signature length"))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
Ok(signatures)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn similarity_storage_stats(&self, profile_id: &str) -> Result<SimilarityStorageStats> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let total_tracks = conn.query_row("SELECT COUNT(*) FROM tracks", [], |row| {
|
||||||
|
row.get::<_, i64>(0)
|
||||||
|
})?;
|
||||||
|
let embedded_tracks = conn.query_row(
|
||||||
|
"SELECT COUNT(*)
|
||||||
|
FROM track_embeddings e JOIN tracks t ON t.id = e.track_id
|
||||||
|
WHERE e.profile_id = ?1 AND e.source_content_id IS t.content_id",
|
||||||
|
[profile_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?;
|
||||||
|
let (stored_vectors, stored_bytes) = conn.query_row(
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(length(vector)), 0) FROM track_embeddings",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
|
)?;
|
||||||
|
Ok(SimilarityStorageStats {
|
||||||
|
total_tracks: total_tracks.max(0) as usize,
|
||||||
|
embedded_tracks: embedded_tracks.max(0) as usize,
|
||||||
|
stored_vectors: stored_vectors.max(0) as usize,
|
||||||
|
stored_bytes: stored_bytes.max(0) as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_similarity_embeddings(&self) -> Result<()> {
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute("DELETE FROM track_embeddings", [])?;
|
||||||
|
conn.execute("DELETE FROM similarity_profiles", [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Playlists & likes
|
// Playlists & likes
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
@@ -3131,6 +3426,32 @@ fn now_ms_i64() -> i64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn embedding_to_bytes(vector: &[f32]) -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::with_capacity(std::mem::size_of_val(vector));
|
||||||
|
for value in vector {
|
||||||
|
bytes.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embedding_from_bytes(dimensions: i64, bytes: &[u8]) -> Result<Vec<f32>> {
|
||||||
|
anyhow::ensure!(dimensions > 0, "stored embedding has invalid dimensions");
|
||||||
|
let dimensions = dimensions as usize;
|
||||||
|
anyhow::ensure!(
|
||||||
|
bytes.len() == dimensions * std::mem::size_of::<f32>(),
|
||||||
|
"stored embedding byte length does not match its dimensions"
|
||||||
|
);
|
||||||
|
let vector: Vec<f32> = bytes
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
|
||||||
|
.collect();
|
||||||
|
anyhow::ensure!(
|
||||||
|
vector.iter().all(|value| value.is_finite()),
|
||||||
|
"stored embedding contains a non-finite value"
|
||||||
|
);
|
||||||
|
Ok(vector)
|
||||||
|
}
|
||||||
|
|
||||||
fn remote_artist_id(artist_key: &str) -> i64 {
|
fn remote_artist_id(artist_key: &str) -> i64 {
|
||||||
let hash = blake3::hash(artist_key.as_bytes());
|
let hash = blake3::hash(artist_key.as_bytes());
|
||||||
let mut bytes = [0u8; 8];
|
let mut bytes = [0u8; 8];
|
||||||
@@ -3186,6 +3507,16 @@ fn ensure_schema_migrations(conn: &Connection) -> Result<()> {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
let embedding_columns = table_columns(conn, "track_embeddings")?;
|
||||||
|
if !embedding_columns
|
||||||
|
.iter()
|
||||||
|
.any(|column| column == "routing_signature")
|
||||||
|
{
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE track_embeddings ADD COLUMN routing_signature BLOB",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
let mut rows = conn.prepare("SELECT id, title FROM playlists WHERE sync_id IS NULL")?;
|
let mut rows = conn.prepare("SELECT id, title FROM playlists WHERE sync_id IS NULL")?;
|
||||||
let missing = rows
|
let missing = rows
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
|
|||||||
@@ -686,3 +686,101 @@ fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() {
|
|||||||
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
|
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
|
||||||
assert!(lib.listen_history(20).unwrap().is_empty());
|
assert!(lib.listen_history(20).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similarity_embeddings_round_trip_and_keep_profiles_separate() {
|
||||||
|
let lib = test_library();
|
||||||
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
|
for profile in ["profile-a", "profile-b"] {
|
||||||
|
lib.ensure_similarity_profile(profile, "model", "1", "sha", "prep", 3)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let track = lib
|
||||||
|
.pending_similarity_tracks("profile-a")
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|track| track.id == track_id)
|
||||||
|
.unwrap();
|
||||||
|
let first = [0.26726124, 0.5345225, 0.8017837];
|
||||||
|
let second = [0.8017837, 0.5345225, 0.26726124];
|
||||||
|
lib.store_similarity_embedding(&track, "profile-a", &first)
|
||||||
|
.unwrap();
|
||||||
|
lib.store_similarity_embedding(&track, "profile-b", &second)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
lib.similarity_embedding(track_id, "profile-a").unwrap(),
|
||||||
|
Some(first.to_vec())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lib.similarity_embedding(track_id, "profile-b").unwrap(),
|
||||||
|
Some(second.to_vec())
|
||||||
|
);
|
||||||
|
let stats = lib.similarity_storage_stats("profile-a").unwrap();
|
||||||
|
assert_eq!(stats.total_tracks, 1);
|
||||||
|
assert_eq!(stats.embedded_tracks, 1);
|
||||||
|
assert_eq!(stats.stored_vectors, 2);
|
||||||
|
assert_eq!(stats.stored_bytes, 24);
|
||||||
|
lib.lock()
|
||||||
|
.execute(
|
||||||
|
"UPDATE track_embeddings SET routing_signature = NULL WHERE profile_id = 'profile-a'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lib.similarity_routing_signatures("profile-a")
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let stored_signature_bytes: i64 = lib
|
||||||
|
.lock()
|
||||||
|
.query_row(
|
||||||
|
"SELECT length(routing_signature) FROM track_embeddings WHERE profile_id = 'profile-a'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored_signature_bytes, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_content_id_invalidates_only_the_stale_embedding() {
|
||||||
|
let lib = test_library();
|
||||||
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
|
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
|
||||||
|
.unwrap();
|
||||||
|
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
|
||||||
|
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
|
||||||
|
.unwrap();
|
||||||
|
lib.lock()
|
||||||
|
.execute(
|
||||||
|
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
|
||||||
|
params![track_id, format!("b3:{}", "f".repeat(64))],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(lib.pending_similarity_tracks("profile").unwrap().len(), 1);
|
||||||
|
assert_eq!(lib.similarity_embedding(track_id, "profile").unwrap(), None);
|
||||||
|
assert!(lib.load_similarity_index("profile").unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_embeddings_preserves_the_library() {
|
||||||
|
let lib = test_library();
|
||||||
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
|
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
|
||||||
|
.unwrap();
|
||||||
|
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
|
||||||
|
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
lib.clear_similarity_embeddings().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(lib.tracks_by_ids(&[track_id]).unwrap().len(), 1);
|
||||||
|
assert!(
|
||||||
|
lib.similarity_embedding(track_id, "profile")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod library;
|
|||||||
mod media;
|
mod media;
|
||||||
mod player;
|
mod player;
|
||||||
mod share;
|
mod share;
|
||||||
|
mod similarity;
|
||||||
mod status;
|
mod status;
|
||||||
mod streaming;
|
mod streaming;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|||||||
+1189
File diff suppressed because it is too large
Load Diff
+187
-70
@@ -7,7 +7,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Paragraph};
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
|
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use crate::app::state::{AppState, DevicePresenceSection, FedRow, settings_rows};
|
use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows};
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
@@ -28,12 +28,12 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
.areas(inner);
|
.areas(inner);
|
||||||
|
|
||||||
draw_settings_rows(frame, rows_area, state);
|
draw_settings_rows(frame, rows_area, state);
|
||||||
draw_status(frame, status_area, state);
|
draw_status_column(frame, status_area, state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows_height =
|
let rows_height =
|
||||||
(settings_rows(state).len() + 8 + device_presence_sections(state).len()) as u16;
|
(settings_rows(state).len() + 10 + device_presence_sections(state).len()) as u16;
|
||||||
let [rows_area, _, status_area] = Layout::vertical([
|
let [rows_area, _, status_area] = Layout::vertical([
|
||||||
Constraint::Length(rows_height.min(inner.height)),
|
Constraint::Length(rows_height.min(inner.height)),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
@@ -42,7 +42,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
.areas(inner);
|
.areas(inner);
|
||||||
|
|
||||||
draw_settings_rows(frame, rows_area, state);
|
draw_settings_rows(frame, rows_area, state);
|
||||||
draw_status(frame, status_area, state);
|
draw_status_column(frame, status_area, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn device_presence_sections(state: &AppState) -> Vec<DevicePresenceSection> {
|
fn device_presence_sections(state: &AppState) -> Vec<DevicePresenceSection> {
|
||||||
@@ -88,6 +88,47 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
|
|
||||||
y = y.saturating_add(1);
|
y = y.saturating_add(1);
|
||||||
|
|
||||||
|
draw_section(frame, area, state, &mut y, "Similarity Search");
|
||||||
|
let similarity = &state.similarity.settings;
|
||||||
|
for row in SimilarityRow::ALL {
|
||||||
|
let (label, value) = match row {
|
||||||
|
SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
|
||||||
|
SimilarityRow::Model => (
|
||||||
|
"Embedding model",
|
||||||
|
crate::similarity::model_by_id(&similarity.model)
|
||||||
|
.map(|model| format!("{} · {}", model.id, model.license))
|
||||||
|
.unwrap_or_else(|| similarity.model.clone()),
|
||||||
|
),
|
||||||
|
SimilarityRow::Profile => (
|
||||||
|
"Preprocessing 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::Clear => ("Clear all stored embeddings", "↵".to_string()),
|
||||||
|
};
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
state,
|
||||||
|
&mut y,
|
||||||
|
cursor,
|
||||||
|
state.settings_cursor,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
|
||||||
draw_section(frame, area, state, &mut y, "Federation");
|
draw_section(frame, area, state, &mut y, "Federation");
|
||||||
for row in FedRow::ALL {
|
for row in FedRow::ALL {
|
||||||
let (label, value) = match row {
|
let (label, value) = match row {
|
||||||
@@ -366,6 +407,8 @@ fn protocol_label(id: &str) -> &str {
|
|||||||
"music_dht" => "Music DHT",
|
"music_dht" => "Music DHT",
|
||||||
"catalog" => "Catalog",
|
"catalog" => "Catalog",
|
||||||
"audio" => "Audio transfer",
|
"audio" => "Audio transfer",
|
||||||
|
"similarity" => "Similarity search",
|
||||||
|
"similarity_dht" => "Similarity DHT",
|
||||||
"device_sync" => "Device sync",
|
"device_sync" => "Device sync",
|
||||||
"jam" => "Jam",
|
"jam" => "Jam",
|
||||||
other => other,
|
other => other,
|
||||||
@@ -505,6 +548,61 @@ fn short_id(id: &str) -> String {
|
|||||||
id.chars().take(12).collect::<String>() + "…"
|
id.chars().take(12).collect::<String>() + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_status_column(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
draw_status(frame, area, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 progress = if status.total_tracks == 0 {
|
||||||
|
"0 / 0".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} / {}", status.completed_tracks, status.total_tracks)
|
||||||
|
};
|
||||||
|
let active = status
|
||||||
|
.active_profile
|
||||||
|
.as_deref()
|
||||||
|
.map(short_id)
|
||||||
|
.unwrap_or_else(|| "not ready".to_string());
|
||||||
|
let target = status
|
||||||
|
.target_profile
|
||||||
|
.as_deref()
|
||||||
|
.map(short_id)
|
||||||
|
.unwrap_or_else(|| "—".to_string());
|
||||||
|
vec![
|
||||||
|
summary_line("State", status.phase.label().to_string()),
|
||||||
|
summary_line("Progress", progress),
|
||||||
|
summary_line("Active", active),
|
||||||
|
summary_line("Processing", target),
|
||||||
|
summary_line(
|
||||||
|
"Stored",
|
||||||
|
format!(
|
||||||
|
"{} vectors / {}",
|
||||||
|
status.stored_vectors,
|
||||||
|
short_bytes_label(status.stored_bytes)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
summary_line(
|
||||||
|
"Current",
|
||||||
|
status
|
||||||
|
.current_track
|
||||||
|
.clone()
|
||||||
|
.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) {
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -519,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 }),
|
||||||
@@ -599,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([
|
||||||
@@ -610,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);
|
||||||
@@ -647,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,
|
||||||
@@ -670,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
|
||||||
|
|||||||
+159
-1
@@ -890,11 +890,19 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
|||||||
|
|
||||||
fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||||
let search = &state.search;
|
let search = &state.search;
|
||||||
let mut title = format!(" Search: {} ", search.query);
|
let prefix = if search.similarity_source.is_some() {
|
||||||
|
"Search similar to"
|
||||||
|
} else {
|
||||||
|
"Search"
|
||||||
|
};
|
||||||
|
let mut title = format!(" {prefix}: {} ", search.query);
|
||||||
if search.loading {
|
if search.loading {
|
||||||
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 {
|
||||||
@@ -1097,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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+70
-3
@@ -25,6 +25,10 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
..
|
..
|
||||||
}) => draw_edit(frame, state, title, fields, *focus, error.as_deref()),
|
}) => draw_edit(frame, state, title, fields, *focus, error.as_deref()),
|
||||||
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, state, label),
|
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, state, label),
|
||||||
|
Some(Popup::SimilarityPrivacyConsent { .. }) => {
|
||||||
|
draw_similarity_privacy_consent(frame, state)
|
||||||
|
}
|
||||||
|
Some(Popup::ConfirmClearEmbeddings) => draw_confirm_clear_embeddings(frame, state),
|
||||||
Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor),
|
Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor),
|
||||||
Some(Popup::TrackInfo {
|
Some(Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -100,6 +104,51 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_similarity_privacy_consent(frame: &mut Frame, state: &AppState) {
|
||||||
|
let area = centered(frame.area(), 78, 11);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Similarity search and federation ")
|
||||||
|
.title_style(theme::header_for(state))
|
||||||
|
.border_style(theme::strong_border_for(state));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::raw("To find similar music on other peers, Furumi sends them an"),
|
||||||
|
Line::raw("anonymous numeric embedding of the selected track."),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::raw("It contains no account identity, but a peer may technically"),
|
||||||
|
Line::raw("infer what kind of music you are searching from it."),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::styled("enter/y: agree and enable · n/esc: cancel", theme::dim()),
|
||||||
|
])
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_confirm_clear_embeddings(frame: &mut Frame, state: &AppState) {
|
||||||
|
let area = centered(frame.area(), 70, 8);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Clear embeddings? ")
|
||||||
|
.title_style(theme::header_for(state))
|
||||||
|
.border_style(theme::strong_border_for(state));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::raw("All model/profile embeddings will be removed from SQLite."),
|
||||||
|
Line::raw("Audio and library metadata stay untouched."),
|
||||||
|
Line::raw("If enabled, processing starts again automatically."),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::styled("enter/y: clear · n/esc: cancel", theme::dim()),
|
||||||
|
]),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_music_directory_confirmation(frame: &mut Frame, state: &AppState, path: &std::path::Path) {
|
fn draw_music_directory_confirmation(frame: &mut Frame, state: &AppState, path: &std::path::Path) {
|
||||||
let area = centered(frame.area(), 76, 9);
|
let area = centered(frame.area(), 76, 9);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
@@ -873,11 +922,19 @@ fn draw_fed_input(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only wrapped text (this peer's federation ticket).
|
/// Read-only wrapped text.
|
||||||
fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) {
|
fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) {
|
||||||
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
||||||
let text_width = usize::from(width.saturating_sub(2));
|
let text_width = usize::from(width.saturating_sub(2));
|
||||||
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
|
let lines_needed = text
|
||||||
|
.lines()
|
||||||
|
.map(|line| {
|
||||||
|
UnicodeWidthStr::width(line)
|
||||||
|
.max(1)
|
||||||
|
.div_ceil(text_width.max(1))
|
||||||
|
})
|
||||||
|
.sum::<usize>()
|
||||||
|
.saturating_add(2) as u16;
|
||||||
let area = centered(
|
let area = centered(
|
||||||
frame.area(),
|
frame.area(),
|
||||||
width,
|
width,
|
||||||
@@ -1281,12 +1338,22 @@ fn draw_track_info(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let can_share = crate::share::track_can_share(track);
|
let can_share = crate::share::track_can_share(track);
|
||||||
let hint = if tracks.len() > 1 && can_share {
|
let can_similar =
|
||||||
|
state.similarity.settings.enabled && track.id >= 0 && !track.file_path.is_empty();
|
||||||
|
let hint = if tracks.len() > 1 && can_share && can_similar {
|
||||||
|
"j/k scroll · h/left previous · l/right next · a artist · s similar · c copy link · esc"
|
||||||
|
} else if tracks.len() > 1 && can_share {
|
||||||
"j/k scroll · h/left previous · l/right next · a artist · c copy frid link · esc close"
|
"j/k scroll · h/left previous · l/right next · a artist · c copy frid link · esc close"
|
||||||
|
} else if tracks.len() > 1 && can_similar {
|
||||||
|
"j/k scroll · h/left previous · l/right next · a artist · s similar · esc close"
|
||||||
} else if tracks.len() > 1 {
|
} else if tracks.len() > 1 {
|
||||||
"j/k scroll · h/left previous · l/right next · a artist · esc close"
|
"j/k scroll · h/left previous · l/right next · a artist · esc close"
|
||||||
|
} else if can_share && can_similar {
|
||||||
|
"j/k scroll · a artist · s similar · c copy link · esc close"
|
||||||
} else if can_share {
|
} else if can_share {
|
||||||
"j/k scroll · a artist · c copy frid link · esc close"
|
"j/k scroll · a artist · c copy frid link · esc close"
|
||||||
|
} else if can_similar {
|
||||||
|
"j/k scroll · a artist · s similar · esc close"
|
||||||
} else {
|
} else {
|
||||||
"j/k scroll · a artist · esc close"
|
"j/k scroll · a artist · esc close"
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user