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
+61
View File
@@ -8,6 +8,7 @@ use crate::app::Runtime;
use crate::app::command::{self, Command, Parsed};
use crate::app::event::AppEvent;
use crate::app::state::{AppState, GlobalView, SearchState, Tab};
use crate::library::models::SearchResults;
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
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,
/// and the receiver drops responses that arrive out of date.
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 query = state.search.query.clone();
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.
///
/// 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
/// responses to be dropped or flicker away.
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();
if query.is_empty() {
return;
+10
View File
@@ -32,6 +32,16 @@ pub enum AppEvent {
seq: u64,
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.
ArtLoaded {
key: String,
+60
View File
@@ -41,6 +41,7 @@ pub struct Runtime {
pub devices: Arc<crate::devices::DeviceSync>,
pub jam: Arc<crate::jam::JamManager>,
pub federation: Arc<crate::federation::Federation>,
pub similarity: Arc<crate::similarity::Manager>,
/// When the last Federation-tab status snapshot was requested.
pub fed_status_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.global.filters = settings.library;
state.music_dir = settings.music_dir.clone();
state.similarity.settings = settings.similarity.clone();
if let Err(err) = state.visualizer.load_library() {
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))?;
devices.set_event_tx(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(
Arc::clone(&library),
Arc::clone(&devices),
Arc::clone(&jam),
Arc::clone(&similarity),
settings.music_dir.clone(),
);
state.music_dir = federation.media_dir();
@@ -292,6 +301,7 @@ pub async fn run(
devices,
jam,
federation,
similarity,
fed_status_at: None,
library_network_refresh_at: None,
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(),
};
spawn_content_id_backfill(&runtime);
if state.similarity.settings.enabled {
runtime.similarity.start();
}
{
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);
});
}
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::FedSyncNow => {
state.federation.publishing = true;
@@ -2785,6 +2807,7 @@ fn save_app_settings(state: &AppState) {
volume: state.player.volume,
library: state.global.filters,
music_dir: state.music_dir.clone(),
similarity: state.similarity.settings.clone(),
};
if let Err(err) = crate::config::settings::save(&settings) {
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),
}
}
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 } => {
let entry = match art {
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 } => {
on_library_changed(state, runtime);
if state.similarity.settings.enabled {
runtime.similarity.start();
}
if let Some(message) = 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 } => {
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::TrackInfo {
tracks,
@@ -544,6 +577,21 @@ fn handle_fed_input(
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 => {
if value.is_empty() {
state.status_message = Some("ticket is empty".into());
@@ -1019,6 +1067,28 @@ fn handle_track_info(
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 {
tracks,
+40
View File
@@ -687,6 +687,10 @@ pub enum Popup {
},
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
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.
LibraryFilters { cursor: usize },
/// Track metadata viewer; left/right switch between selected tracks.
@@ -812,6 +816,7 @@ impl StatusDetailFocus {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedInputField {
MusicDirectory,
SimilarityWorkers,
NetworkId,
ConnectTicket,
DeviceName,
@@ -823,6 +828,7 @@ impl FedInputField {
pub fn title(self) -> &'static str {
match self {
FedInputField::MusicDirectory => "Music save directory",
FedInputField::SimilarityWorkers => "Similarity background workers",
FedInputField::NetworkId => "Network ID",
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
FedInputField::DeviceName => "Device name",
@@ -836,6 +842,9 @@ impl FedInputField {
FedInputField::MusicDirectory => {
"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 => {
"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,
}
#[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 {
pub const ALL: [FedRow; 6] = [
FedRow::Toggle,
@@ -883,6 +911,7 @@ impl FedRow {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
MusicDirectory,
Similarity(SimilarityRow),
Federation(FedRow),
StatusDetails,
DeviceName,
@@ -1021,6 +1050,7 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
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.push(SettingsRow::DeviceName);
rows.push(SettingsRow::DeviceInvite);
@@ -1059,6 +1089,12 @@ pub struct FederationTab {
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
/// through likes, not direct adds).
pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
@@ -1187,6 +1223,9 @@ pub struct SearchState {
/// from the artist names of matching tracks.
pub fed_artists: Vec<crate::federation::FedArtistHit>,
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)]
@@ -1423,6 +1462,7 @@ pub struct AppState {
pub logs: LogsTab,
pub queue_tab: QueueTab,
pub federation: FederationTab,
pub similarity: SimilarityTab,
/// The one federated artist card being viewed (name + loading state);
/// opening another card replaces it.
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.
FedApplySettings,
/// Persist/apply embedding model, profile, worker or enable changes.
SimilarityApplySettings,
SimilarityClear,
/// Force an immediate library publish into the DHT.
FedSyncNow,
/// 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
/// one-shot operations. The heavy lifting happens in perform_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()? {
SettingsRow::MusicDirectory => {
if state.music_dir_changing {
@@ -2735,6 +2738,52 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
});
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) => {
let settings = &mut state.federation.settings;
if !settings.enabled && settings.network_id.trim().is_empty() {
@@ -2744,7 +2793,17 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
});
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)
}
SettingsRow::Federation(FedRow::NetworkId) => {
+58
View File
@@ -45,6 +45,40 @@ pub struct LibraryFilters {
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)]
pub struct AppSettings {
#[serde(default = "default_volume")]
@@ -54,6 +88,8 @@ pub struct AppSettings {
/// Root used for music materialized from federation peers.
#[serde(default = "default_music_dir")]
pub music_dir: PathBuf,
#[serde(default)]
pub similarity: SimilaritySettings,
}
impl Default for AppSettings {
@@ -62,6 +98,7 @@ impl Default for AppSettings {
volume: default_volume(),
library: LibraryFilters::default(),
music_dir: default_music_dir(),
similarity: SimilaritySettings::default(),
}
}
}
@@ -72,6 +109,13 @@ impl AppSettings {
if self.music_dir.as_os_str().is_empty() {
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
}
}
@@ -80,6 +124,20 @@ fn default_volume() -> u8 {
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
/// backward compatibility with existing installations.
pub fn default_music_dir() -> PathBuf {
+1
View File
@@ -170,6 +170,7 @@ mod tests {
"music_dht",
"catalog",
"audio",
"similarity",
"device_sync",
"jam",
] {
+32
View File
@@ -15,6 +15,7 @@
mod audio;
mod capabilities;
pub mod catalog;
mod similarity;
use std::collections::{HashMap, VecDeque};
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 capabilities::ProtocolVersions;
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.
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
@@ -420,6 +422,7 @@ pub struct Federation {
library: Arc<Library>,
devices: Arc<crate::devices::DeviceSync>,
jam: Arc<crate::jam::JamManager>,
similarity: Arc<crate::similarity::Manager>,
data_dir: PathBuf,
cache_dir: PathBuf,
media_dir: std::sync::Mutex<PathBuf>,
@@ -526,6 +529,7 @@ impl Federation {
library: Arc<Library>,
devices: Arc<crate::devices::DeviceSync>,
jam: Arc<crate::jam::JamManager>,
similarity: Arc<crate::similarity::Manager>,
media_dir: PathBuf,
) -> Arc<Self> {
let dirs = crate::config::project_dirs();
@@ -549,6 +553,7 @@ impl Federation {
library,
devices,
jam,
similarity,
data_dir,
cache_dir,
media_dir: std::sync::Mutex::new(media_dir),
@@ -692,6 +697,8 @@ impl Federation {
.stream_protocol(AUDIO_ALPN)
// ...and browse each other's per-artist catalogs over this one.
.stream_protocol(CATALOG_ALPN)
// Anonymous, bounded direct embedding queries.
.stream_protocol(SIMILARITY_ALPN)
// Personal-device sync (likes, playlists, trusted devices).
.stream_protocol(crate::devices::SYNC_ALPN)
// Capability-scoped shared playback control.
@@ -746,6 +753,15 @@ impl Federation {
service.endpoint_id(),
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
.stream_acceptor(crate::devices::SYNC_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
@@ -788,6 +804,7 @@ impl Federation {
sync_task,
audio_task,
catalog_task,
similarity_task,
device_sync_task,
device_tick_task,
jam_serve_task,
@@ -1147,6 +1164,21 @@ impl Federation {
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.
///
/// 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);
CREATE INDEX IF NOT EXISTS idx_network_artist_cache_artist
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
@@ -271,6 +291,32 @@ pub struct NetworkArtistImageRequest {
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 {
conn: Mutex<Connection>,
db_path: PathBuf,
@@ -1433,6 +1479,192 @@ impl Library {
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
// -----------------------------------------------------------------
@@ -3131,6 +3363,32 @@ fn now_ms_i64() -> i64 {
.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 {
let hash = blake3::hash(artist_key.as_bytes());
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.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 player;
mod share;
mod similarity;
mod status;
mod streaming;
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 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) {
let block = Block::bordered()
@@ -28,12 +28,12 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
.areas(inner);
draw_settings_rows(frame, rows_area, state);
draw_status(frame, status_area, state);
draw_status_column(frame, status_area, state);
return;
}
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([
Constraint::Length(rows_height.min(inner.height)),
Constraint::Length(1),
@@ -42,7 +42,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
.areas(inner);
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> {
@@ -88,6 +88,39 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
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");
for row in FedRow::ALL {
let (label, value) = match row {
@@ -366,6 +399,7 @@ fn protocol_label(id: &str) -> &str {
"music_dht" => "Music DHT",
"catalog" => "Catalog",
"audio" => "Audio transfer",
"similarity" => "Similarity search",
"device_sync" => "Device sync",
"jam" => "Jam",
other => other,
@@ -505,6 +539,68 @@ fn short_id(id: &str) -> 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) {
if area.width == 0 || area.height == 0 {
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) {
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 {
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()),
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::TrackInfo {
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) {
let area = centered(frame.area(), 76, 9);
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) {
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
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(
frame.area(),
width,
@@ -1281,12 +1338,22 @@ fn draw_track_info(
);
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"
} 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 {
"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 {
"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 {
"j/k scroll · a artist · esc close"
};