Reworked settings page
Build and Publish / Build and Publish Docker Image (push) Successful in 3m28s
Build and Publish / Build and Publish Docker Image (push) Successful in 3m28s
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user