Reworked settings page
Build and Publish / Build and Publish Docker Image (push) Successful in 3m28s

This commit is contained in:
Ultradesu
2026-08-11 12:26:16 +01:00
parent 6f337ee626
commit 69883af8bd
13 changed files with 930 additions and 209 deletions
+4
View File
@@ -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<f32>,
}
#[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,
}
}
+50 -2
View File
@@ -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<String> = 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<String, String> = 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<TransportStats>,
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<Vec<similarity::RemoteSimilarityTrack>> {
) -> Result<similarity::SimilaritySearchOutcome> {
anyhow::ensure!(
crate::similarity::handle().enabled(),
"similarity search is disabled"
+14 -2
View File
@@ -39,6 +39,12 @@ pub struct RemoteSimilarityTrack {
pub release_title: Option<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub similarity_score: f32,
}
pub struct SimilaritySearchOutcome {
pub tracks: Vec<RemoteSimilarityTrack>,
pub queried_peers: usize,
}
pub async fn serve_peers(
@@ -151,7 +157,7 @@ pub async fn search(
query: QueryVector,
limit: usize,
transport: Arc<TransportStats>,
) -> Result<Vec<RemoteSimilarityTrack>> {
) -> Result<SimilaritySearchOutcome> {
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,
+2 -2
View File
@@ -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. Попробуйте ещё раз.";
+64 -22
View File
@@ -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<TrackItem>,
tracks: Vec<ScoredSimilarityTrack>,
federation_tracks: Vec<crate::federation::client::TrackDto>,
federation_error: Option<String>,
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<PathId>,
options: cot::request::extractors::UrlQuery<SimilaritySearchQuery>,
) -> cot::Result<cot::response::Response> {
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::<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 scores: HashMap<i64, f32> = 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<PathId>| {
path: Path<PathId>,
query: cot::request::extractors::UrlQuery<SimilaritySearchQuery>| {
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
}
}
}),
+52 -1
View File
@@ -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<Self>, config: &AppConfig) {