Added similarity search
Build and Publish / Build and Publish Docker Image (push) Successful in 3m29s

This commit is contained in:
Aleksandr Bogomiakov
2026-08-10 01:44:40 +01:00
parent 05d68724f3
commit 77cbb62d84
22 changed files with 3092 additions and 17 deletions
+14
View File
@@ -422,6 +422,20 @@ impl App for AdminApp {
}),
"admin_v2_federation_status",
),
Route::with_handler_and_name(
"/v2/api/similarity",
get(move |session: Session, db: Database| async move {
v2::similarity_status(session, db).await
}),
"admin_v2_similarity_status",
),
Route::with_handler_and_name(
"/v2/api/similarity/clear",
cot::router::method::post(move |session: Session, db: Database| async move {
v2::similarity_clear(session, db).await
}),
"admin_v2_similarity_clear",
),
Route::with_handler_and_name(
"/v2/api/federation/sync",
cot::router::method::post(move |session: Session, db: Database| async move {
+119
View File
@@ -454,6 +454,14 @@ struct AdminSettingsValues {
federation_network_id: String,
#[serde(default)]
federation_save_on_listen: bool,
#[serde(default)]
similarity_enabled: bool,
#[serde(default = "default_similarity_model")]
similarity_model: String,
#[serde(default = "default_similarity_profile")]
similarity_profile: String,
#[serde(default = "default_similarity_workers")]
similarity_workers: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
@@ -481,6 +489,10 @@ struct AdminSettingsSources {
federation_enabled: &'static str,
federation_network_id: &'static str,
federation_save_on_listen: &'static str,
similarity_enabled: &'static str,
similarity_model: &'static str,
similarity_profile: &'static str,
similarity_workers: &'static str,
}
#[derive(Debug, Deserialize)]
@@ -511,6 +523,26 @@ pub(super) struct UpdateSettingsRequest {
federation_network_id: String,
#[serde(default)]
federation_save_on_listen: bool,
#[serde(default)]
similarity_enabled: bool,
#[serde(default = "default_similarity_model")]
similarity_model: String,
#[serde(default = "default_similarity_profile")]
similarity_profile: String,
#[serde(default = "default_similarity_workers")]
similarity_workers: String,
}
fn default_similarity_model() -> String {
crate::similarity::DEFAULT_MODEL_ID.to_owned()
}
fn default_similarity_profile() -> String {
crate::similarity::DEFAULT_PROFILE_ID.to_owned()
}
fn default_similarity_workers() -> String {
"1".to_owned()
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -948,6 +980,29 @@ pub async fn update_settings(
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let similarity_model = body.similarity_model.trim();
if crate::similarity::model_by_id(similarity_model).is_none() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"unknown similarity model",
));
}
let similarity_profile = body.similarity_profile.trim();
if crate::similarity::profile_by_id(similarity_profile).is_none() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"unknown similarity preprocessing profile",
));
}
let similarity_workers = match body.similarity_workers.trim().parse::<u64>() {
Ok(workers @ 1..=16) => workers,
_ => {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"similarity workers must be an integer from 1 to 16",
));
}
};
let fields = [
(
"auth_password_enabled",
@@ -1002,6 +1057,10 @@ pub async fn update_settings(
"federation_save_on_listen",
body.federation_save_on_listen.to_string(),
),
("similarity_enabled", body.similarity_enabled.to_string()),
("similarity_model", similarity_model.to_string()),
("similarity_profile", similarity_profile.to_string()),
("similarity_workers", similarity_workers.to_string()),
];
for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_string(), value);
@@ -1014,6 +1073,7 @@ pub async fn update_settings(
// the freshly saved settings — no server restart involved.
let (fresh, _) = AppConfig::load_with_db(&db).await;
tokio::spawn(async move {
crate::similarity::handle().apply(&fresh);
crate::federation::handle().apply(&fresh).await;
});
Json(serde_json::json!({ "ok": true })).into_response()
@@ -1033,6 +1093,57 @@ pub async fn federation_status(
Json(crate::federation::handle().status().await).into_response()
}
pub async fn similarity_status(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let manager = crate::similarity::handle();
let status = manager.status();
let model_id = if status.model.is_empty() {
crate::similarity::DEFAULT_MODEL_ID
} else {
&status.model
};
let profiles = crate::similarity::PROFILES
.iter()
.map(|profile| {
serde_json::json!({
"id": profile.id,
"title": profile.title,
"details": crate::similarity::profile_details(
profile.id,
model_id,
).unwrap_or_default(),
})
})
.collect::<Vec<_>>();
Json(serde_json::json!({
"status": status,
"models": crate::similarity::MODELS,
"profiles": profiles,
}))
.into_response()
}
pub async fn similarity_clear(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
match crate::similarity::handle().clear().await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(error) => Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("embedding cleanup failed: {error:#}"),
)),
}
}
pub async fn federation_sync(
session: Session,
db: Database,
@@ -1153,6 +1264,10 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
federation_enabled: config.federation_enabled,
federation_network_id: config.federation_network_id,
federation_save_on_listen: config.federation_save_on_listen,
similarity_enabled: config.similarity_enabled,
similarity_model: config.similarity_model,
similarity_profile: config.similarity_profile,
similarity_workers: config.similarity_workers.to_string(),
},
sources: AdminSettingsSources {
auth_password_enabled: sources.auth_password_enabled.code(),
@@ -1178,6 +1293,10 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
federation_enabled: sources.federation_enabled.code(),
federation_network_id: sources.federation_network_id.code(),
federation_save_on_listen: sources.federation_save_on_listen.code(),
similarity_enabled: sources.similarity_enabled.code(),
similarity_model: sources.similarity_model.code(),
similarity_profile: sources.similarity_profile.code(),
similarity_workers: sources.similarity_workers.code(),
},
}
}
+37
View File
@@ -138,6 +138,10 @@ pub struct ConfigSources {
pub federation_enabled: ConfigSource,
pub federation_network_id: ConfigSource,
pub federation_save_on_listen: ConfigSource,
pub similarity_enabled: ConfigSource,
pub similarity_model: ConfigSource,
pub similarity_profile: ConfigSource,
pub similarity_workers: ConfigSource,
}
impl Default for ConfigSources {
@@ -168,6 +172,10 @@ impl Default for ConfigSources {
federation_enabled: ConfigSource::Default,
federation_network_id: ConfigSource::Default,
federation_save_on_listen: ConfigSource::Default,
similarity_enabled: ConfigSource::Default,
similarity_model: ConfigSource::Default,
similarity_profile: ConfigSource::Default,
similarity_workers: ConfigSource::Default,
}
}
}
@@ -285,6 +293,14 @@ pub struct AppConfig {
/// Whether a federated track requested for playback is imported into the
/// shared local library. This is a server-wide administrator policy.
pub federation_save_on_listen: bool,
/// Whether local embedding calculation and similarity search are enabled.
pub similarity_enabled: bool,
/// Embedding model selected by the administrator.
pub similarity_model: String,
/// Audio preprocessing profile selected by the administrator.
pub similarity_profile: String,
/// Maximum number of concurrent CPU embedding workers.
pub similarity_workers: u64,
}
impl Default for AppConfig {
@@ -315,6 +331,12 @@ impl Default for AppConfig {
federation_enabled: false,
federation_network_id: String::new(),
federation_save_on_listen: false,
similarity_enabled: false,
similarity_model: "discogs-effnet-bsdynamic-1".into(),
similarity_profile: "furumi-full-track-v1".into(),
similarity_workers: std::thread::available_parallelism()
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
.unwrap_or(1),
}
}
}
@@ -346,6 +368,10 @@ impl_env_overrides!(
federation_enabled,
federation_network_id,
federation_save_on_listen,
similarity_enabled,
similarity_model,
similarity_profile,
similarity_workers,
);
impl AppConfig {
@@ -476,6 +502,10 @@ impl AppConfig {
apply_db_field!(federation_enabled);
apply_db_field!(federation_network_id);
apply_db_field!(federation_save_on_listen);
apply_db_field!(similarity_enabled);
apply_db_field!(similarity_model);
apply_db_field!(similarity_profile);
apply_db_field!(similarity_workers);
}
}
@@ -495,6 +525,13 @@ mod tests {
let cfg = AppConfig::default();
assert!(cfg.database_url.is_empty());
assert_eq!(cfg.log_level, "info");
assert!(!cfg.similarity_enabled);
assert_eq!(cfg.similarity_model, crate::similarity::DEFAULT_MODEL_ID);
assert_eq!(
cfg.similarity_profile,
crate::similarity::DEFAULT_PROFILE_ID
);
assert!((1..=4).contains(&cfg.similarity_workers));
}
#[test]
+10 -2
View File
@@ -5,8 +5,8 @@ use std::time::Duration;
use anyhow::Result;
use music_dht::StreamAcceptor;
use music_dht::capabilities::{
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, read_message,
write_message,
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, SIMILARITY_ID,
read_message, write_message,
};
use super::serve::AUDIO_PROTOCOL_VERSION;
@@ -16,6 +16,10 @@ fn local_manifest() -> CapabilityManifest {
// The web server does not expose federation Jam yet.
.without_protocol(JAM_ID)
.with_protocol("audio", AUDIO_PROTOCOL_VERSION)
.with_protocol(
SIMILARITY_ID,
music_dht::similarity::SIMILARITY_PROTOCOL_VERSION,
)
}
pub async fn serve(mut acceptor: StreamAcceptor) {
@@ -61,6 +65,10 @@ mod tests {
Some(&AUDIO_PROTOCOL_VERSION)
);
assert!(!manifest.protocols.contains_key(JAM_ID));
assert_eq!(
manifest.protocols.get(SIMILARITY_ID),
Some(&music_dht::similarity::SIMILARITY_PROTOCOL_VERSION)
);
manifest.validate().unwrap();
}
}
+60
View File
@@ -98,6 +98,66 @@ pub struct SearchEvent {
}
impl Federation {
pub async fn prepare_similarity_tracks(
&self,
tracks: Vec<super::similarity::RemoteSimilarityTrack>,
) -> Result<Vec<TrackDto>> {
let pool = self.pool().await?;
let mut prepared = Vec::new();
for track in tracks {
let Some(content_id) = track.content_id.as_deref().and_then(normalize_content_id)
else {
continue;
};
let local = local_availability(&pool, &content_id).await?;
// A local result is already present in the first result section.
if local.is_some() {
continue;
}
let owner = track.owner;
let item_id = track.item_id;
let dto = TrackDto {
key: TrackKeyDto {
content_id: content_id.clone(),
},
metadata: TrackMetadataDto {
title: track.title,
artists: artist_refs(&track.artist_names),
featured_artists: artist_refs(&track.featured_artist_names),
release: track.release_title.map(|title| ReleaseRefDto {
key: ReleaseKeyDto {
normalized_title: music_dht::normalize_name(&title),
primary_artists: track
.artist_names
.iter()
.map(|artist| music_dht::normalize_name(artist))
.collect(),
release_type: None,
year: track.year,
},
local_id: None,
title,
}),
year: track.year,
duration_seconds: track.duration_seconds.map(|value| value as f64),
track_number: track.track_number,
disc_number: track.disc_number,
cover_url: Some(format!(
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
)),
},
availability: TrackAvailabilityDto {
state: "federated",
local: None,
federation: vec![FederationSourceDto { owner, item_id }],
},
};
persist_track_ref(&pool, &dto).await?;
prepared.push(dto);
}
Ok(prepared)
}
pub fn stream_artist_catalogs(
self: &std::sync::Arc<Self>,
name: String,
+35 -4
View File
@@ -5,18 +5,21 @@
//! releases, tracks — names and small metadata, never files) into the
//! shared DHT and serves audio, track metadata, cover art and per-artist
//! catalogs to other peers (TUI clients) over the same wire protocols the
//! clients speak among themselves. Serve-only: the server does not search
//! or download from other peers.
//! clients speak among themselves. The web player also searches known peers
//! for catalog metadata and, when enabled, compatible similarity embeddings;
//! local playback and the local library remain independent of the network.
//!
//! Settings are the regular admin config entries (`federation_enabled`,
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
//! starts, stops or re-joins the node without a server restart.
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly —
//! saving the settings starts, stops or re-joins the node without a server
//! restart.
mod capabilities;
pub mod client;
pub mod devices;
mod receive;
mod serve;
mod similarity;
mod storage;
use std::collections::{HashMap, HashSet, VecDeque};
@@ -38,6 +41,7 @@ use crate::config::AppConfig;
use storage::PostgresFederationStorage;
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
pub use similarity::SIMILARITY_ALPN;
/// How often the published library is re-synchronized with the database.
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
@@ -124,6 +128,7 @@ struct TransportStatsState {
audio_samples: u64,
catalog_samples: u64,
sync_samples: u64,
similarity_samples: u64,
last: VecDeque<TransportSample>,
}
@@ -157,6 +162,7 @@ impl TransportStats {
"audio" => state.audio_samples += 1,
"catalog" => state.catalog_samples += 1,
"device-sync" => state.sync_samples += 1,
"similarity" => state.similarity_samples += 1,
_ => {}
}
state.last.push_front(sample);
@@ -177,6 +183,7 @@ impl TransportStats {
"audio_samples": state.audio_samples,
"catalog_samples": state.catalog_samples,
"sync_samples": state.sync_samples,
"similarity_samples": state.similarity_samples,
"last_path": latest.map(|sample| sample.selected_path.clone()),
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
"last_peer": latest.map(|sample| sample.peer_id.clone()),
@@ -382,6 +389,7 @@ impl Federation {
.stream_protocol(AUDIO_ALPN)
.stream_protocol(CATALOG_ALPN)
.stream_protocol(devices::SYNC_ALPN)
.stream_protocol(SIMILARITY_ALPN)
.schema_independent_stream_protocol(CAPABILITIES_ALPN)
.build()
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
@@ -454,6 +462,15 @@ impl Federation {
.stream_acceptor(CAPABILITIES_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take the capabilities acceptor: {err}"))?;
let capabilities_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
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,
crate::similarity::handle(),
service.endpoint_id(),
Arc::clone(&self.transport_stats),
));
*guard = Some(Running {
service,
@@ -466,6 +483,7 @@ impl Federation {
device_task,
device_sync_task,
capabilities_task,
similarity_task,
],
});
self.set_error(None);
@@ -827,6 +845,19 @@ impl Federation {
Ok(peer.to_string())
}
pub async fn search_similarity(
&self,
query: crate::similarity::QueryVector,
limit: usize,
) -> Result<Vec<similarity::RemoteSimilarityTrack>> {
anyhow::ensure!(
crate::similarity::handle().enabled(),
"similarity search is disabled"
);
let service = self.service().await?;
similarity::search(service, query, limit, Arc::clone(&self.transport_stats)).await
}
pub async fn fed_device_status(
&self,
user_id: i64,
+310
View File
@@ -0,0 +1,310 @@
//! Furumusic policy and PostgreSQL adapter for the shared similarity protocol.
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::similarity::{Manager, QueryVector};
use super::TransportStats;
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;
#[derive(Debug, Clone)]
pub struct RemoteSimilarityTrack {
pub owner: String,
pub item_id: String,
pub title: String,
pub artist_names: Vec<String>,
pub featured_artist_names: Vec<String>,
pub year: Option<i32>,
pub duration_seconds: Option<i64>,
pub content_id: Option<String>,
pub release_title: Option<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
}
pub async fn serve_peers(
mut acceptor: StreamAcceptor,
manager: Arc<Manager>,
own: EndpointId,
transport: Arc<TransportStats>,
) {
while let Some(stream) = acceptor.accept().await {
let manager = Arc::clone(&manager);
let transport = Arc::clone(&transport);
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(error) = serve_one(stream, manager, own, transport).await {
tracing::warn!(peer = %peer, "similarity request failed: {error:#}");
}
});
}
}
async fn serve_one(
mut stream: ByteStream,
manager: 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 !manager.enabled() {
SimilarityResponse::refused("similarity search is disabled on this instance")?
} else {
let profile_id = request.profile_id;
let vector = request.vector;
let limit = request.limit;
let rank_manager = Arc::clone(&manager);
let ranked = tokio::task::spawn_blocking(move || {
rank_manager.rank_vector(&profile_id, &vector, None, None, limit)
})
.await
.context("local similarity task failed")
.and_then(|result| result);
match ranked {
Ok(ranked) => {
let ids = ranked
.iter()
.map(|track| track.track_id)
.collect::<Vec<_>>();
match manager.metadata_for_tracks(&ids).await {
Ok(metadata) => {
let by_id = ranked
.into_iter()
.map(|track| (track.track_id, track))
.collect::<HashMap<_, _>>();
let hits = metadata
.into_iter()
.filter_map(|track| {
let ranked = by_id.get(&track.track_id)?;
let hit = SimilarityHit {
score: ranked.score,
item_id: hex(
ItemId::derive(
&own,
ItemKind::Track,
&format!("track:{}", track.track_id),
)
.as_bytes(),
),
title: track.title,
artist_names: track.artist_names,
featured_artist_names: track.featured_artist_names,
year: track.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(ranked.embedding_signature),
};
match hit.validate() {
Ok(()) => Some(hit),
Err(error) => {
tracing::debug!(%error, "invalid local similarity metadata skipped");
None
}
}
})
.collect();
SimilarityResponse::success(hits)?
}
Err(error) => SimilarityResponse::refused(format!(
"similarity metadata is unavailable: {error:#}"
))?,
}
}
Err(error) => {
SimilarityResponse::refused(format!("similarity query is unavailable: {error:#}"))?
}
}
};
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<Vec<RemoteSimilarityTrack>> {
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(error) => tracing::debug!(%error, "similarity peer query skipped"),
}
}
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
let mut dedup = HashSet::new();
let mut signatures = vec![query_signature];
let mut artist_counts: HashMap<String, usize> = HashMap::new();
let mut tracks = Vec::new();
for (track, _, 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 signature.is_some_and(|candidate| {
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) = signature {
signatures.push(signature);
}
tracks.push(track);
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
break;
}
}
Ok(tracks)
}
async fn query_peer(
service: Arc<MusicDhtService>,
owner: EndpointId,
request: &SimilarityRequest,
transport: Arc<TransportStats>,
) -> Result<
Vec<(
RemoteSimilarityTrack,
f32,
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
)>,
> {
let mut stream = service
.open_stream(owner, SIMILARITY_ALPN)
.await
.map_err(|error| anyhow::anyhow!("cannot reach similarity peer: {error}"))?;
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 signature = hit.embedding_signature;
(
RemoteSimilarityTrack {
owner: owner.to_string(),
item_id: hit.item_id,
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,
signature,
)
})
.collect())
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hamming_threshold_keeps_exact_and_near_duplicates_out() {
let query = [0u8; wire::SIMILARITY_SIGNATURE_BYTES];
let mut near = query;
near[0] = 0b0000_0111;
assert!(wire::signature_distance(&query, &near) <= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE);
}
}
+6
View File
@@ -298,6 +298,9 @@ translations! {
player_likes_playlist: "Likes" , "Лайки";
player_listened: "listened" , "прослушано";
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
player_search_similar_to: "Search similar to:" , "Поиск похожих на:";
player_find_similar: "Find similar tracks" , "Найти похожие треки";
player_similarity_failed: "Similarity search failed" , "Не удалось найти похожие треки";
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
player_active_device: "Active device" , "Активный девайс";
@@ -314,6 +317,9 @@ translations! {
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
player_rename: "Rename" , "Переименовать";
player_close: "Close" , "Закрыть";
player_interface_language: "Interface language" , "Язык интерфейса";
player_language_description: "Choose the language used by the web player." , "Выберите язык интерфейса веб-плеера.";
player_switch_language: "Русский" , "English";
player_log_out: "Log out" , "Выйти";
player_admin_panel: "Admin Panel" , "Админка";
player_info: "Info" , "Информация";
+8
View File
@@ -13,6 +13,7 @@ mod music;
mod oidc;
mod player;
mod scheduler;
mod similarity;
mod torrents;
mod user;
@@ -567,6 +568,13 @@ impl Project for FuruProject {
federation::handle().boot(&fed_config).await;
});
// Embedding calculation is an independent, server-wide background
// service. It remains useful locally when federation is disabled.
let similarity_config = Arc::clone(&self.app_config);
tokio::spawn(async move {
similarity::handle().boot(&similarity_config).await;
});
apps.register(cot::session::db::SessionApp::new());
apps.register_with_views(
FuruApp {
+66
View File
@@ -2476,6 +2476,71 @@ pub mod db_migrations {
&[Operation::custom(repair_legacy_listen_qualification).build()];
}
#[cot::db::migrations::migration_op]
async fn create_similarity_embeddings(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__similarity_profile (
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,
active BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL
)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_similarity_profile_active
ON furumusic__similarity_profile (active)
WHERE active = TRUE",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__track_embedding (
track_id BIGINT NOT NULL REFERENCES furumusic__track(id)
ON DELETE CASCADE,
profile_id TEXT NOT NULL REFERENCES furumusic__similarity_profile(profile_id)
ON DELETE CASCADE,
dimensions INTEGER NOT NULL,
vector BYTEA NOT NULL,
source_sha256 TEXT NOT NULL,
source_content_id TEXT,
computed_at TEXT NOT NULL,
PRIMARY KEY (track_id, profile_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_track_embedding_profile
ON furumusic__track_embedding (profile_id, track_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0043CreateSimilarityEmbeddings;
impl migrations::Migration for M0043CreateSimilarityEmbeddings {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0043_create_similarity_embeddings";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0042_repair_legacy_listen_qualification",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_similarity_embeddings).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -2509,5 +2574,6 @@ pub mod db_migrations {
&M0040CreateContentAddressedMusicRefs,
&M0041CreateSyncedListenHistory,
&M0042RepairLegacyListenQualification,
&M0043CreateSimilarityEmbeddings,
];
}
+140
View File
@@ -14,6 +14,7 @@ use cot::router::method::{delete, get, post};
use cot::router::{Route, Router};
use cot::session::Session;
use cot::{App, Body, Template};
use serde::Serialize;
use sqlx::Row as _;
use crate::auth;
@@ -4314,6 +4315,118 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul
.collect())
}
#[derive(Debug, Serialize)]
struct SimilaritySearchResponse {
label: String,
tracks: Vec<TrackItem>,
federation_tracks: Vec<crate::federation::client::TrackDto>,
federation_error: Option<String>,
}
async fn similarity_search_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
pool: &sqlx::PgPool,
Path(path): Path<PathId>,
) -> cot::Result<cot::response::Response> {
let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
if path.id <= 0 {
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid track id"));
}
let mut source = load_track_items_by_ids(pool, &[path.id]).await?;
let Some(source_track) = source.pop() else {
return Ok(json_error(StatusCode::NOT_FOUND, "local track not found"));
};
let manager = crate::similarity::handle();
let query = match manager.query_for_track(path.id).await {
Ok(query) => query,
Err(error) => {
return Ok(json_error(
StatusCode::SERVICE_UNAVAILABLE,
&format!("similarity search is not ready: {error:#}"),
));
}
};
let rank_manager = std::sync::Arc::clone(&manager);
let profile_id = query.profile_id.clone();
let vector = query.vector.clone();
let source_content_id = query.source_content_id.clone();
let ranked = match tokio::task::spawn_blocking(move || {
rank_manager.rank_vector(
&profile_id,
&vector,
Some(path.id),
source_content_id.as_deref(),
49,
)
})
.await
{
Ok(Ok(ranked)) => ranked,
Ok(Err(error)) => {
return Ok(json_error(
StatusCode::SERVICE_UNAVAILABLE,
&format!("similarity search failed: {error:#}"),
));
}
Err(error) => {
return Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("similarity worker failed: {error}"),
));
}
};
let ids = ranked
.iter()
.map(|track| track.track_id)
.collect::<Vec<_>>();
let mut tracks = Vec::with_capacity(ids.len() + 1);
tracks.push(source_track.clone());
tracks.extend(load_track_items_by_ids(pool, &ids).await?);
let (config, _) = AppConfig::load_with_db(&db).await;
let (federation_tracks, federation_error) = if config.federation_enabled {
match crate::federation::handle()
.search_similarity(query, 50)
.await
{
Ok(remote) => match crate::federation::handle()
.prepare_similarity_tracks(remote)
.await
{
Ok(tracks) => (tracks, None),
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
},
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
}
} else {
(Vec::new(), None)
};
let artists = source_track
.artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let label = if artists.is_empty() {
source_track.title.clone()
} else {
format!("{}{artists}", source_track.title)
};
Json(SimilaritySearchResponse {
label,
tracks,
federation_tracks,
federation_error,
})
.into_response()
}
// ---------------------------------------------------------------------------
// POST /api/player/share-playlist
// ---------------------------------------------------------------------------
@@ -9813,6 +9926,33 @@ impl App for PlayerApp {
}),
"player_search",
),
Route::with_handler_and_name(
"/similarity/{id}",
get({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |auth_ctx: auth::AuthContext,
session: Session,
db: Database,
path: Path<PathId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
similarity_search_handler(auth_ctx, session, db, pg_pool, path).await
}
}
}),
"player_similarity_search",
),
Route::with_handler_and_name(
"/federation/search/events",
get(
+1323
View File
File diff suppressed because it is too large Load Diff