Add federated music similarity search

This commit is contained in:
Aleksandr Bogomiakov
2026-08-10 00:22:20 +01:00
parent c971e81eed
commit 03f74cc91e
22 changed files with 2998 additions and 22 deletions
+28
View File
@@ -282,6 +282,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 +315,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 +347,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;
+26
View File
@@ -7,6 +7,32 @@ 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.
### Changed
- Similarity wire types, bounds, validation, and stream framing now come from
the shared `music-dht 0.3.1` API so native, web, and future clients can
interoperate without sharing an embedding implementation.
- 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.
## [0.2.5] - 2026-08-02 ## [0.2.5] - 2026-08-02
### Added ### Added
Generated
+704 -11
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -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 = { version = "0.3.1", path = "../frid/crates/music-dht" }
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]
+8
View File
@@ -69,6 +69,13 @@ 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.
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 +151,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;
+61
View File
@@ -8,6 +8,7 @@ use crate::app::Runtime;
use crate::app::command::{self, Command, Parsed}; use crate::app::command::{self, Command, Parsed};
use crate::app::event::AppEvent; use crate::app::event::AppEvent;
use crate::app::state::{AppState, GlobalView, SearchState, Tab}; use crate::app::state::{AppState, GlobalView, SearchState, Tab};
use crate::library::models::SearchResults;
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180); const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
const SEARCH_LIMIT: i64 = 12; const SEARCH_LIMIT: i64 = 12;
@@ -97,6 +98,7 @@ 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;
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 +147,62 @@ 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.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;
let source_track = track.clone();
tokio::task::spawn_blocking(move || {
let result = similarity
.search_track(track_id, 49)
.map(|(matches, query)| {
let mut tracks = Vec::with_capacity(1 + matches.len());
tracks.push(source_track);
tracks.extend(matches.into_iter().map(|found| found.track));
(
SearchResults {
artists: Vec::new(),
releases: Vec::new(),
tracks,
},
query,
)
});
let (result, query) = match result {
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 +210,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;
+10
View File
@@ -32,6 +32,16 @@ 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<SearchResults, String>,
query: Option<crate::similarity::QueryVector>,
},
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,
+60
View File
@@ -41,6 +41,7 @@ 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>,
pub library_network_refresh_at: Option<std::time::Instant>, pub library_network_refresh_at: Option<std::time::Instant>,
@@ -262,6 +263,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 +271,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,6 +301,7 @@ pub async fn run(
devices, devices,
jam, jam,
federation, federation,
similarity,
fed_status_at: None, fed_status_at: None,
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)),
@@ -319,6 +329,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 +1594,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;
@@ -2785,6 +2807,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");
@@ -3632,6 +3655,40 @@ 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.results = Some(results),
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::FedSearchLoaded { 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),
@@ -3857,6 +3914,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);
} }
+70
View File
@@ -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,21 @@ 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::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 +1067,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,
+40
View File
@@ -687,6 +687,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 +816,7 @@ impl StatusDetailFocus {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedInputField { pub enum FedInputField {
MusicDirectory, MusicDirectory,
SimilarityWorkers,
NetworkId, NetworkId,
ConnectTicket, ConnectTicket,
DeviceName, DeviceName,
@@ -823,6 +828,7 @@ 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::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 +842,9 @@ 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::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 +875,25 @@ pub enum FedRow {
Connect, Connect,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SimilarityRow {
Toggle,
Model,
Profile,
Workers,
Clear,
}
impl SimilarityRow {
pub const ALL: [SimilarityRow; 5] = [
SimilarityRow::Toggle,
SimilarityRow::Model,
SimilarityRow::Profile,
SimilarityRow::Workers,
SimilarityRow::Clear,
];
}
impl FedRow { impl FedRow {
pub const ALL: [FedRow; 6] = [ pub const ALL: [FedRow; 6] = [
FedRow::Toggle, FedRow::Toggle,
@@ -883,6 +911,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 +1050,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 +1089,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)> {
@@ -1187,6 +1223,9 @@ 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>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -1423,6 +1462,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>)>,
+61 -2
View File
@@ -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.
@@ -2720,7 +2723,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 +2738,52 @@ 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::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 +2793,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) => {
+58
View File
@@ -45,6 +45,40 @@ pub struct LibraryFilters {
pub source_mode: LibrarySourceMode, pub source_mode: LibrarySourceMode,
} }
#[derive(Debug, Clone, PartialEq, Eq, 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,
#[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(),
federation_consent: false,
active_profile: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSettings { pub struct AppSettings {
#[serde(default = "default_volume")] #[serde(default = "default_volume")]
@@ -54,6 +88,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 +98,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 +109,13 @@ 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);
self self
} }
} }
@@ -80,6 +124,20 @@ 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)
}
/// 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 {
+1
View File
@@ -170,6 +170,7 @@ mod tests {
"music_dht", "music_dht",
"catalog", "catalog",
"audio", "audio",
"similarity",
"device_sync", "device_sync",
"jam", "jam",
] { ] {
+32
View File
@@ -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};
@@ -39,6 +40,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);
@@ -420,6 +422,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 +529,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 +553,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,6 +697,8 @@ 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.
.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.
@@ -746,6 +753,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}"))?;
@@ -788,6 +804,7 @@ 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,
@@ -1147,6 +1164,21 @@ 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<FedSearchResults> {
anyhow::ensure!(
self.similarity.network_allowed(),
"similarity federation has no consent"
);
let service = self.service().await?;
similarity::search(service, query, limit, 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,
+277
View File
@@ -0,0 +1,277 @@
//! 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::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
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::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
use crate::federation::{FedSearchResults, FedTrack, TransportStats};
use crate::similarity::{Manager, QueryVector};
pub use music_dht::similarity::SIMILARITY_ALPN;
const MAX_QUERY_PEERS: usize = 16;
const QUERY_CONCURRENCY: usize = 6;
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_PER_ARTIST: usize = 3;
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(&profile, &vector, None, None, 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>,
query: QueryVector,
limit: usize,
transport: Arc<TransportStats>,
) -> Result<FedSearchResults> {
let own = service.endpoint_id();
let mut peers = Vec::new();
let mut seen = HashSet::new();
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(peer);
}
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 responses = stream::iter(peers.into_iter().map(|peer| {
let service = Arc::clone(&service);
let request = Arc::clone(&request);
let transport = Arc::clone(&transport);
async move {
tokio::time::timeout(
QUERY_TIMEOUT,
query_peer(service, peer, &request, transport),
)
.await
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
}
}))
.buffer_unordered(QUERY_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut hits = Vec::new();
for response in responses {
match response {
Ok(peer_hits) => hits.extend(peer_hits),
Err(err) => tracing::debug!(%err, "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 artist_counts: HashMap<String, usize> = HashMap::new();
let mut tracks = Vec::new();
for (track, _, embedding_signature) in hits {
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;
}
let artist = track
.artist_names
.first()
.map(|name| music_dht::normalize_name(name))
.unwrap_or_default();
let count = artist_counts.entry(artist.clone()).or_default();
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
continue;
}
*count += 1;
if let Some(signature) = embedding_signature {
embedding_signatures.push(signature);
}
tracks.push(track);
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
break;
}
}
Ok(FedSearchResults {
artists: Vec::new(),
tracks,
})
}
async fn query_peer(
service: Arc<MusicDhtService>,
owner: EndpointId,
request: &SimilarityRequest,
transport: Arc<TransportStats>,
) -> Result<
Vec<(
FedTrack,
f32,
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
)>,
> {
let mut stream = service
.open_stream(owner, SIMILARITY_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach similarity peer: {err}"))?;
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
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())
}
+258
View File
@@ -193,6 +193,26 @@ 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,
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 +291,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 +1479,192 @@ 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 conn = self.lock();
conn.execute(
"INSERT INTO track_embeddings (
track_id, profile_id, dimensions, vector,
source_content_id, computed_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(track_id, profile_id) DO UPDATE SET
dimensions = excluded.dimensions,
vector = excluded.vector,
source_content_id = excluded.source_content_id,
computed_at_ms = excluded.computed_at_ms",
params![
track.id,
profile_id,
vector.len() as i64,
bytes,
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)
}
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 +3363,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];
+75
View File
@@ -686,3 +686,78 @@ 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();
lib.store_similarity_embedding(&track, "profile-a", &[0.1, 0.2, 0.3])
.unwrap();
lib.store_similarity_embedding(&track, "profile-b", &[0.3, 0.2, 0.1])
.unwrap();
assert_eq!(
lib.similarity_embedding(track_id, "profile-a").unwrap(),
Some(vec![0.1, 0.2, 0.3])
);
assert_eq!(
lib.similarity_embedding(track_id, "profile-b").unwrap(),
Some(vec![0.3, 0.2, 0.1])
);
let stats = lib.similarity_storage_stats("profile-a").unwrap();
assert_eq!(stats.total_tracks, 1);
assert_eq!(stats.embedded_tracks, 1);
assert_eq!(stats.stored_vectors, 2);
assert_eq!(stats.stored_bytes, 24);
}
#[test]
fn changed_content_id_invalidates_only_the_stale_embedding() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
.unwrap();
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
.unwrap();
lib.lock()
.execute(
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
params![track_id, format!("b3:{}", "f".repeat(64))],
)
.unwrap();
assert_eq!(lib.pending_similarity_tracks("profile").unwrap().len(), 1);
assert_eq!(lib.similarity_embedding(track_id, "profile").unwrap(), None);
assert!(lib.load_similarity_index("profile").unwrap().is_empty());
}
#[test]
fn clearing_embeddings_preserves_the_library() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
.unwrap();
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
.unwrap();
lib.clear_similarity_embeddings().unwrap();
assert_eq!(lib.tracks_by_ids(&[track_id]).unwrap().len(), 1);
assert!(
lib.similarity_embedding(track_id, "profile")
.unwrap()
.is_none()
);
}
+1
View File
@@ -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;
+1047
View File
File diff suppressed because it is too large Load Diff
+100 -4
View File
@@ -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,39 @@ 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::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 +399,7 @@ 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",
"device_sync" => "Device sync", "device_sync" => "Device sync",
"jam" => "Jam", "jam" => "Jam",
other => other, other => other,
@@ -505,6 +539,68 @@ 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) {
if area.height < 12 {
draw_status(frame, area, state);
return;
}
let [similarity_area, _, federation_area] = Layout::vertical([
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(area);
draw_similarity_status(frame, similarity_area, state);
draw_status(frame, federation_area, state);
}
fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
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());
draw_summary_card(
frame,
area,
state,
" Similarity Processing ",
vec![
status_line("State", status.phase.label().to_string()),
status_line("Progress", progress),
status_line("Active", active),
status_line("Processing", target),
status_line(
"Stored",
format!(
"{} vectors / {}",
status.stored_vectors,
short_bytes_label(status.stored_bytes)
),
),
status_line(
"Current / errors",
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;
+6 -1
View File
@@ -890,7 +890,12 @@ 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… ");
} }
+70 -3
View File
@@ -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"
}; };