diff --git a/Cargo.lock b/Cargo.lock index d0a8b5f..3959df6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.10.1" +version = "0.10.2" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 6685dbb..078d27b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.10.1" +version = "0.10.2" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/federation/client.rs b/src/federation/client.rs index 5d052f6..7c36185 100644 --- a/src/federation/client.rs +++ b/src/federation/client.rs @@ -85,6 +85,8 @@ pub struct TrackDto { pub key: TrackKeyDto, pub metadata: TrackMetadataDto, pub availability: TrackAvailabilityDto, + #[serde(skip_serializing_if = "Option::is_none")] + pub similarity_score: Option, } #[derive(Debug, Clone, Serialize)] @@ -151,6 +153,7 @@ impl Federation { local: None, federation: vec![FederationSourceDto { owner, item_id }], }, + similarity_score: Some(track.similarity_score), }; persist_track_ref(&pool, &dto).await?; prepared.push(dto); @@ -491,6 +494,7 @@ fn track_from_item( local, federation: vec![FederationSourceDto { owner, item_id }], }, + similarity_score: None, } } diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 6ce5917..06cfce2 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -213,6 +213,50 @@ impl TransportStats { } } +async fn enrich_transport_users(pool: &PgPool, transport: &mut Value) { + let Some(samples) = transport.get_mut("last").and_then(Value::as_array_mut) else { + return; + }; + let peer_ids: Vec = samples + .iter() + .filter_map(|sample| sample.get("peer_id").and_then(Value::as_str)) + .map(str::to_owned) + .collect(); + if peer_ids.is_empty() { + return; + } + let Ok(rows) = sqlx::query( + "SELECT DISTINCT ON (d.endpoint_id) + d.endpoint_id, + COALESCE(NULLIF(u.display_name, ''), u.username::text) AS user_name + FROM furumusic__fed_device d + JOIN furumusic__user u ON u.id = d.user_id + WHERE d.endpoint_id = ANY($1) AND d.revoked_at_ms IS NULL + ORDER BY d.endpoint_id, d.last_seen_ms DESC NULLS LAST", + ) + .bind(&peer_ids) + .fetch_all(pool) + .await + else { + return; + }; + let users: HashMap = rows + .into_iter() + .map(|row| (row.get("endpoint_id"), row.get("user_name"))) + .collect(); + for sample in samples { + let Some(peer_id) = sample.get("peer_id").and_then(Value::as_str) else { + continue; + }; + let Some(user_name) = users.get(peer_id) else { + continue; + }; + if let Some(object) = sample.as_object_mut() { + object.insert("user_name".to_owned(), Value::String(user_name.clone())); + } + } +} + pub fn record_stream_transport( stats: &Arc, protocol: &'static str, @@ -849,6 +893,10 @@ impl Federation { .iter() .map(|p| p.to_string()) .collect(); + let mut transport = self.transport_stats.snapshot(); + if let Ok(pool) = self.pool().await { + enrich_transport_users(&pool, &mut transport).await; + } json!({ "running": true, "network": running.network_name, @@ -857,7 +905,7 @@ impl Federation { "known_contacts": service.known_peers().len(), "similarity_routing_peers": running.similarity_dht.known_peers(), "published_items": published, - "transport": self.transport_stats.snapshot(), + "transport": transport, }) } None => json!({ "running": false }), @@ -895,7 +943,7 @@ impl Federation { &self, query: crate::similarity::QueryVector, limit: usize, - ) -> Result> { + ) -> Result { anyhow::ensure!( crate::similarity::handle().enabled(), "similarity search is disabled" diff --git a/src/federation/similarity.rs b/src/federation/similarity.rs index 7a68aa1..2a3c638 100644 --- a/src/federation/similarity.rs +++ b/src/federation/similarity.rs @@ -39,6 +39,12 @@ pub struct RemoteSimilarityTrack { pub release_title: Option, pub track_number: Option, pub disc_number: Option, + pub similarity_score: f32, +} + +pub struct SimilaritySearchOutcome { + pub tracks: Vec, + pub queried_peers: usize, } pub async fn serve_peers( @@ -151,7 +157,7 @@ pub async fn search( query: QueryVector, limit: usize, transport: Arc, -) -> Result> { +) -> Result { let own = service.endpoint_id(); let routed = match tokio::time::timeout( ROUTING_TIMEOUT, @@ -204,6 +210,7 @@ pub async fn search( let mut hits = Vec::new(); let initial = peers.len().min(INITIAL_QUERY_PEERS); + let mut queried_peers = initial; let responses = query_peers( Arc::clone(&service), &peers[..initial], @@ -222,6 +229,7 @@ pub async fn search( } } if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) { + queried_peers += peers.len() - initial; for response in query_peers( Arc::clone(&service), &peers[initial..], @@ -282,7 +290,10 @@ pub async fn search( break; } } - Ok(tracks) + Ok(SimilaritySearchOutcome { + tracks, + queried_peers, + }) } type PeerHits = Vec<( @@ -360,6 +371,7 @@ async fn query_peer( release_title: hit.release_title, track_number: hit.track_number, disc_number: hit.disc_number, + similarity_score: score, }, score, signature, diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 241607f..e2240e3 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -96,9 +96,9 @@ translations! { settings_swagger: "Swagger UI" , "Swagger UI"; settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)"; settings_lastfm_api_key: "Last.fm API key" , "API ключ Last.fm"; - settings_lastfm_api_key_help: "Used for Last.fm popularity and account connection" , "Используется для популярности Last.fm и подключения аккаунта"; + settings_lastfm_api_key_help: "Identifies this application to Last.fm and enables metadata, popularity data, and user account connection" , "Идентифицирует приложение в Last.fm и включает метаданные, данные о популярности и подключение аккаунтов"; settings_lastfm_shared_secret: "Last.fm shared secret" , "Shared secret Last.fm"; - settings_lastfm_shared_secret_help: "Required for signed Last.fm scrobbling requests" , "Нужен для подписанных запросов скробблинга Last.fm"; + settings_lastfm_shared_secret_help: "Authenticates signed Last.fm requests, including scrobbling. Keep this value private" , "Подтверждает подписанные запросы Last.fm, включая скробблинг. Не раскрывайте это значение"; // OIDC login errors login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз."; diff --git a/src/player/mod.rs b/src/player/mod.rs index 4fb2dec..5d2d39a 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -14,7 +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 serde::{Deserialize, Serialize}; use sqlx::Row as _; use crate::auth; @@ -4318,9 +4318,25 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul #[derive(Debug, Serialize)] struct SimilaritySearchResponse { label: String, - tracks: Vec, + tracks: Vec, federation_tracks: Vec, federation_error: Option, + queried_peers: usize, + elapsed_ms: u64, + complete: bool, +} + +#[derive(Debug, Serialize)] +struct ScoredSimilarityTrack { + #[serde(flatten)] + track: TrackItem, + similarity_score: f32, +} + +#[derive(Debug, Deserialize)] +struct SimilaritySearchQuery { + #[serde(default)] + local_only: bool, } async fn similarity_search_handler( @@ -4329,7 +4345,9 @@ async fn similarity_search_handler( db: Database, pool: &sqlx::PgPool, Path(path): Path, + options: cot::request::extractors::UrlQuery, ) -> cot::Result { + let started = std::time::Instant::now(); let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -4385,28 +4403,48 @@ async fn similarity_search_handler( .iter() .map(|track| track.track_id) .collect::>(); - 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 scores: HashMap = ranked + .iter() + .map(|track| (track.track_id, track.score)) + .collect(); + let mut local_tracks = Vec::with_capacity(ids.len() + 1); + local_tracks.push(source_track.clone()); + local_tracks.extend(load_track_items_by_ids(pool, &ids).await?); + let tracks = local_tracks + .into_iter() + .map(|track| ScoredSimilarityTrack { + similarity_score: if track.id == path.id { + 1.0 + } else { + scores.get(&track.id).copied().unwrap_or_default() + }, + track, + }) + .collect(); 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) + let (federation_tracks, federation_error, queried_peers) = + if config.federation_enabled && !options.0.local_only { + match crate::federation::handle() + .search_similarity(query, 50) .await { - Ok(tracks) => (tracks, None), - Err(error) => (Vec::new(), Some(format!("{error:#}"))), - }, - Err(error) => (Vec::new(), Some(format!("{error:#}"))), - } - } else { - (Vec::new(), None) - }; + Ok(outcome) => match crate::federation::handle() + .prepare_similarity_tracks(outcome.tracks) + .await + { + Ok(tracks) => (tracks, None, outcome.queried_peers), + Err(error) => ( + Vec::new(), + Some(format!("{error:#}")), + outcome.queried_peers, + ), + }, + Err(error) => (Vec::new(), Some(format!("{error:#}")), 0), + } + } else { + (Vec::new(), None, 0) + }; let artists = source_track .artists .iter() @@ -4423,6 +4461,9 @@ async fn similarity_search_handler( tracks, federation_tracks, federation_error, + queried_peers, + elapsed_ms: started.elapsed().as_millis() as u64, + complete: !options.0.local_only, }) .into_response() } @@ -9934,7 +9975,8 @@ impl App for PlayerApp { move |auth_ctx: auth::AuthContext, session: Session, db: Database, - path: Path| { + path: Path, + query: cot::request::extractors::UrlQuery| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -9947,7 +9989,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - similarity_search_handler(auth_ctx, session, db, pg_pool, path).await + similarity_search_handler(auth_ctx, session, db, pg_pool, path, query).await } } }), diff --git a/src/similarity.rs b/src/similarity.rs index 4bf166f..b0a410f 100644 --- a/src/similarity.rs +++ b/src/similarity.rs @@ -244,10 +244,61 @@ impl Manager { return; } }; + let mut effective = config.clone(); + let mut rows = None; + for attempt in 0..20 { + match sqlx::query( + "SELECT key, value FROM furumusic__config_entry + WHERE key IN ('similarity_enabled', 'similarity_model', + 'similarity_profile', 'similarity_workers', + 'agent_storage_dir')", + ) + .fetch_all(&pool) + .await + { + Ok(loaded) => { + rows = Some(loaded); + break; + } + Err(error) if attempt < 19 => { + tracing::debug!(attempt, %error, "similarity boot: settings table not ready"); + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(error) => { + tracing::warn!(%error, "similarity boot: database settings unavailable"); + } + } + } + for row in rows.unwrap_or_default() { + let key: String = row.get(0); + let value: String = row.get(1); + let env_key = format!("FURU_{}", key.to_ascii_uppercase()); + if std::env::var(&env_key).is_ok() { + continue; + } + match key.as_str() { + "similarity_enabled" => { + if let Ok(parsed) = value.parse() { + effective.similarity_enabled = parsed; + } + } + "similarity_model" => effective.similarity_model = value, + "similarity_profile" => effective.similarity_profile = value, + "similarity_workers" => { + if let Ok(parsed) = value.parse() { + effective.similarity_workers = parsed; + } + } + "agent_storage_dir" => { + effective.agent_storage_dir = crate::media_paths::resolve_config_path(&value); + } + _ => {} + } + } if let Err(error) = self.restore_stored_status(&pool).await { tracing::warn!(%error, "similarity boot: stored status unavailable"); } - self.apply(config); + self.apply(&effective); } pub fn apply(self: &Arc, config: &AppConfig) { diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 62e3851..4a7f91b 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -806,35 +806,68 @@ tbody tr:hover { } .settings-page { - max-width: none; + max-width: 1440px; + margin: 0 auto; } .settings-layout { display: grid; - grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px); - gap: 14px; - align-items: start; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-template-areas: + "access access access access oidc oidc oidc oidc oidc oidc oidc oidc" + "agent agent agent agent agent agent agent agent agentstatus agentstatus agentstatus agentstatus" + "similarity similarity similarity similarity similarity similarity similarity similarity similaritystatus similaritystatus similaritystatus similaritystatus" + "federation federation federation federation federation federation federation federation federation federation federation federation" + "lastfm lastfm lastfm lastfm lastfm lastfm lastfm lastfm developer developer developer developer" + "actions actions actions actions actions actions actions actions actions actions actions actions"; + gap: 16px; + align-items: stretch; } .settings-column { - display: grid; - gap: 14px; - align-content: start; + display: contents; } -.settings-side .settings-grid { +.settings-section { + min-width: 0; + margin: 0; +} +.settings-access { grid-area: access; } +.settings-oidc { grid-area: oidc; } +.settings-agent { grid-area: agent; } +.settings-agent-status { grid-area: agentstatus; } +.settings-similarity { grid-area: similarity; } +.settings-similarity-status { grid-area: similaritystatus; } +.settings-federation { grid-area: federation; } +.settings-lastfm { grid-area: lastfm; } +.settings-developer { grid-area: developer; } +.settings-section-narrow { border-left: 2px solid rgba(29, 185, 84, 0.55); } + +.settings-access .settings-grid, +.settings-developer .settings-grid { grid-template-columns: minmax(0, 1fr); } +.settings-section-narrow .panel-head { + background: rgba(29, 185, 84, 0.035); +} + .settings-actions { - grid-column: 1 / -1; + grid-area: actions; + position: sticky; + bottom: 0; + z-index: 5; + border: 1px solid var(--border-color); + border-radius: 8px; + background: rgba(35, 35, 35, 0.96); + backdrop-filter: blur(10px); } .settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - padding: 14px; + gap: 14px 16px; + padding: 16px; } .settings-card { @@ -843,22 +876,26 @@ tbody tr:hover { .setting-field { min-width: 0; + max-width: 480px; } +.setting-field.settings-short { max-width: 150px; } +.settings-wide { max-width: 680px; } + .setting-field label, .setting-toggle label { display: flex; align-items: center; justify-content: space-between; gap: 8px; - margin-bottom: 6px; + margin-bottom: 7px; color: var(--text-secondary); - font-size: 11px; - font-weight: 800; - text-transform: uppercase; + font-size: 12px; + font-weight: 700; } -.setting-field input { +.setting-field input, +.setting-field select { width: 100%; height: 34px; padding: 0 10px; @@ -869,13 +906,27 @@ tbody tr:hover { outline: none; } -.setting-field input:focus { +.setting-field textarea { + width: 100%; + min-height: 68px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + outline: none; + resize: vertical; +} + +.setting-field input:focus, +.setting-field select:focus, +.setting-field textarea:focus { border-color: var(--accent); } .setting-toggle { - min-height: 74px; - padding: 12px; + min-height: 68px; + padding: 11px 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary); @@ -894,9 +945,12 @@ tbody tr:hover { font-weight: 800; } -.setting-toggle input { +.setting-toggle input, +.setting-toggle-row input[type="checkbox"] { + flex: 0 0 auto; width: 18px; height: 18px; + padding: 0; accent-color: var(--accent); } @@ -904,7 +958,8 @@ tbody tr:hover { margin-top: 6px; color: var(--text-subdued); font-size: 11px; - line-height: 1.4; + line-height: 1.45; + max-width: 68ch; } .source-pill { @@ -929,6 +984,101 @@ tbody tr:hover { grid-column: 1 / -1; } +.settings-federation-body { + display: grid; + grid-template-columns: minmax(360px, 4fr) minmax(580px, 8fr); + gap: 20px; + padding: 16px; +} + +.settings-federation-body > .settings-grid, +.settings-federation-body > .probe-body { + padding: 0; +} + +.federation-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.transport-log { + grid-column: 1 / -1; + min-width: 0; +} + +.transport-log-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + margin: 14px 0 7px; +} + +.transport-log-head strong { font-size: 12px; } +.transport-log-head span { color: var(--text-subdued); font-size: 11px; } + +.transport-log-scroll { + max-height: 210px; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 7px; + background: var(--bg-primary); +} + +.transport-row { + display: grid; + grid-template-columns: 68px minmax(100px, 1.2fr) 84px 72px 54px 64px 64px 66px 66px 66px; + gap: 8px; + align-items: center; + min-width: 850px; + min-height: 30px; + padding: 5px 9px; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); + color: var(--text-secondary); + font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.transport-row:last-child { border-bottom: 0; } +.transport-row.transport-header { + position: sticky; + top: 0; + z-index: 1; + color: var(--text-subdued); + background: var(--bg-elevated); + font-size: 10px; + font-weight: 850; + text-transform: uppercase; +} +.transport-row > span { overflow: hidden; text-overflow: ellipsis; } +.transport-number { text-align: right; } + +@media (max-width: 1000px) { + .settings-layout { + grid-template-columns: 1fr; + grid-template-areas: + "access" + "oidc" + "agent" + "agentstatus" + "similarity" + "similaritystatus" + "federation" + "lastfm" + "developer" + "actions"; + } + .settings-federation-body { grid-template-columns: 1fr; } +} + +@media (max-width: 700px) { + .settings-grid, + .federation-status-grid { grid-template-columns: 1fr; } + .setting-field { max-width: none; } +} + .settings-note { padding: 14px; color: var(--text-secondary); @@ -937,7 +1087,7 @@ tbody tr:hover { } .probe-body { - padding: 14px; + padding: 16px; } .probe-intro { @@ -955,9 +1105,43 @@ tbody tr:hover { } .probe-row { - display: flex; - justify-content: space-between; - gap: 10px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 12px; + min-height: 22px; +} + +.probe-row strong { + max-width: 210px; + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.similarity-profile-details { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-color); +} + +.similarity-profile-details > span { + display: block; + margin-bottom: 5px; + color: var(--text-subdued); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.similarity-profile-details pre { + margin: 0; + color: var(--text-secondary); + font: inherit; + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; } .library-row { @@ -1560,7 +1744,7 @@ tbody tr:hover {

- @@ -2059,7 +2243,7 @@ tbody tr:hover {
-
+
OIDC @@ -2070,6 +2254,7 @@ tbody tr:hover {
+
Register this exact redirect URL in your identity provider.
+
Comma-separated identity-provider groups whose members receive administrator access.
+
Comma-separated groups allowed to sign in. Leave empty to allow any authenticated OIDC user.
-
+
Agent @@ -2134,12 +2321,13 @@ tbody tr:hover {
-
+
+
Maximum number of inbox items processed at the same time. Higher values use more CPU and LLM capacity.
+
Base URL of an OpenAI-compatible service. The agent sends chat requests to its /v1/chat/completions endpoint.
+
Model identifier sent to the configured LLM service, for example the name exposed by your local model server.
+
Complete HTTP Authorization value expected by the LLM endpoint, for example Bearer ….
+
Minimum confidence required to accept generated metadata automatically. Lower-confidence results are sent for review.
+
Maximum model context budget in tokens. Reduce it for smaller models or increase it when processing large batches.
-
+
Similarity Search @@ -2210,7 +2403,7 @@ tbody tr:hover {
-
Downloads the selected model and processes every visible local track. With federation enabled, signed anonymous LSH summaries discover likely peers; full query embeddings are sent only to those peers, and this instance answers their searches.
+
Builds an audio fingerprint index for finding musically similar tracks. When federation is enabled, compatible peers can also participate in searches.
-
+
-
Applied immediately after saving.
+
Number of tracks indexed in parallel. Higher values finish sooner but use more CPU and memory.
-
+
Authentication @@ -2283,26 +2476,15 @@ tbody tr:hover {
-
+
- API - Developer and enrichment integrations + Last.fm Integration + Metadata enrichment and scrobbling credentials
-
- -
- - -
-
Interactive API docs at /swagger/ after restart.
-
-
+
+
+
+ Developer API + Interactive API documentation +
+
+
+
+ +
+ + +
+
Exposes interactive API documentation at /swagger/ for developers and integrations.
+
+
+
+ +
Federation @@ -2330,6 +2534,7 @@ tbody tr:hover {
+
-
Applies immediately on save — no restart needed. Peers can browse and stream every visible track.
+
Lets other peers in this logical network discover the visible library and request audio streams from this instance.
-
Every peer using the same id finds the others automatically.
+
Peers with the same Network ID form one isolated logical network and discover each other automatically. You may choose any private value; use the exact same value on every peer that should join this network.
-
+
-
Server-wide policy. Imported tracks become available to every user and are published by this peer. Federation metadata is trusted and bypasses the AI agent.
+
When enabled, a federated track is permanently imported after playback and becomes available to every local user. Otherwise it remains only in the temporary cache. Imported peer metadata is trusted as provided and does not enter AI review.
+
Endpoint
Network
@@ -2372,7 +2578,7 @@ tbody tr:hover {
Published items
Last sync
-
+
Transport path @@ -2384,23 +2590,34 @@ tbody tr:hover {
Protocols
Last peer
-
- + +
+

-
+
-
+
- Similarity Status - Model download, indexing, and active profile + Search Index Statistics + Similarity model, indexing progress, and storage
@@ -2445,15 +2663,15 @@ tbody tr:hover {
Stored
Current track
+
+ Selected preprocessing profile +

+                                

-
-
+
Agent Status @@ -2493,10 +2711,6 @@ tbody tr:hover {
Settings are stored as database overrides unless an environment variable wins.
-