Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69883af8bd | ||
|
|
6f337ee626 |
Generated
+5
-5
@@ -1793,9 +1793,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "15a8707baeccb46b5935138f9cb3df3c988c0730b807a2634d901f26b39250d6"
|
checksum = "c3e690b370c505d153bef214b21a8f2aa55d667367ac1e16bde8bc0de88963c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1938,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.9.8"
|
version = "0.10.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -3775,9 +3775,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91592b40de9f3c2158a39da105c821e9bf17f461fe142a56a8607fb0faf56a9c"
|
checksum = "0c5b429b90a8f1b0980b3a35a6fa5445d7a275c737eb04db18db4d7f14c81478"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.10.0"
|
version = "0.10.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
@@ -43,4 +43,4 @@ uuid = "1"
|
|||||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||||
# P2P federation: publishes the library into a shared DHT and serves audio /
|
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||||
# catalogs to furumi peers (TUI clients) over the frid stack.
|
# catalogs to furumi peers (TUI clients) over the frid stack.
|
||||||
music-dht = "0.3.1"
|
music-dht = "0.4.0"
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# furumusic
|
# furumusic
|
||||||
|
|
||||||
|
Furumusic can join the decentralized Furumi federation while remaining a
|
||||||
|
complete local web player. Optional similarity search stores versioned audio
|
||||||
|
embeddings in PostgreSQL and uses signed two-level LSH summaries in a separate
|
||||||
|
DHT to discover compatible peers without a central recommendation index. The
|
||||||
|
shared `music-dht` layer owns routing and wire compatibility; model inference
|
||||||
|
and exact cosine ranking stay local to each instance.
|
||||||
|
|
||||||
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
|
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
|
||||||
|
|
||||||
Built with Rust ([cot](https://cot.rs) framework).
|
Built with Rust ([cot](https://cot.rs) framework).
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ mod tests {
|
|||||||
manifest.protocols.get(SIMILARITY_ID),
|
manifest.protocols.get(SIMILARITY_ID),
|
||||||
Some(&music_dht::similarity::SIMILARITY_PROTOCOL_VERSION)
|
Some(&music_dht::similarity::SIMILARITY_PROTOCOL_VERSION)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest
|
||||||
|
.protocols
|
||||||
|
.get(music_dht::capabilities::SIMILARITY_DHT_ID),
|
||||||
|
Some(&music_dht::similarity_lsh::SIMILARITY_DHT_PROTOCOL_VERSION)
|
||||||
|
);
|
||||||
manifest.validate().unwrap();
|
manifest.validate().unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ pub struct TrackDto {
|
|||||||
pub key: TrackKeyDto,
|
pub key: TrackKeyDto,
|
||||||
pub metadata: TrackMetadataDto,
|
pub metadata: TrackMetadataDto,
|
||||||
pub availability: TrackAvailabilityDto,
|
pub availability: TrackAvailabilityDto,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub similarity_score: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -151,6 +153,7 @@ impl Federation {
|
|||||||
local: None,
|
local: None,
|
||||||
federation: vec![FederationSourceDto { owner, item_id }],
|
federation: vec![FederationSourceDto { owner, item_id }],
|
||||||
},
|
},
|
||||||
|
similarity_score: Some(track.similarity_score),
|
||||||
};
|
};
|
||||||
persist_track_ref(&pool, &dto).await?;
|
persist_track_ref(&pool, &dto).await?;
|
||||||
prepared.push(dto);
|
prepared.push(dto);
|
||||||
@@ -491,6 +494,7 @@ fn track_from_item(
|
|||||||
local,
|
local,
|
||||||
federation: vec![FederationSourceDto { owner, item_id }],
|
federation: vec![FederationSourceDto { owner, item_id }],
|
||||||
},
|
},
|
||||||
|
similarity_score: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+167
-7
@@ -29,6 +29,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use music_dht::capabilities::CAPABILITIES_ALPN;
|
use music_dht::capabilities::CAPABILITIES_ALPN;
|
||||||
|
use music_dht::similarity_dht::SimilarityDht;
|
||||||
|
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
|
||||||
use music_dht::{
|
use music_dht::{
|
||||||
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||||
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||||
@@ -49,6 +51,7 @@ const TRANSPORT_SAMPLE_LIMIT: usize = 16;
|
|||||||
|
|
||||||
struct Running {
|
struct Running {
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
|
similarity_dht: Arc<SimilarityDht>,
|
||||||
network_name: String,
|
network_name: String,
|
||||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@@ -210,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(
|
pub fn record_stream_transport(
|
||||||
stats: &Arc<TransportStats>,
|
stats: &Arc<TransportStats>,
|
||||||
protocol: &'static str,
|
protocol: &'static str,
|
||||||
@@ -221,7 +268,8 @@ pub fn record_stream_transport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Federation {
|
pub struct Federation {
|
||||||
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
/// Transport files and replaceable similarity-routing cache. Durable
|
||||||
|
/// catalog DHT state and identity live in PostgreSQL.
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
database_url: std::sync::Mutex<String>,
|
database_url: std::sync::Mutex<String>,
|
||||||
storage_dir: std::sync::Mutex<String>,
|
storage_dir: std::sync::Mutex<String>,
|
||||||
@@ -380,6 +428,9 @@ impl Federation {
|
|||||||
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||||
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
||||||
self.transport_stats.reset();
|
self.transport_stats.reset();
|
||||||
|
tokio::fs::create_dir_all(&self.data_dir)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {}", self.data_dir.display()))?;
|
||||||
|
|
||||||
let config = MusicDhtConfig::builder()
|
let config = MusicDhtConfig::builder()
|
||||||
.data_dir(&self.data_dir)
|
.data_dir(&self.data_dir)
|
||||||
@@ -389,7 +440,8 @@ impl Federation {
|
|||||||
.stream_protocol(AUDIO_ALPN)
|
.stream_protocol(AUDIO_ALPN)
|
||||||
.stream_protocol(CATALOG_ALPN)
|
.stream_protocol(CATALOG_ALPN)
|
||||||
.stream_protocol(devices::SYNC_ALPN)
|
.stream_protocol(devices::SYNC_ALPN)
|
||||||
.stream_protocol(SIMILARITY_ALPN)
|
.schema_independent_stream_protocol(SIMILARITY_ALPN)
|
||||||
|
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
|
||||||
.schema_independent_stream_protocol(CAPABILITIES_ALPN)
|
.schema_independent_stream_protocol(CAPABILITIES_ALPN)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||||
@@ -404,6 +456,25 @@ impl Federation {
|
|||||||
"federation started"
|
"federation started"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let similarity_dht = SimilarityDht::open(
|
||||||
|
Arc::clone(&service),
|
||||||
|
self.data_dir.join("similarity-routing.sqlite3"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("failed to start the similarity DHT: {error}"))?;
|
||||||
|
let similarity_dht_acceptor = service
|
||||||
|
.stream_acceptor(SIMILARITY_DHT_ALPN)
|
||||||
|
.map_err(|error| anyhow::anyhow!("failed to take similarity DHT acceptor: {error}"))?;
|
||||||
|
let similarity_dht_serve_task =
|
||||||
|
tokio::spawn(Arc::clone(&similarity_dht).serve(similarity_dht_acceptor));
|
||||||
|
let similarity_dht_maintenance_task =
|
||||||
|
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
|
||||||
|
let similarity_manager = crate::similarity::handle();
|
||||||
|
let similarity_dht_sync_task = tokio::spawn(similarity_route_sync_loop(
|
||||||
|
Arc::clone(&similarity_dht),
|
||||||
|
Arc::clone(&similarity_manager),
|
||||||
|
));
|
||||||
|
|
||||||
// Drain DHT events into the log; the channel is bounded.
|
// Drain DHT events into the log; the channel is bounded.
|
||||||
let event_task = tokio::spawn(async move {
|
let event_task = tokio::spawn(async move {
|
||||||
while let Some(event) = events.recv().await {
|
while let Some(event) = events.recv().await {
|
||||||
@@ -467,13 +538,14 @@ impl Federation {
|
|||||||
.map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?;
|
||||||
let similarity_task = tokio::spawn(similarity::serve_peers(
|
let similarity_task = tokio::spawn(similarity::serve_peers(
|
||||||
similarity_acceptor,
|
similarity_acceptor,
|
||||||
crate::similarity::handle(),
|
similarity_manager,
|
||||||
service.endpoint_id(),
|
service.endpoint_id(),
|
||||||
Arc::clone(&self.transport_stats),
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
|
|
||||||
*guard = Some(Running {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
|
similarity_dht,
|
||||||
network_name,
|
network_name,
|
||||||
tasks: vec![
|
tasks: vec![
|
||||||
event_task,
|
event_task,
|
||||||
@@ -484,6 +556,9 @@ impl Federation {
|
|||||||
device_sync_task,
|
device_sync_task,
|
||||||
capabilities_task,
|
capabilities_task,
|
||||||
similarity_task,
|
similarity_task,
|
||||||
|
similarity_dht_serve_task,
|
||||||
|
similarity_dht_maintenance_task,
|
||||||
|
similarity_dht_sync_task,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
self.set_error(None);
|
self.set_error(None);
|
||||||
@@ -507,6 +582,20 @@ impl Federation {
|
|||||||
.context("federation is not running")
|
.context("federation is not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn similarity_services(&self) -> Result<(Arc<MusicDhtService>, Arc<SimilarityDht>)> {
|
||||||
|
self.running
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|running| {
|
||||||
|
(
|
||||||
|
Arc::clone(&running.service),
|
||||||
|
Arc::clone(&running.similarity_dht),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.context("federation is not running")
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_sync_soon(self: &Arc<Self>) {
|
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||||
if let Ok(service) = self.service().await {
|
if let Ok(service) = self.service().await {
|
||||||
let fed = Arc::clone(self);
|
let fed = Arc::clone(self);
|
||||||
@@ -804,14 +893,19 @@ impl Federation {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.to_string())
|
.map(|p| p.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut transport = self.transport_stats.snapshot();
|
||||||
|
if let Ok(pool) = self.pool().await {
|
||||||
|
enrich_transport_users(&pool, &mut transport).await;
|
||||||
|
}
|
||||||
json!({
|
json!({
|
||||||
"running": true,
|
"running": true,
|
||||||
"network": running.network_name,
|
"network": running.network_name,
|
||||||
"endpoint_id": service.endpoint_id().to_string(),
|
"endpoint_id": service.endpoint_id().to_string(),
|
||||||
"connected_peers": peers,
|
"connected_peers": peers,
|
||||||
"known_contacts": service.known_peers().len(),
|
"known_contacts": service.known_peers().len(),
|
||||||
|
"similarity_routing_peers": running.similarity_dht.known_peers(),
|
||||||
"published_items": published,
|
"published_items": published,
|
||||||
"transport": self.transport_stats.snapshot(),
|
"transport": transport,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => json!({ "running": false }),
|
None => json!({ "running": false }),
|
||||||
@@ -849,13 +943,20 @@ impl Federation {
|
|||||||
&self,
|
&self,
|
||||||
query: crate::similarity::QueryVector,
|
query: crate::similarity::QueryVector,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<similarity::RemoteSimilarityTrack>> {
|
) -> Result<similarity::SimilaritySearchOutcome> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
crate::similarity::handle().enabled(),
|
crate::similarity::handle().enabled(),
|
||||||
"similarity search is disabled"
|
"similarity search is disabled"
|
||||||
);
|
);
|
||||||
let service = self.service().await?;
|
let (service, similarity_dht) = self.similarity_services().await?;
|
||||||
similarity::search(service, query, limit, Arc::clone(&self.transport_stats)).await
|
similarity::search(
|
||||||
|
service,
|
||||||
|
similarity_dht,
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fed_device_status(
|
pub async fn fed_device_status(
|
||||||
@@ -983,6 +1084,65 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn similarity_route_sync_loop(
|
||||||
|
routing: Arc<SimilarityDht>,
|
||||||
|
manager: Arc<crate::similarity::Manager>,
|
||||||
|
) {
|
||||||
|
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
let mut published_marker: Option<(String, blake3::Hash)> = None;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if !manager.enabled() {
|
||||||
|
if published_marker.take().is_some() {
|
||||||
|
routing.clear_local_signatures();
|
||||||
|
tracing::info!("local similarity DHT publication disabled");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let status = manager.status();
|
||||||
|
let Some(profile_id) = status.active_profile else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if status.phase != crate::similarity::Phase::Ready {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let signatures = match manager.routing_signatures(&profile_id).await {
|
||||||
|
Ok(signatures) => signatures,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, %profile_id, "similarity routing signatures unavailable");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
for signature in &signatures {
|
||||||
|
hasher.update(signature);
|
||||||
|
}
|
||||||
|
let marker = (profile_id.clone(), hasher.finalize());
|
||||||
|
if published_marker.as_ref() == Some(&marker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match routing
|
||||||
|
.sync_local_signatures(profile_id.clone(), signatures)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stats) => {
|
||||||
|
tracing::info!(
|
||||||
|
profile = %profile_id,
|
||||||
|
records = stats.records,
|
||||||
|
keys = stats.keys,
|
||||||
|
remote_nodes = stats.remote_nodes,
|
||||||
|
"local similarity DHT index synchronized"
|
||||||
|
);
|
||||||
|
published_marker = Some(marker);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, %profile_id, "similarity DHT synchronization failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn persist_content_id(
|
async fn persist_content_id(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
media_file_id: i64,
|
media_file_id: i64,
|
||||||
|
|||||||
+125
-37
@@ -7,7 +7,10 @@ use std::time::Duration;
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use futures_util::stream::{self, StreamExt as _};
|
use futures_util::stream::{self, StreamExt as _};
|
||||||
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
use music_dht::similarity_dht::SimilarityDht;
|
||||||
|
use music_dht::{
|
||||||
|
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::similarity::{Manager, QueryVector};
|
use crate::similarity::{Manager, QueryVector};
|
||||||
|
|
||||||
@@ -15,9 +18,11 @@ use super::TransportStats;
|
|||||||
|
|
||||||
pub use music_dht::similarity::SIMILARITY_ALPN;
|
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||||
|
|
||||||
const MAX_QUERY_PEERS: usize = 16;
|
const INITIAL_QUERY_PEERS: usize = 16;
|
||||||
const QUERY_CONCURRENCY: usize = 6;
|
const MAX_QUERY_PEERS: usize = 48;
|
||||||
|
const QUERY_CONCURRENCY: usize = 8;
|
||||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const MAX_PER_ARTIST: usize = 3;
|
const MAX_PER_ARTIST: usize = 3;
|
||||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||||
|
|
||||||
@@ -34,6 +39,12 @@ pub struct RemoteSimilarityTrack {
|
|||||||
pub release_title: Option<String>,
|
pub release_title: Option<String>,
|
||||||
pub track_number: Option<i32>,
|
pub track_number: Option<i32>,
|
||||||
pub disc_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(
|
pub async fn serve_peers(
|
||||||
@@ -142,20 +153,49 @@ async fn serve_one(
|
|||||||
|
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
|
routing: Arc<SimilarityDht>,
|
||||||
query: QueryVector,
|
query: QueryVector,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
transport: Arc<TransportStats>,
|
transport: Arc<TransportStats>,
|
||||||
) -> Result<Vec<RemoteSimilarityTrack>> {
|
) -> Result<SimilaritySearchOutcome> {
|
||||||
let own = service.endpoint_id();
|
let own = service.endpoint_id();
|
||||||
let mut peers = Vec::new();
|
let routed = match tokio::time::timeout(
|
||||||
|
ROUTING_TIMEOUT,
|
||||||
|
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(peers)) => peers,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!("similarity DHT lookup timed out; using known peers");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
tracing::debug!(%error, "similarity DHT lookup unavailable; using known peers");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
|
let mut peers: Vec<QueryPeer> = routed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|ticket| {
|
||||||
|
let owner = ticket.endpoint_id();
|
||||||
|
(owner != own && seen.insert(owner)).then_some(QueryPeer {
|
||||||
|
owner,
|
||||||
|
ticket: Some(ticket),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
for peer in service
|
for peer in service
|
||||||
.connected_peers()
|
.connected_peers()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||||
{
|
{
|
||||||
if peer != own && seen.insert(peer) {
|
if peer != own && seen.insert(peer) {
|
||||||
peers.push(peer);
|
peers.push(QueryPeer {
|
||||||
|
owner: peer,
|
||||||
|
ticket: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if peers.len() >= MAX_QUERY_PEERS {
|
if peers.len() >= MAX_QUERY_PEERS {
|
||||||
break;
|
break;
|
||||||
@@ -168,30 +208,42 @@ pub async fn search(
|
|||||||
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
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();
|
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],
|
||||||
|
Arc::clone(&request),
|
||||||
|
Arc::clone(&transport),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut successful = 0usize;
|
||||||
for response in responses {
|
for response in responses {
|
||||||
match response {
|
match response {
|
||||||
Ok(peer_hits) => hits.extend(peer_hits),
|
Ok(peer_hits) => {
|
||||||
|
successful += 1;
|
||||||
|
hits.extend(peer_hits);
|
||||||
|
}
|
||||||
Err(error) => tracing::debug!(%error, "similarity peer query skipped"),
|
Err(error) => tracing::debug!(%error, "similarity peer query skipped"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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..],
|
||||||
|
Arc::clone(&request),
|
||||||
|
Arc::clone(&transport),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
match response {
|
||||||
|
Ok(peer_hits) => hits.extend(peer_hits),
|
||||||
|
Err(error) => tracing::debug!(%error, "fallback similarity peer query skipped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||||
let mut dedup = HashSet::new();
|
let mut dedup = HashSet::new();
|
||||||
let mut signatures = vec![query_signature];
|
let mut signatures = vec![query_signature];
|
||||||
@@ -238,25 +290,60 @@ pub async fn search(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(tracks)
|
Ok(SimilaritySearchOutcome {
|
||||||
|
tracks,
|
||||||
|
queried_peers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerHits = Vec<(
|
||||||
|
RemoteSimilarityTrack,
|
||||||
|
f32,
|
||||||
|
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||||
|
)>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct QueryPeer {
|
||||||
|
owner: EndpointId,
|
||||||
|
ticket: Option<PeerTicket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_peers(
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
peers: &[QueryPeer],
|
||||||
|
request: Arc<SimilarityRequest>,
|
||||||
|
transport: Arc<TransportStats>,
|
||||||
|
) -> Vec<Result<PeerHits>> {
|
||||||
|
stream::iter(peers.iter().cloned().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()
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_peer(
|
async fn query_peer(
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
owner: EndpointId,
|
peer: QueryPeer,
|
||||||
request: &SimilarityRequest,
|
request: &SimilarityRequest,
|
||||||
transport: Arc<TransportStats>,
|
transport: Arc<TransportStats>,
|
||||||
) -> Result<
|
) -> Result<PeerHits> {
|
||||||
Vec<(
|
let owner = peer.owner;
|
||||||
RemoteSimilarityTrack,
|
let mut stream = match peer.ticket {
|
||||||
f32,
|
Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
|
||||||
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
None => service.open_stream(owner, SIMILARITY_ALPN).await,
|
||||||
)>,
|
}
|
||||||
> {
|
.map_err(|error| anyhow::anyhow!("cannot reach similarity peer: {error}"))?;
|
||||||
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);
|
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
||||||
let response = wire::exchange(&mut stream, request).await?;
|
let response = wire::exchange(&mut stream, request).await?;
|
||||||
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||||
@@ -284,6 +371,7 @@ async fn query_peer(
|
|||||||
release_title: hit.release_title,
|
release_title: hit.release_title,
|
||||||
track_number: hit.track_number,
|
track_number: hit.track_number,
|
||||||
disc_number: hit.disc_number,
|
disc_number: hit.disc_number,
|
||||||
|
similarity_score: score,
|
||||||
},
|
},
|
||||||
score,
|
score,
|
||||||
signature,
|
signature,
|
||||||
|
|||||||
+2
-2
@@ -96,9 +96,9 @@ translations! {
|
|||||||
settings_swagger: "Swagger UI" , "Swagger UI";
|
settings_swagger: "Swagger UI" , "Swagger UI";
|
||||||
settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)";
|
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: "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: "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
|
// OIDC login errors
|
||||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||||
|
|||||||
@@ -2541,6 +2541,34 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_similarity_embeddings).build()];
|
&[Operation::custom(create_similarity_embeddings).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn add_similarity_routing_signature(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__track_embedding
|
||||||
|
ADD COLUMN IF NOT EXISTS routing_signature BYTEA",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0044AddSimilarityRoutingSignature;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0044AddSimilarityRoutingSignature {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0044_add_similarity_routing_signature";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0043_create_similarity_embeddings",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(add_similarity_routing_signature).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -2575,5 +2603,6 @@ pub mod db_migrations {
|
|||||||
&M0041CreateSyncedListenHistory,
|
&M0041CreateSyncedListenHistory,
|
||||||
&M0042RepairLegacyListenQualification,
|
&M0042RepairLegacyListenQualification,
|
||||||
&M0043CreateSimilarityEmbeddings,
|
&M0043CreateSimilarityEmbeddings,
|
||||||
|
&M0044AddSimilarityRoutingSignature,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-22
@@ -14,7 +14,7 @@ use cot::router::method::{delete, get, post};
|
|||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{App, Body, Template};
|
use cot::{App, Body, Template};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row as _;
|
use sqlx::Row as _;
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
@@ -4318,9 +4318,25 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct SimilaritySearchResponse {
|
struct SimilaritySearchResponse {
|
||||||
label: String,
|
label: String,
|
||||||
tracks: Vec<TrackItem>,
|
tracks: Vec<ScoredSimilarityTrack>,
|
||||||
federation_tracks: Vec<crate::federation::client::TrackDto>,
|
federation_tracks: Vec<crate::federation::client::TrackDto>,
|
||||||
federation_error: Option<String>,
|
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(
|
async fn similarity_search_handler(
|
||||||
@@ -4329,7 +4345,9 @@ async fn similarity_search_handler(
|
|||||||
db: Database,
|
db: Database,
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
Path(path): Path<PathId>,
|
Path(path): Path<PathId>,
|
||||||
|
options: cot::request::extractors::UrlQuery<SimilaritySearchQuery>,
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
@@ -4385,28 +4403,48 @@ async fn similarity_search_handler(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|track| track.track_id)
|
.map(|track| track.track_id)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut tracks = Vec::with_capacity(ids.len() + 1);
|
let scores: HashMap<i64, f32> = ranked
|
||||||
tracks.push(source_track.clone());
|
.iter()
|
||||||
tracks.extend(load_track_items_by_ids(pool, &ids).await?);
|
.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 (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let (federation_tracks, federation_error) = if config.federation_enabled {
|
let (federation_tracks, federation_error, queried_peers) =
|
||||||
match crate::federation::handle()
|
if config.federation_enabled && !options.0.local_only {
|
||||||
.search_similarity(query, 50)
|
match crate::federation::handle()
|
||||||
.await
|
.search_similarity(query, 50)
|
||||||
{
|
|
||||||
Ok(remote) => match crate::federation::handle()
|
|
||||||
.prepare_similarity_tracks(remote)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(tracks) => (tracks, None),
|
Ok(outcome) => match crate::federation::handle()
|
||||||
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
|
.prepare_similarity_tracks(outcome.tracks)
|
||||||
},
|
.await
|
||||||
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
|
{
|
||||||
}
|
Ok(tracks) => (tracks, None, outcome.queried_peers),
|
||||||
} else {
|
Err(error) => (
|
||||||
(Vec::new(), None)
|
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
|
let artists = source_track
|
||||||
.artists
|
.artists
|
||||||
.iter()
|
.iter()
|
||||||
@@ -4423,6 +4461,9 @@ async fn similarity_search_handler(
|
|||||||
tracks,
|
tracks,
|
||||||
federation_tracks,
|
federation_tracks,
|
||||||
federation_error,
|
federation_error,
|
||||||
|
queried_peers,
|
||||||
|
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||||
|
complete: !options.0.local_only,
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -9934,7 +9975,8 @@ impl App for PlayerApp {
|
|||||||
move |auth_ctx: auth::AuthContext,
|
move |auth_ctx: auth::AuthContext,
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
path: Path<PathId>| {
|
path: Path<PathId>,
|
||||||
|
query: cot::request::extractors::UrlQuery<SimilaritySearchQuery>| {
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
async move {
|
async move {
|
||||||
@@ -9947,7 +9989,7 @@ impl App for PlayerApp {
|
|||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
similarity_search_handler(auth_ctx, session, db, pg_pool, path).await
|
similarity_search_handler(auth_ctx, session, db, pg_pool, path, query).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
+142
-4
@@ -244,10 +244,61 @@ impl Manager {
|
|||||||
return;
|
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 {
|
if let Err(error) = self.restore_stored_status(&pool).await {
|
||||||
tracing::warn!(%error, "similarity boot: stored status unavailable");
|
tracing::warn!(%error, "similarity boot: stored status unavailable");
|
||||||
}
|
}
|
||||||
self.apply(config);
|
self.apply(&effective);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply(self: &Arc<Self>, config: &AppConfig) {
|
pub fn apply(self: &Arc<Self>, config: &AppConfig) {
|
||||||
@@ -283,6 +334,90 @@ impl Manager {
|
|||||||
lock(&self.status).clone()
|
lock(&self.status).clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads compact routing signatures for every current visible embedding.
|
||||||
|
/// Embeddings created before DHT routing existed are upgraded in place;
|
||||||
|
/// the CPU-heavy projection runs outside the async runtime.
|
||||||
|
pub async fn routing_signatures(&self, profile_id: &str) -> Result<Vec<[u8; 32]>> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let missing = sqlx::query(
|
||||||
|
"SELECT e.track_id, e.dimensions, e.vector
|
||||||
|
FROM furumusic__track_embedding e
|
||||||
|
JOIN furumusic__track t ON t.id = e.track_id
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
WHERE e.profile_id = $1 AND e.source_sha256 = m.sha256_hash
|
||||||
|
AND t.is_hidden = FALSE AND r.is_hidden = FALSE
|
||||||
|
AND (e.routing_signature IS NULL
|
||||||
|
OR octet_length(e.routing_signature) != 32)
|
||||||
|
ORDER BY e.track_id",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
(
|
||||||
|
row.get::<i64, _>(0),
|
||||||
|
row.get::<i32, _>(1),
|
||||||
|
row.get::<Vec<u8>, _>(2),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let computed = tokio::task::spawn_blocking(move || {
|
||||||
|
missing
|
||||||
|
.into_iter()
|
||||||
|
.map(|(track_id, dimensions, bytes)| {
|
||||||
|
let vector = embedding_from_bytes(dimensions, &bytes)?;
|
||||||
|
let signature = music_dht::similarity_lsh::routing_signature(&vector)?;
|
||||||
|
Ok::<_, anyhow::Error>((track_id, signature))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("similarity routing backfill task failed")??;
|
||||||
|
|
||||||
|
if !computed.is_empty() {
|
||||||
|
let mut transaction = pool.begin().await?;
|
||||||
|
for (track_id, signature) in computed {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE furumusic__track_embedding
|
||||||
|
SET routing_signature = $3
|
||||||
|
WHERE track_id = $1 AND profile_id = $2
|
||||||
|
AND (routing_signature IS NULL
|
||||||
|
OR octet_length(routing_signature) != 32)",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.bind(profile_id)
|
||||||
|
.bind(signature.as_slice())
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
transaction.commit().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored = sqlx::query_scalar::<_, Vec<u8>>(
|
||||||
|
"SELECT e.routing_signature
|
||||||
|
FROM furumusic__track_embedding e
|
||||||
|
JOIN furumusic__track t ON t.id = e.track_id
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
WHERE e.profile_id = $1 AND e.source_sha256 = m.sha256_hash
|
||||||
|
AND t.is_hidden = FALSE AND r.is_hidden = FALSE
|
||||||
|
ORDER BY e.track_id",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
stored
|
||||||
|
.into_iter()
|
||||||
|
.map(|signature| {
|
||||||
|
<[u8; 32]>::try_from(signature)
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid similarity routing signature length"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn start(self: &Arc<Self>) {
|
pub fn start(self: &Arc<Self>) {
|
||||||
let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
|
let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
let manager = Arc::clone(self);
|
let manager = Arc::clone(self);
|
||||||
@@ -846,14 +981,16 @@ async fn store_embedding(
|
|||||||
vector.iter().all(|value| value.is_finite()),
|
vector.iter().all(|value| value.is_finite()),
|
||||||
"embedding contains a non-finite value"
|
"embedding contains a non-finite value"
|
||||||
);
|
);
|
||||||
|
let routing_signature = music_dht::similarity_lsh::routing_signature(vector)?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO furumusic__track_embedding
|
"INSERT INTO furumusic__track_embedding
|
||||||
(track_id, profile_id, dimensions, vector, source_sha256,
|
(track_id, profile_id, dimensions, vector, routing_signature,
|
||||||
source_content_id, computed_at)
|
source_sha256, source_content_id, computed_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
ON CONFLICT (track_id, profile_id) DO UPDATE SET
|
ON CONFLICT (track_id, profile_id) DO UPDATE SET
|
||||||
dimensions = EXCLUDED.dimensions,
|
dimensions = EXCLUDED.dimensions,
|
||||||
vector = EXCLUDED.vector,
|
vector = EXCLUDED.vector,
|
||||||
|
routing_signature = EXCLUDED.routing_signature,
|
||||||
source_sha256 = EXCLUDED.source_sha256,
|
source_sha256 = EXCLUDED.source_sha256,
|
||||||
source_content_id = EXCLUDED.source_content_id,
|
source_content_id = EXCLUDED.source_content_id,
|
||||||
computed_at = EXCLUDED.computed_at",
|
computed_at = EXCLUDED.computed_at",
|
||||||
@@ -862,6 +999,7 @@ async fn store_embedding(
|
|||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.bind(vector.len() as i32)
|
.bind(vector.len() as i32)
|
||||||
.bind(embedding_to_bytes(vector))
|
.bind(embedding_to_bytes(vector))
|
||||||
|
.bind(routing_signature.as_slice())
|
||||||
.bind(&track.source_sha256)
|
.bind(&track.source_sha256)
|
||||||
.bind(&track.source_content_id)
|
.bind(&track.source_content_id)
|
||||||
.bind(now_iso())
|
.bind(now_iso())
|
||||||
|
|||||||
+311
-85
@@ -806,35 +806,68 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: none;
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-layout {
|
.settings-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px);
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
gap: 14px;
|
grid-template-areas:
|
||||||
align-items: start;
|
"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 {
|
.settings-column {
|
||||||
display: grid;
|
display: contents;
|
||||||
gap: 14px;
|
|
||||||
align-content: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-section-narrow .panel-head {
|
||||||
|
background: rgba(29, 185, 84, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-actions {
|
.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 {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 14px 16px;
|
||||||
padding: 14px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card {
|
.settings-card {
|
||||||
@@ -843,22 +876,26 @@ tbody tr:hover {
|
|||||||
|
|
||||||
.setting-field {
|
.setting-field {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-field.settings-short { max-width: 150px; }
|
||||||
|
.settings-wide { max-width: 680px; }
|
||||||
|
|
||||||
.setting-field label,
|
.setting-field label,
|
||||||
.setting-toggle label {
|
.setting-toggle label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 7px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-field input {
|
.setting-field input,
|
||||||
|
.setting-field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
@@ -869,13 +906,27 @@ tbody tr:hover {
|
|||||||
outline: none;
|
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);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-toggle {
|
.setting-toggle {
|
||||||
min-height: 74px;
|
min-height: 68px;
|
||||||
padding: 12px;
|
padding: 11px 12px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -894,9 +945,12 @@ tbody tr:hover {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-toggle input {
|
.setting-toggle input,
|
||||||
|
.setting-toggle-row input[type="checkbox"] {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +958,8 @@ tbody tr:hover {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: var(--text-subdued);
|
color: var(--text-subdued);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.4;
|
line-height: 1.45;
|
||||||
|
max-width: 68ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-pill {
|
.source-pill {
|
||||||
@@ -929,6 +984,101 @@ tbody tr:hover {
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.settings-note {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -937,7 +1087,7 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.probe-body {
|
.probe-body {
|
||||||
padding: 14px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.probe-intro {
|
.probe-intro {
|
||||||
@@ -955,9 +1105,43 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.probe-row {
|
.probe-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
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 {
|
.library-row {
|
||||||
@@ -1560,7 +1744,7 @@ tbody tr:hover {
|
|||||||
<p x-text="pageSubtitle()"></p>
|
<p x-text="pageSubtitle()"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<button class="btn" @click="refreshAll()">
|
<button class="btn" @click="refreshAll()" x-show="activeView !== 'settings'">
|
||||||
<i data-lucide="refresh-cw"></i>
|
<i data-lucide="refresh-cw"></i>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -2059,7 +2243,7 @@ tbody tr:hover {
|
|||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<form class="settings-layout" @submit.prevent="saveSettings()">
|
<form class="settings-layout" @submit.prevent="saveSettings()">
|
||||||
<div class="settings-column">
|
<div class="settings-column">
|
||||||
<section class="panel">
|
<section class="panel settings-section settings-section-wide settings-oidc">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong>OIDC</strong>
|
<strong>OIDC</strong>
|
||||||
@@ -2070,6 +2254,7 @@ tbody tr:hover {
|
|||||||
<div class="setting-field settings-wide">
|
<div class="setting-field settings-wide">
|
||||||
<label>Callback URL</label>
|
<label>Callback URL</label>
|
||||||
<input readonly :value="callbackUrl()" />
|
<input readonly :value="callbackUrl()" />
|
||||||
|
<div class="setting-help">Register this exact redirect URL in your identity provider.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field">
|
||||||
<label>
|
<label>
|
||||||
@@ -2105,6 +2290,7 @@ tbody tr:hover {
|
|||||||
<span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span>
|
<span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" />
|
<input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" />
|
||||||
|
<div class="setting-help">Comma-separated identity-provider groups whose members receive administrator access.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field">
|
||||||
<label>
|
<label>
|
||||||
@@ -2112,11 +2298,12 @@ tbody tr:hover {
|
|||||||
<span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span>
|
<span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input x-model="settingsDraft.oidc_user_groups" />
|
<input x-model="settingsDraft.oidc_user_groups" />
|
||||||
|
<div class="setting-help">Comma-separated groups allowed to sign in. Leave empty to allow any authenticated OIDC user.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel settings-section settings-section-wide settings-agent">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong>Agent</strong>
|
<strong>Agent</strong>
|
||||||
@@ -2134,12 +2321,13 @@ tbody tr:hover {
|
|||||||
<input type="checkbox" x-model="settingsDraft.agent_enabled" />
|
<input type="checkbox" x-model="settingsDraft.agent_enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field settings-short">
|
||||||
<label>
|
<label>
|
||||||
<span>Concurrency</span>
|
<span>Concurrency</span>
|
||||||
<span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span>
|
<span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" />
|
<input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" />
|
||||||
|
<div class="setting-help">Maximum number of inbox items processed at the same time. Higher values use more CPU and LLM capacity.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field settings-wide">
|
<div class="setting-field settings-wide">
|
||||||
<label>
|
<label>
|
||||||
@@ -2161,6 +2349,7 @@ tbody tr:hover {
|
|||||||
<span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span>
|
<span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input x-model="settingsDraft.agent_llm_url" />
|
<input x-model="settingsDraft.agent_llm_url" />
|
||||||
|
<div class="setting-help">Base URL of an OpenAI-compatible service. The agent sends chat requests to its <code>/v1/chat/completions</code> endpoint.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field">
|
||||||
<label>
|
<label>
|
||||||
@@ -2168,6 +2357,7 @@ tbody tr:hover {
|
|||||||
<span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span>
|
<span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input x-model="settingsDraft.agent_llm_model" />
|
<input x-model="settingsDraft.agent_llm_model" />
|
||||||
|
<div class="setting-help">Model identifier sent to the configured LLM service, for example the name exposed by your local model server.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field">
|
||||||
<label>
|
<label>
|
||||||
@@ -2175,6 +2365,7 @@ tbody tr:hover {
|
|||||||
<span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span>
|
<span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" />
|
<input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" />
|
||||||
|
<div class="setting-help">Complete HTTP Authorization value expected by the LLM endpoint, for example <code>Bearer …</code>.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field">
|
||||||
<label>
|
<label>
|
||||||
@@ -2182,6 +2373,7 @@ tbody tr:hover {
|
|||||||
<span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span>
|
<span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input x-model="settingsDraft.agent_confidence_threshold" />
|
<input x-model="settingsDraft.agent_confidence_threshold" />
|
||||||
|
<div class="setting-help">Minimum confidence required to accept generated metadata automatically. Lower-confidence results are sent for review.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field">
|
||||||
<label>
|
<label>
|
||||||
@@ -2189,11 +2381,12 @@ tbody tr:hover {
|
|||||||
<span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span>
|
<span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input x-model="settingsDraft.agent_context_limit" />
|
<input x-model="settingsDraft.agent_context_limit" />
|
||||||
|
<div class="setting-help">Maximum model context budget in tokens. Reduce it for smaller models or increase it when processing large batches.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel settings-section settings-section-wide settings-similarity">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong>Similarity Search</strong>
|
<strong>Similarity Search</strong>
|
||||||
@@ -2210,7 +2403,7 @@ tbody tr:hover {
|
|||||||
<span x-text="settingsDraft.similarity_enabled ? 'Enabled for this instance' : 'Disabled'"></span>
|
<span x-text="settingsDraft.similarity_enabled ? 'Enabled for this instance' : 'Disabled'"></span>
|
||||||
<input type="checkbox" x-model="settingsDraft.similarity_enabled" />
|
<input type="checkbox" x-model="settingsDraft.similarity_enabled" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-help">Downloads the selected model and processes every visible local track. When federation is also enabled, this instance sends anonymized query embeddings to peers and answers their searches.</div>
|
<div class="setting-help">Builds an audio fingerprint index for finding musically similar tracks. When federation is enabled, compatible peers can also participate in searches.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field settings-wide">
|
<div class="setting-field settings-wide">
|
||||||
<label>
|
<label>
|
||||||
@@ -2222,7 +2415,10 @@ tbody tr:hover {
|
|||||||
<option :value="model.id" x-text="`${model.id} · ${model.dimensions}d`"></option>
|
<option :value="model.id" x-text="`${model.id} · ${model.dimensions}d`"></option>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
<div class="setting-help" x-text="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license || ''"></div>
|
<div class="setting-help">
|
||||||
|
<span>The model converts audio into vectors used for comparison. Changing it rebuilds the search index.</span>
|
||||||
|
<span x-show="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license" x-text="' License: ' + (similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field settings-wide">
|
<div class="setting-field settings-wide">
|
||||||
<label>
|
<label>
|
||||||
@@ -2234,25 +2430,22 @@ tbody tr:hover {
|
|||||||
<option :value="profile.id" x-text="profile.title"></option>
|
<option :value="profile.id" x-text="profile.title"></option>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
<details class="setting-help" style="margin-top:8px">
|
<div class="setting-help">Controls how audio is decoded and normalized before comparison. Changing it rebuilds the search index.</div>
|
||||||
<summary style="cursor:pointer">Show profile details</summary>
|
|
||||||
<pre style="white-space:pre-wrap;font:inherit;margin:8px 0 0" x-text="selectedSimilarityProfile()?.details || 'Profile details are loading…'"></pre>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field settings-short">
|
||||||
<label>
|
<label>
|
||||||
<span>Background workers</span>
|
<span>Background workers</span>
|
||||||
<span class="source-pill" :class="sourceClass('similarity_workers')" x-text="settingSource('similarity_workers')"></span>
|
<span class="source-pill" :class="sourceClass('similarity_workers')" x-text="settingSource('similarity_workers')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" min="1" max="16" step="1" x-model="settingsDraft.similarity_workers" />
|
<input type="number" min="1" max="16" step="1" x-model="settingsDraft.similarity_workers" />
|
||||||
<div class="setting-help">Applied immediately after saving.</div>
|
<div class="setting-help">Number of tracks indexed in parallel. Higher values finish sooner but use more CPU and memory.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-column settings-side">
|
<div class="settings-column settings-side">
|
||||||
<section class="panel">
|
<section class="panel settings-section settings-section-narrow settings-access">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong>Authentication</strong>
|
<strong>Authentication</strong>
|
||||||
@@ -2283,26 +2476,15 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel settings-section settings-lastfm">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong>API</strong>
|
<strong>Last.fm Integration</strong>
|
||||||
<span>Developer and enrichment integrations</span>
|
<span>Metadata enrichment and scrobbling credentials</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
|
<span class="badge" :class="settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<div class="setting-toggle">
|
|
||||||
<label>
|
|
||||||
<span>Swagger UI</span>
|
|
||||||
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
|
|
||||||
</label>
|
|
||||||
<div class="setting-toggle-row">
|
|
||||||
<span x-text="settingsDraft.swagger_enabled ? 'Enabled' : 'Disabled'"></span>
|
|
||||||
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
|
|
||||||
</div>
|
|
||||||
<div class="setting-help">Interactive API docs at /swagger/ after restart.</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-field">
|
<div class="setting-field">
|
||||||
<label>
|
<label>
|
||||||
<span>{{ t.settings_lastfm_api_key }}</span>
|
<span>{{ t.settings_lastfm_api_key }}</span>
|
||||||
@@ -2322,7 +2504,29 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel settings-section settings-developer">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Developer API</strong>
|
||||||
|
<span>Interactive API documentation</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-toggle settings-wide">
|
||||||
|
<label>
|
||||||
|
<span>Swagger UI</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.swagger_enabled ? 'Available at /swagger/' : 'Disabled' "></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-help">Exposes interactive API documentation at <code>/swagger/</code> for developers and integrations.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel settings-section settings-section-full settings-federation">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong>Federation</strong>
|
<strong>Federation</strong>
|
||||||
@@ -2330,6 +2534,7 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
|
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-federation-body">
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<div class="setting-toggle">
|
<div class="setting-toggle">
|
||||||
<label>
|
<label>
|
||||||
@@ -2340,7 +2545,7 @@ tbody tr:hover {
|
|||||||
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
|
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
<input type="checkbox" x-model="settingsDraft.federation_enabled" />
|
<input type="checkbox" x-model="settingsDraft.federation_enabled" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-help">Applies immediately on save — no restart needed. Peers can browse and stream every visible track.</div>
|
<div class="setting-help">Lets other peers in this logical network discover the visible library and request audio streams from this instance.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field settings-wide">
|
<div class="setting-field settings-wide">
|
||||||
<label>
|
<label>
|
||||||
@@ -2348,9 +2553,9 @@ tbody tr:hover {
|
|||||||
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
|
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
|
||||||
</label>
|
</label>
|
||||||
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
|
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
|
||||||
<div class="setting-help">Every peer using the same id finds the others automatically.</div>
|
<div class="setting-help">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.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-field">
|
<div class="setting-field settings-wide">
|
||||||
<label>
|
<label>
|
||||||
<span>Save federated tracks on play</span>
|
<span>Save federated tracks on play</span>
|
||||||
<span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span>
|
<span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span>
|
||||||
@@ -2359,19 +2564,21 @@ tbody tr:hover {
|
|||||||
<span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span>
|
<span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span>
|
||||||
<input type="checkbox" x-model="settingsDraft.federation_save_on_listen" />
|
<input type="checkbox" x-model="settingsDraft.federation_save_on_listen" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-help">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.</div>
|
<div class="setting-help">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.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="probe-body" x-show="federationStatus.node">
|
<div class="probe-body" x-show="federationStatus.node">
|
||||||
|
<div class="federation-status-grid">
|
||||||
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
|
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
|
||||||
<div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div>
|
<div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div>
|
||||||
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div>
|
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div>
|
||||||
<div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div>
|
<div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div>
|
||||||
<div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div>
|
<div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Similarity routing peers</span><strong x-text="(federationStatus.node && federationStatus.node.similarity_routing_peers) ?? '-'"></strong></div>
|
||||||
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
|
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
|
||||||
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="probe-table" x-show="fedTransport().total_samples > 0" style="margin-top:10px">
|
<div class="probe-table" x-show="fedTransport().total_samples > 0">
|
||||||
<div class="probe-row">
|
<div class="probe-row">
|
||||||
<span>Transport path</span>
|
<span>Transport path</span>
|
||||||
<strong>
|
<strong>
|
||||||
@@ -2383,23 +2590,34 @@ tbody tr:hover {
|
|||||||
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
||||||
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
|
<div class="transport-log" x-show="fedTransport().last && fedTransport().last.length">
|
||||||
<template x-for="(sample, index) in fedTransport().last.slice(0, 5)" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
|
<div class="transport-log-head">
|
||||||
<div class="probe-row">
|
<strong>Recent transport operations</strong>
|
||||||
<span x-text="`${sample.protocol} · ${sample.direction} · ${sample.phase}`"></span>
|
<span>Newest first · updates automatically</span>
|
||||||
<strong>
|
</div>
|
||||||
<span class="badge" :class="fedPathBadge(sample.selected_path)" x-text="sample.selected_path || 'unknown'"></span>
|
<div class="transport-log-scroll">
|
||||||
<span x-text="` ${fedRtt(sample.selected_rtt_ms)} · tx ${formatBytes(sample.total_tx_bytes || 0)} · rx ${formatBytes(sample.total_rx_bytes || 0)} · lost ${formatBytes(sample.lost_bytes || 0)}`"></span>
|
<div class="transport-row transport-header">
|
||||||
</strong>
|
<span>Time</span><span>User / peer</span><span>Protocol</span><span>Direction</span><span>Phase</span><span>Path</span><span class="transport-number">RTT</span><span class="transport-number">TX</span><span class="transport-number">RX</span><span class="transport-number">Lost</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<template x-for="(sample, index) in fedTransport().last" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
|
||||||
|
<div class="transport-row">
|
||||||
|
<span :title="sample.at" x-text="formatTransportTime(sample.at)"></span>
|
||||||
|
<span :title="sample.user_name || sample.peer_id" x-text="sample.user_name || fedShort(sample.peer_id)"></span>
|
||||||
|
<span x-text="sample.protocol || '-'"></span>
|
||||||
|
<span x-text="sample.direction || '-'"></span>
|
||||||
|
<span x-text="sample.phase || '-'"></span>
|
||||||
|
<span x-text="sample.selected_path || 'unknown'"></span>
|
||||||
|
<span class="transport-number" x-text="fedRtt(sample.selected_rtt_ms)"></span>
|
||||||
|
<span class="transport-number" x-text="formatBytes(sample.total_tx_bytes || 0)"></span>
|
||||||
|
<span class="transport-number" x-text="formatBytes(sample.total_rx_bytes || 0)"></span>
|
||||||
|
<span class="transport-number" x-text="formatBytes(sample.lost_bytes || 0)"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
|
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
|
||||||
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
||||||
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
|
|
||||||
<i data-lucide="refresh-cw"></i>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
||||||
<i data-lucide="upload-cloud"></i>
|
<i data-lucide="upload-cloud"></i>
|
||||||
Publish now
|
Publish now
|
||||||
@@ -2421,13 +2639,14 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel settings-section settings-section-narrow settings-similarity-status">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong>Similarity Status</strong>
|
<strong>Search Index Statistics</strong>
|
||||||
<span>Model download, indexing, and active profile</span>
|
<span>Similarity model, indexing progress, and storage</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="similarityBadge()" x-text="similarityStatus.status?.phase || 'disabled'"></span>
|
<span class="badge" :class="similarityBadge()" x-text="similarityStatus.status?.phase || 'disabled'"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2444,15 +2663,15 @@ tbody tr:hover {
|
|||||||
<div class="probe-row"><span>Stored</span><strong x-text="`${similarityStatus.status?.stored_vectors || 0} vectors · ${formatBytes(similarityStatus.status?.stored_bytes || 0)}`"></strong></div>
|
<div class="probe-row"><span>Stored</span><strong x-text="`${similarityStatus.status?.stored_vectors || 0} vectors · ${formatBytes(similarityStatus.status?.stored_bytes || 0)}`"></strong></div>
|
||||||
<div class="probe-row" x-show="similarityStatus.status?.current_track"><span>Current track</span><strong x-text="similarityStatus.status?.current_track"></strong></div>
|
<div class="probe-row" x-show="similarityStatus.status?.current_track"><span>Current track</span><strong x-text="similarityStatus.status?.current_track"></strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="similarity-profile-details">
|
||||||
|
<span>Selected preprocessing profile</span>
|
||||||
|
<pre x-text="selectedSimilarityProfile()?.details || 'Profile information is not available.'"></pre>
|
||||||
|
</div>
|
||||||
<div style="height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-top:12px" x-show="similarityStatus.status?.phase === 'processing'">
|
<div style="height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-top:12px" x-show="similarityStatus.status?.phase === 'processing'">
|
||||||
<div style="height:100%;background:var(--accent);transition:width .25s" :style="`width:${similarityProgress()}%`"></div>
|
<div style="height:100%;background:var(--accent);transition:width .25s" :style="`width:${similarityProgress()}%`"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="probe-intro muted" x-show="similarityStatus.status?.last_error" x-text="similarityStatus.status?.last_error"></p>
|
<p class="probe-intro muted" x-show="similarityStatus.status?.last_error" x-text="similarityStatus.status?.last_error"></p>
|
||||||
<div class="toolbar" style="margin-top:14px;flex-wrap:wrap;gap:8px">
|
<div class="toolbar" style="margin-top:14px;flex-wrap:wrap;gap:8px">
|
||||||
<button class="btn" type="button" @click="loadSimilarity()" :disabled="similarityLoading">
|
|
||||||
<i data-lucide="refresh-cw"></i>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<button class="btn danger" type="button" @click="clearSimilarityEmbeddings()" :disabled="similarityLoading || !(similarityStatus.status?.stored_vectors > 0)">
|
<button class="btn danger" type="button" @click="clearSimilarityEmbeddings()" :disabled="similarityLoading || !(similarityStatus.status?.stored_vectors > 0)">
|
||||||
<i data-lucide="trash-2"></i>
|
<i data-lucide="trash-2"></i>
|
||||||
Clear all embeddings
|
Clear all embeddings
|
||||||
@@ -2461,7 +2680,7 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel settings-section settings-section-narrow settings-agent-status">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong>Agent Status</strong>
|
<strong>Agent Status</strong>
|
||||||
@@ -2492,10 +2711,6 @@ tbody tr:hover {
|
|||||||
<div class="action-strip settings-actions">
|
<div class="action-strip settings-actions">
|
||||||
<span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span>
|
<span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn" type="button" @click="loadSettings()">
|
|
||||||
<i data-lucide="refresh-cw"></i>
|
|
||||||
Reload
|
|
||||||
</button>
|
|
||||||
<button class="btn primary" type="submit" :disabled="settingsSaving">
|
<button class="btn primary" type="submit" :disabled="settingsSaving">
|
||||||
<i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i>
|
<i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i>
|
||||||
<span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span>
|
<span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span>
|
||||||
@@ -2567,7 +2782,7 @@ tbody tr:hover {
|
|||||||
<div class="user-activity-row">
|
<div class="user-activity-row">
|
||||||
<div class="user-activity-cover">
|
<div class="user-activity-cover">
|
||||||
<template x-if="play.cover_url">
|
<template x-if="play.cover_url">
|
||||||
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy">
|
<img :src="play.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!play.cover_url">
|
<template x-if="!play.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
@@ -3496,6 +3711,17 @@ function adminV2() {
|
|||||||
return ms != null ? `${Math.round(Number(ms))} ms` : '-';
|
return ms != null ? `${Math.round(Number(ms))} ms` : '-';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
formatTransportTime(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!value || Number.isNaN(date.getTime())) return '-';
|
||||||
|
return date.toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async loadSettingsProbe(showErrors = true) {
|
async loadSettingsProbe(showErrors = true) {
|
||||||
this.settingsProbeLoading = true;
|
this.settingsProbeLoading = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -825,7 +825,7 @@
|
|||||||
@click.stop="$store.history.playFrom(idx)"
|
@click.stop="$store.history.playFrom(idx)"
|
||||||
:title="item.track?.title || item.track_title">
|
:title="item.track?.title || item.track_title">
|
||||||
<template x-if="item.track && item.track.cover_url">
|
<template x-if="item.track && item.track.cover_url">
|
||||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
<img :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!item.track || !item.track.cover_url">
|
<template x-if="!item.track || !item.track.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
|
|||||||
+253
-21
@@ -2198,6 +2198,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_dragOverIdx: null,
|
_dragOverIdx: null,
|
||||||
_pointerDragMove: null,
|
_pointerDragMove: null,
|
||||||
_pointerDragEnd: null,
|
_pointerDragEnd: null,
|
||||||
|
_playNextGroupId: null,
|
||||||
|
_playNextGroupSequence: 0,
|
||||||
|
|
||||||
add(track) {
|
add(track) {
|
||||||
this.addToEnd([track]);
|
this.addToEnd([track]);
|
||||||
@@ -2210,13 +2212,21 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
effectiveCurrentIndex() {
|
effectiveCurrentIndex() {
|
||||||
const currentTrack = Alpine.store('player')?.currentTrack || null;
|
const currentTrack = Alpine.store('player')?.currentTrack || null;
|
||||||
if (currentTrack?.id) {
|
const currentKey = this._trackIdentity(currentTrack);
|
||||||
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
|
if (currentKey) {
|
||||||
|
const index = this.tracks.findIndex(track => this._trackIdentity(track) === currentKey);
|
||||||
|
if (index >= 0) return index;
|
||||||
}
|
}
|
||||||
if (!this.tracks.length) return -1;
|
if (!this.tracks.length) return -1;
|
||||||
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
|
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_trackIdentity(track) {
|
||||||
|
if (track?.content_id) return `content:${track.content_id}`;
|
||||||
|
if (track?.id != null && track.id !== '') return `id:${String(track.id)}`;
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
queueItemState(index) {
|
queueItemState(index) {
|
||||||
const current = this.effectiveCurrentIndex();
|
const current = this.effectiveCurrentIndex();
|
||||||
if (current < 0) return 'upcoming';
|
if (current < 0) return 'upcoming';
|
||||||
@@ -2252,8 +2262,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
syncCurrentIndexToTrack(track) {
|
syncCurrentIndexToTrack(track) {
|
||||||
if (!track?.id || !this.tracks.length) return -1;
|
const key = this._trackIdentity(track);
|
||||||
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
|
if (!key || !this.tracks.length) return -1;
|
||||||
|
const index = this.tracks.findIndex(item => this._trackIdentity(item) === key);
|
||||||
if (index >= 0) this.currentIndex = index;
|
if (index >= 0) this.currentIndex = index;
|
||||||
return index;
|
return index;
|
||||||
},
|
},
|
||||||
@@ -2280,6 +2291,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
playRelease(tracks, startIndex) {
|
playRelease(tracks, startIndex) {
|
||||||
this.tracks = this._tracksForQueueAdd(tracks);
|
this.tracks = this._tracksForQueueAdd(tracks);
|
||||||
|
this._playNextGroupId = null;
|
||||||
this.playFromIndex(startIndex || 0);
|
this.playFromIndex(startIndex || 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2447,8 +2459,35 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_addNextLocal(tracks) {
|
_addNextLocal(tracks) {
|
||||||
const items = this._tracksWithJamDefaults(tracks);
|
const items = this._tracksWithJamDefaults(tracks);
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
const insertAt = Math.min(this.currentIndex + 1, this.tracks.length);
|
const current = this.effectiveCurrentIndex();
|
||||||
this.tracks.splice(insertAt, 0, ...items);
|
let insertAt = Math.min(Math.max(0, current + 1), this.tracks.length);
|
||||||
|
let groupId = this._playNextGroupId
|
||||||
|
|| this.tracks[insertAt]?._playNextGroupId
|
||||||
|
|| null;
|
||||||
|
if (groupId) this._playNextGroupId = groupId;
|
||||||
|
if (groupId) {
|
||||||
|
let lastGroupIndex = -1;
|
||||||
|
for (let index = current; index < this.tracks.length; index++) {
|
||||||
|
if (this.tracks[index]?._playNextGroupId === groupId) {
|
||||||
|
lastGroupIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastGroupIndex >= current) {
|
||||||
|
insertAt = lastGroupIndex + 1;
|
||||||
|
} else {
|
||||||
|
groupId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!groupId) {
|
||||||
|
this._playNextGroupSequence += 1;
|
||||||
|
groupId = `next-${Date.now()}-${this._playNextGroupSequence}`;
|
||||||
|
this._playNextGroupId = groupId;
|
||||||
|
}
|
||||||
|
const groupedItems = items.map(item => ({
|
||||||
|
...item,
|
||||||
|
_playNextGroupId: groupId,
|
||||||
|
}));
|
||||||
|
this.tracks.splice(insertAt, 0, ...groupedItems);
|
||||||
},
|
},
|
||||||
|
|
||||||
_removeLocal(idx) {
|
_removeLocal(idx) {
|
||||||
@@ -2471,6 +2510,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (toIdx < 0 || toIdx >= this.tracks.length) return;
|
if (toIdx < 0 || toIdx >= this.tracks.length) return;
|
||||||
const [track] = this.tracks.splice(fromIdx, 1);
|
const [track] = this.tracks.splice(fromIdx, 1);
|
||||||
this.tracks.splice(toIdx, 0, track);
|
this.tracks.splice(toIdx, 0, track);
|
||||||
|
this._playNextGroupId = null;
|
||||||
// Adjust currentIndex to follow the currently playing track
|
// Adjust currentIndex to follow the currently playing track
|
||||||
if (this.currentIndex === fromIdx) {
|
if (this.currentIndex === fromIdx) {
|
||||||
this.currentIndex = toIdx;
|
this.currentIndex = toIdx;
|
||||||
@@ -2484,6 +2524,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_clearLocal() {
|
_clearLocal() {
|
||||||
this.tracks = [];
|
this.tracks = [];
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
|
this._playNextGroupId = null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2508,6 +2549,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
searchLoading: false,
|
searchLoading: false,
|
||||||
similaritySearchLabel: '',
|
similaritySearchLabel: '',
|
||||||
similaritySearchError: '',
|
similaritySearchError: '',
|
||||||
|
similaritySearchStats: { loading: false, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 },
|
||||||
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
|
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
|
||||||
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
|
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
|
||||||
federationPreparing: {},
|
federationPreparing: {},
|
||||||
@@ -3400,6 +3442,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
||||||
if (!res.ok) throw new Error('failed');
|
if (!res.ok) throw new Error('failed');
|
||||||
this.searchResults = await res.json();
|
this.searchResults = await res.json();
|
||||||
|
this.applyFederationArtworkFallbacks();
|
||||||
} catch {
|
} catch {
|
||||||
this.searchResults = { artists: [], releases: [], tracks: [] };
|
this.searchResults = { artists: [], releases: [], tracks: [] };
|
||||||
}
|
}
|
||||||
@@ -3425,18 +3468,30 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.searchLoading = true;
|
this.searchLoading = true;
|
||||||
this.searchResults = null;
|
this.searchResults = null;
|
||||||
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
|
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
|
||||||
|
this.similaritySearchStats = { loading: true, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 };
|
||||||
Alpine.store('info').close();
|
Alpine.store('info').close();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/player/similarity/${id}`);
|
const completeRequest = fetch(`/api/player/similarity/${id}`);
|
||||||
const data = await response.json().catch(() => ({}));
|
const localResponse = await fetch(`/api/player/similarity/${id}?local_only=true`);
|
||||||
if (!response.ok) throw new Error(data.error || T.similarityFailed);
|
const localData = await localResponse.json().catch(() => ({}));
|
||||||
this.similaritySearchLabel = data.label || initialLabel;
|
if (!localResponse.ok) throw new Error(localData.error || T.similarityFailed);
|
||||||
|
this.similaritySearchLabel = localData.label || initialLabel;
|
||||||
this.searchQuery = this.similaritySearchLabel;
|
this.searchQuery = this.similaritySearchLabel;
|
||||||
this.searchResults = {
|
this.searchResults = {
|
||||||
artists: [],
|
artists: [],
|
||||||
releases: [],
|
releases: [],
|
||||||
tracks: Array.isArray(data.tracks) ? data.tracks : [],
|
tracks: Array.isArray(localData.tracks) ? localData.tracks : [],
|
||||||
};
|
};
|
||||||
|
this.searchLoading = false;
|
||||||
|
this.similaritySearchStats = {
|
||||||
|
loading: true,
|
||||||
|
...this.similarityResultCounts(this.searchResults.tracks, []),
|
||||||
|
peers: 0,
|
||||||
|
elapsed_ms: localData.elapsed_ms || 0,
|
||||||
|
};
|
||||||
|
const response = await completeRequest;
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || T.similarityFailed);
|
||||||
this.federationSearch = {
|
this.federationSearch = {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: data.federation_error || '',
|
error: data.federation_error || '',
|
||||||
@@ -3444,15 +3499,94 @@ document.addEventListener('alpine:init', () => {
|
|||||||
releases: [],
|
releases: [],
|
||||||
tracks: Array.isArray(data.federation_tracks) ? data.federation_tracks : [],
|
tracks: Array.isArray(data.federation_tracks) ? data.federation_tracks : [],
|
||||||
};
|
};
|
||||||
|
this.similaritySearchStats = {
|
||||||
|
loading: false,
|
||||||
|
...this.similarityResultCounts(
|
||||||
|
this.searchResults.tracks,
|
||||||
|
this.federationSearch.tracks
|
||||||
|
),
|
||||||
|
peers: Number(data.queried_peers || 0),
|
||||||
|
elapsed_ms: Number(data.elapsed_ms || 0),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.searchResults = { artists: [], releases: [], tracks: [] };
|
if (!this.searchResults) {
|
||||||
this.federationSearch = { loading: false, error: '', artists: [], releases: [], tracks: [] };
|
this.searchResults = { artists: [], releases: [], tracks: [] };
|
||||||
this.similaritySearchError = error?.message || T.similarityFailed;
|
this.similaritySearchError = error?.message || T.similarityFailed;
|
||||||
|
} else {
|
||||||
|
this.federationSearch = {
|
||||||
|
...this.federationSearch,
|
||||||
|
loading: false,
|
||||||
|
error: error?.message || T.similarityFailed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.similaritySearchStats = {
|
||||||
|
...this.similaritySearchStats,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
this._afterNavigation(options);
|
this._afterNavigation(options);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
similarityResultCounts(localTracks = [], federationTracks = []) {
|
||||||
|
const artists = new Set();
|
||||||
|
for (const track of localTracks) {
|
||||||
|
for (const artist of [...(track?.artists || []), ...(track?.featured_artists || [])]) {
|
||||||
|
const name = this.normalizeFederationSearchText(artist?.name);
|
||||||
|
if (name) artists.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const track of federationTracks) {
|
||||||
|
const metadata = track?.metadata || {};
|
||||||
|
for (const artist of [...(metadata.artists || []), ...(metadata.featured_artists || [])]) {
|
||||||
|
const name = this.normalizeFederationSearchText(artist?.name);
|
||||||
|
if (name) artists.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tracks: localTracks.length + federationTracks.length,
|
||||||
|
artists: artists.size,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
similarityTrackOrder(track) {
|
||||||
|
const score = Number(track?.similarity_score);
|
||||||
|
if (!Number.isFinite(score)) return 1000000;
|
||||||
|
return Math.max(0, Math.round((1 - score) * 100000));
|
||||||
|
},
|
||||||
|
|
||||||
|
similarityQueueTracks() {
|
||||||
|
const local = (this.searchResults?.tracks || []).map(track => ({ ...track }));
|
||||||
|
const federated = (this.federationSearch?.tracks || []).map(track => ({
|
||||||
|
...this.federationQueueTrack(track),
|
||||||
|
similarity_score: track.similarity_score,
|
||||||
|
}));
|
||||||
|
return [...local, ...federated].sort((left, right) => {
|
||||||
|
const score = Number(right?.similarity_score || 0)
|
||||||
|
- Number(left?.similarity_score || 0);
|
||||||
|
if (score) return score;
|
||||||
|
return String(left?.title || '').localeCompare(String(right?.title || ''));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
playSimilarityResult(track) {
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
const tracks = this.similarityQueueTracks();
|
||||||
|
const key = queue._trackIdentity(track);
|
||||||
|
const index = tracks.findIndex(item => queue._trackIdentity(item) === key);
|
||||||
|
if (index >= 0) queue.playRelease(tracks, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatSearchDuration(milliseconds) {
|
||||||
|
const ms = Math.max(0, Number(milliseconds) || 0);
|
||||||
|
if (ms < 10000) return `${(ms / 1000).toFixed(1)} s`;
|
||||||
|
if (ms < 60000) return `${Math.round(ms / 1000)} s`;
|
||||||
|
const totalSeconds = Math.round(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
||||||
|
},
|
||||||
|
|
||||||
clearSearch() {
|
clearSearch() {
|
||||||
this.stopFederationSearch();
|
this.stopFederationSearch();
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
@@ -3460,6 +3594,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
this.similaritySearchLabel = '';
|
this.similaritySearchLabel = '';
|
||||||
this.similaritySearchError = '';
|
this.similaritySearchError = '';
|
||||||
|
this.similaritySearchStats = { loading: false, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 };
|
||||||
if (this.view === 'search') {
|
if (this.view === 'search') {
|
||||||
this.view = this._previousView || 'artists';
|
this.view = this._previousView || 'artists';
|
||||||
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
||||||
@@ -3518,13 +3653,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
};
|
};
|
||||||
source.addEventListener('federation.track', upsertTrack);
|
source.addEventListener('federation.track', upsertTrack);
|
||||||
source.addEventListener('federation.artist', event => {
|
source.addEventListener('federation.artist', event => {
|
||||||
const artist = JSON.parse(event.data)?.entity;
|
const artist = this.withFederationArtistFallback(
|
||||||
|
JSON.parse(event.data)?.entity
|
||||||
|
);
|
||||||
const key = artist?.key?.normalized_name;
|
const key = artist?.key?.normalized_name;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
updateResults('artists', item => item.key.normalized_name, item => item.name, artist);
|
updateResults('artists', item => item.key.normalized_name, item => item.name, artist);
|
||||||
});
|
});
|
||||||
source.addEventListener('federation.release', event => {
|
source.addEventListener('federation.release', event => {
|
||||||
const release = JSON.parse(event.data)?.entity;
|
const release = this.hydrateFederationSearchRelease(
|
||||||
|
JSON.parse(event.data)?.entity
|
||||||
|
);
|
||||||
if (!release?.key) return;
|
if (!release?.key) return;
|
||||||
updateResults('releases', item => JSON.stringify(item.key || {}), item => item.title, release);
|
updateResults('releases', item => JSON.stringify(item.key || {}), item => item.title, release);
|
||||||
});
|
});
|
||||||
@@ -3601,9 +3740,98 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
federationArtistImage(artist) {
|
federationArtistImage(artist) {
|
||||||
if (!artist?.name) return '';
|
if (!artist?.name) return '';
|
||||||
|
if (artist._federationArtworkFailed) return artist.local_image_url || '';
|
||||||
return this.federationDiscoveredArtwork(artist.name);
|
return this.federationDiscoveredArtwork(artist.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
localArtistImage(name) {
|
||||||
|
const key = this.normalizeFederationSearchText(name);
|
||||||
|
return (this.searchResults?.artists || []).find(candidate =>
|
||||||
|
this.normalizeFederationSearchText(candidate.name) === key
|
||||||
|
)?.image_url || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
withFederationArtistFallback(artist) {
|
||||||
|
if (!artist) return artist;
|
||||||
|
return { ...artist, local_image_url: this.localArtistImage(artist.name) };
|
||||||
|
},
|
||||||
|
|
||||||
|
applyFederationArtworkFallbacks() {
|
||||||
|
this.federationSearch = {
|
||||||
|
...this.federationSearch,
|
||||||
|
artists: this.federationSearch.artists.map(artist =>
|
||||||
|
this.withFederationArtistFallback(artist)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
federationArtistImageFailed(artist) {
|
||||||
|
const key = artist?.key?.normalized_name;
|
||||||
|
if (!key) return;
|
||||||
|
this.federationSearch = {
|
||||||
|
...this.federationSearch,
|
||||||
|
artists: this.federationSearch.artists.map(candidate =>
|
||||||
|
candidate?.key?.normalized_name === key
|
||||||
|
? {
|
||||||
|
...candidate,
|
||||||
|
_federationArtworkFailed: true,
|
||||||
|
local_image_url: candidate.local_image_url
|
||||||
|
|| this.localArtistImage(candidate.name),
|
||||||
|
}
|
||||||
|
: candidate
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
hydrateFederationSearchRelease(release) {
|
||||||
|
if (!release) return release;
|
||||||
|
const title = this.normalizeFederationSearchText(release.title);
|
||||||
|
const primaryArtists = (release.key?.primary_artists || [])
|
||||||
|
.map(name => this.normalizeFederationSearchText(name));
|
||||||
|
const tracks = this.federationSearch.tracks.filter(track => {
|
||||||
|
const metadata = track?.metadata || {};
|
||||||
|
if (this.normalizeFederationSearchText(metadata.release?.title) !== title) return false;
|
||||||
|
if (release.year && metadata.year && Number(release.year) !== Number(metadata.year)) return false;
|
||||||
|
if (!primaryArtists.length) return true;
|
||||||
|
const trackArtists = (metadata.artists || []).map(artist =>
|
||||||
|
this.normalizeFederationSearchText(artist.name)
|
||||||
|
);
|
||||||
|
return primaryArtists.some(artist => trackArtists.includes(artist));
|
||||||
|
});
|
||||||
|
const owners = [...new Set([
|
||||||
|
...(release.sources || []).map(source => source.owner),
|
||||||
|
...tracks.flatMap(track =>
|
||||||
|
(track.availability?.federation || []).map(source => source.owner)
|
||||||
|
),
|
||||||
|
].filter(Boolean))];
|
||||||
|
return { ...release, tracks, owners };
|
||||||
|
},
|
||||||
|
|
||||||
|
federationReleaseCover(release) {
|
||||||
|
if (!release) return '';
|
||||||
|
if (release._federationArtworkFailed) return release._discoveredCoverUrl || '';
|
||||||
|
return release.cover_url
|
||||||
|
|| this.federationDiscoveredArtwork(release.artists?.[0], release.title);
|
||||||
|
},
|
||||||
|
|
||||||
|
federationReleaseCoverFailed(release, failedUrl) {
|
||||||
|
const discovered = this.federationDiscoveredArtwork(release?.artists?.[0], release?.title);
|
||||||
|
if (!release?.key) return;
|
||||||
|
const key = JSON.stringify(release.key);
|
||||||
|
this.federationSearch = {
|
||||||
|
...this.federationSearch,
|
||||||
|
releases: this.federationSearch.releases.map(candidate =>
|
||||||
|
JSON.stringify(candidate?.key) === key
|
||||||
|
? {
|
||||||
|
...candidate,
|
||||||
|
_federationArtworkFailed: true,
|
||||||
|
_discoveredCoverUrl: failedUrl === discovered ? '' : discovered,
|
||||||
|
}
|
||||||
|
: candidate
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
federationDiscoveredArtwork(artist, release = '') {
|
federationDiscoveredArtwork(artist, release = '') {
|
||||||
if (!artist) return '';
|
if (!artist) return '';
|
||||||
const params = new URLSearchParams({ artist });
|
const params = new URLSearchParams({ artist });
|
||||||
@@ -3696,22 +3924,26 @@ document.addEventListener('alpine:init', () => {
|
|||||||
uploader_name: 'Federation',
|
uploader_name: 'Federation',
|
||||||
federation_pending: true,
|
federation_pending: true,
|
||||||
_federationTrack: track,
|
_federationTrack: track,
|
||||||
|
similarity_score: track.similarity_score,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
openFederatedRelease(release, options = {}) {
|
openFederatedRelease(release, options = {}) {
|
||||||
if (!release?.key) return;
|
if (!release?.key) return;
|
||||||
this._federatedReleaseCache[release.key] = release;
|
const cacheKey = typeof release.key === 'string'
|
||||||
this._beginNavigation('#releasefed?key=' + encodeURIComponent(release.key), options);
|
? release.key
|
||||||
|
: JSON.stringify(release.key);
|
||||||
|
this._federatedReleaseCache[cacheKey] = release;
|
||||||
|
this._beginNavigation('#releasefed?key=' + encodeURIComponent(cacheKey), options);
|
||||||
const queuedTracks = (release.tracks || []).map(track => this.federationQueueTrack(track));
|
const queuedTracks = (release.tracks || []).map(track => this.federationQueueTrack(track));
|
||||||
const first = queuedTracks[0];
|
const first = queuedTracks[0];
|
||||||
this.currentRelease = {
|
this.currentRelease = {
|
||||||
id: null,
|
id: null,
|
||||||
title: release.title,
|
title: release.title,
|
||||||
release_type: release.release_type || 'release',
|
release_type: release.release_type || release.key?.release_type || 'release',
|
||||||
year: release.year,
|
year: release.year,
|
||||||
cover_url: release.cover_url,
|
cover_url: this.federationReleaseCover(release),
|
||||||
artists: first?.artists || [],
|
artists: first?.artists || (release.artists || []).map(name => ({ id: null, name })),
|
||||||
tracks: queuedTracks,
|
tracks: queuedTracks,
|
||||||
uploaders: (release.owners || []).map(owner => ({
|
uploaders: (release.owners || []).map(owner => ({
|
||||||
name: `Federation ${owner.slice(0, 10)}`,
|
name: `Federation ${owner.slice(0, 10)}`,
|
||||||
|
|||||||
+89
-52
@@ -17,6 +17,17 @@
|
|||||||
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-widget-actions">
|
<div class="user-widget-actions">
|
||||||
|
<button class="user-logout-btn"
|
||||||
|
x-show="$store.user.profile?.role === 'admin'"
|
||||||
|
x-cloak
|
||||||
|
@click="window.location.href = '/admin/'"
|
||||||
|
title="{{ t.player_admin_panel }}"
|
||||||
|
aria-label="{{ t.player_admin_panel }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M12 3l7 3v5c0 4.6-2.9 8.1-7 10-4.1-1.9-7-5.4-7-10V6l7-3z"/>
|
||||||
|
<path d="M9.5 12l1.7 1.7 3.6-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="user-logout-btn" @click="$store.user.openSettings()" title="User settings" aria-label="User settings">
|
<button class="user-logout-btn" @click="$store.user.openSettings()" title="User settings" aria-label="User settings">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
@@ -80,7 +91,7 @@
|
|||||||
@click="$store.library.openArtist(artist.id)">
|
@click="$store.library.openArtist(artist.id)">
|
||||||
<div class="following-avatar">
|
<div class="following-avatar">
|
||||||
<template x-if="artist.image_url">
|
<template x-if="artist.image_url">
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!artist.image_url">
|
<template x-if="!artist.image_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
@@ -143,9 +154,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-bottom">
|
|
||||||
<a href="/admin/">{{ t.player_admin_panel }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="$store.mobile.libraryOpen">
|
<template x-if="$store.mobile.libraryOpen">
|
||||||
@@ -196,7 +204,7 @@
|
|||||||
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
|
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
|
||||||
<div class="following-avatar">
|
<div class="following-avatar">
|
||||||
<template x-if="artist.image_url">
|
<template x-if="artist.image_url">
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!artist.image_url">
|
<template x-if="!artist.image_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
@@ -359,6 +367,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-account-actions">
|
<div class="mobile-account-actions">
|
||||||
|
<button class="user-logout-btn"
|
||||||
|
x-show="$store.user.profile?.role === 'admin'"
|
||||||
|
x-cloak
|
||||||
|
@click="window.location.href = '/admin/'"
|
||||||
|
title="{{ t.player_admin_panel }}"
|
||||||
|
aria-label="{{ t.player_admin_panel }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M12 3l7 3v5c0 4.6-2.9 8.1-7 10-4.1-1.9-7-5.4-7-10V6l7-3z"/>
|
||||||
|
<path d="M9.5 12l1.7 1.7 3.6-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="user-logout-btn"
|
<button class="user-logout-btn"
|
||||||
@click="$store.user.menuOpen = false; $store.user.openSettings()"
|
@click="$store.user.menuOpen = false; $store.user.openSettings()"
|
||||||
title="User settings"
|
title="User settings"
|
||||||
@@ -379,12 +398,24 @@
|
|||||||
<!-- Search Results -->
|
<!-- Search Results -->
|
||||||
<template x-if="$store.library.view === 'search'">
|
<template x-if="$store.library.view === 'search'">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="search-similarity-title"
|
<div class="search-similarity-heading"
|
||||||
x-show="$store.library.similaritySearchLabel"
|
x-show="$store.library.similaritySearchLabel"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
<span>{{ t.player_search_similar_to }}</span>
|
<h2 class="search-similarity-title">
|
||||||
<strong x-text="$store.library.similaritySearchLabel"></strong>
|
<span>{{ t.player_search_similar_to }}</span>
|
||||||
</h2>
|
<strong x-text="$store.library.similaritySearchLabel"></strong>
|
||||||
|
</h2>
|
||||||
|
<div class="search-similarity-progress"
|
||||||
|
:class="{ loading: $store.library.similaritySearchStats.loading }">
|
||||||
|
<template x-if="$store.library.similaritySearchStats.loading">
|
||||||
|
<span class="search-progress-live"><i></i> Searching peers…</span>
|
||||||
|
</template>
|
||||||
|
<span x-text="`${$store.library.similaritySearchStats.tracks} tracks · ${$store.library.similaritySearchStats.artists} artists`"></span>
|
||||||
|
<template x-if="!$store.library.similaritySearchStats.loading">
|
||||||
|
<span x-text="`${$store.library.similaritySearchStats.peers} peers · ${$store.library.formatSearchDuration($store.library.similaritySearchStats.elapsed_ms)}`"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<template x-if="$store.library.searchLoading">
|
<template x-if="$store.library.searchLoading">
|
||||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -394,7 +425,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
||||||
<div>
|
<div :class="{ 'similarity-unified-results': $store.library.similaritySearchLabel }">
|
||||||
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
@@ -409,11 +440,9 @@
|
|||||||
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
|
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
|
||||||
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
|
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
|
||||||
<div class="search-artist-img">
|
<div class="search-artist-img">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
<template x-if="artist.image_url">
|
<template x-if="artist.image_url">
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
<img class="artwork-image" :src="artist.image_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||||
</template>
|
|
||||||
<template x-if="!artist.image_url">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-artist-name" x-text="artist.name"></div>
|
<div class="search-artist-name" x-text="artist.name"></div>
|
||||||
@@ -430,11 +459,9 @@
|
|||||||
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
|
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
|
||||||
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
|
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
|
||||||
<div class="search-release-cover" style="position:relative">
|
<div class="search-release-cover" style="position:relative">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
<template x-if="release.cover_url">
|
<template x-if="release.cover_url">
|
||||||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
<img class="artwork-image" :src="release.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||||
</template>
|
|
||||||
<template x-if="!release.cover_url">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
|
||||||
</template>
|
</template>
|
||||||
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
|
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||||
@@ -464,7 +491,8 @@
|
|||||||
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
|
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
|
||||||
<div class="track-row"
|
<div class="track-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||||
@dblclick="$store.library.playSearchTrack(idx)">
|
:style="$store.library.similaritySearchLabel ? `order:${$store.library.similarityTrackOrder(track)}` : ''"
|
||||||
|
@dblclick="$store.library.similaritySearchLabel ? $store.library.playSimilarityResult(track) : $store.library.playSearchTrack(idx)">
|
||||||
<span class="track-num" x-text="idx + 1"></span>
|
<span class="track-num" x-text="idx + 1"></span>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<div class="track-title" x-text="track.title"></div>
|
<div class="track-title" x-text="track.title"></div>
|
||||||
@@ -509,8 +537,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="search-section federation-search-section">
|
<div class="search-section federation-search-section"
|
||||||
<h2 class="search-section-title">
|
:class="{ 'similarity-federation-merged': $store.library.similaritySearchLabel }">
|
||||||
|
<h2 class="search-section-title" x-show="!$store.library.similaritySearchLabel">
|
||||||
Federation
|
Federation
|
||||||
<span class="federation-live-badge"
|
<span class="federation-live-badge"
|
||||||
x-show="$store.library.federationSearch.loading"
|
x-show="$store.library.federationSearch.loading"
|
||||||
@@ -520,11 +549,11 @@
|
|||||||
<div class="federation-search-status error"
|
<div class="federation-search-status error"
|
||||||
x-text="$store.library.federationSearch.error"></div>
|
x-text="$store.library.federationSearch.error"></div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="$store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
|
<template x-if="!$store.library.similaritySearchLabel && $store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
|
||||||
<div class="federation-search-status">Searching peers…</div>
|
<div class="federation-search-status">Searching peers…</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="search-artists-row"
|
<div class="search-artists-row"
|
||||||
x-show="$store.library.federationSearch.artists.length > 0"
|
x-show="!$store.library.similaritySearchLabel && $store.library.federationSearch.artists.length > 0"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
<template x-for="artist in $store.library.federationSearch.artists"
|
<template x-for="artist in $store.library.federationSearch.artists"
|
||||||
:key="artist.key.normalized_name">
|
:key="artist.key.normalized_name">
|
||||||
@@ -532,10 +561,12 @@
|
|||||||
@click="$store.library.openFederatedArtist(artist)">
|
@click="$store.library.openFederatedArtist(artist)">
|
||||||
<div class="search-artist-img">
|
<div class="search-artist-img">
|
||||||
<img x-show="$store.library.federationArtistImage(artist)"
|
<img x-show="$store.library.federationArtistImage(artist)"
|
||||||
|
class="artwork-image"
|
||||||
:src="$store.library.federationArtistImage(artist)"
|
:src="$store.library.federationArtistImage(artist)"
|
||||||
:alt="artist.name"
|
alt="" aria-hidden="true"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@error="$event.currentTarget.style.display = 'none'">
|
@load="$event.currentTarget.classList.add('artwork-loaded')"
|
||||||
|
@error="$event.currentTarget.classList.remove('artwork-loaded'); $store.library.federationArtistImageFailed(artist)">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/>
|
<circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -547,18 +578,21 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-releases-row"
|
<div class="search-releases-row"
|
||||||
x-show="$store.library.federationSearch.releases.length > 0"
|
x-show="!$store.library.similaritySearchLabel && $store.library.federationSearch.releases.length > 0"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
<template x-for="release in $store.library.federationSearch.releases"
|
<template x-for="release in $store.library.federationSearch.releases"
|
||||||
:key="JSON.stringify(release.key)">
|
:key="JSON.stringify(release.key)">
|
||||||
<div class="search-release-card federation-entity-card">
|
<div class="search-release-card federation-entity-card"
|
||||||
|
@click="$store.library.openFederatedRelease($store.library.hydrateFederationSearchRelease(release))">
|
||||||
<div class="search-release-cover">
|
<div class="search-release-cover">
|
||||||
<img x-show="release.cover_url"
|
<img x-show="$store.library.federationReleaseCover(release)"
|
||||||
:src="release.cover_url"
|
class="artwork-image"
|
||||||
:alt="release.title"
|
:src="$store.library.federationReleaseCover(release)"
|
||||||
loading="lazy">
|
alt="" aria-hidden="true"
|
||||||
<svg x-show="!release.cover_url"
|
loading="lazy"
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
@load="$event.currentTarget.classList.add('artwork-loaded')"
|
||||||
|
@error="$event.currentTarget.classList.remove('artwork-loaded'); $store.library.federationReleaseCoverFailed(release, $event.currentTarget.getAttribute('src'))">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
|
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +605,8 @@
|
|||||||
<template x-for="(track, idx) in $store.library.federationSearch.tracks"
|
<template x-for="(track, idx) in $store.library.federationSearch.tracks"
|
||||||
:key="track.key.content_id">
|
:key="track.key.content_id">
|
||||||
<div class="track-row federation-track-row"
|
<div class="track-row federation-track-row"
|
||||||
@dblclick="$store.library.playFederatedTrack(track)">
|
:style="$store.library.similaritySearchLabel ? `order:${$store.library.similarityTrackOrder(track)}` : ''"
|
||||||
|
@dblclick="$store.library.similaritySearchLabel ? $store.library.playSimilarityResult($store.library.federationQueueTrack(track)) : $store.library.playFederatedTrack(track)">
|
||||||
<span class="track-num federation-track-status">
|
<span class="track-num federation-track-status">
|
||||||
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
@@ -645,6 +680,12 @@
|
|||||||
x-text="formatTime(track.metadata.duration_seconds)"></span>
|
x-text="formatTime(track.metadata.duration_seconds)"></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div class="similarity-search-inline-progress"
|
||||||
|
x-show="$store.library.similaritySearchLabel && $store.library.similaritySearchStats.loading"
|
||||||
|
x-cloak>
|
||||||
|
<span class="similarity-search-inline-spinner" aria-hidden="true"></span>
|
||||||
|
<span>Searching federation for more similar tracks…</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -666,7 +707,7 @@
|
|||||||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||||
<div class="card-img">
|
<div class="card-img">
|
||||||
<template x-if="artist.image_url">
|
<template x-if="artist.image_url">
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!artist.image_url">
|
<template x-if="!artist.image_url">
|
||||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
||||||
@@ -722,7 +763,7 @@
|
|||||||
<div class="artist-img">
|
<div class="artist-img">
|
||||||
<template x-if="$store.library.currentArtist.image_url">
|
<template x-if="$store.library.currentArtist.image_url">
|
||||||
<img :src="$store.library.currentArtist.image_url"
|
<img :src="$store.library.currentArtist.image_url"
|
||||||
:alt="$store.library.currentArtist.name"
|
alt="" aria-hidden="true"
|
||||||
@error="$store.library.currentArtist.image_url = null">
|
@error="$store.library.currentArtist.image_url = null">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!$store.library.currentArtist.image_url">
|
<template x-if="!$store.library.currentArtist.image_url">
|
||||||
@@ -805,7 +846,7 @@
|
|||||||
:title="track.release_title"
|
:title="track.release_title"
|
||||||
aria-label="{{ t.player_release }}">
|
aria-label="{{ t.player_release }}">
|
||||||
<template x-if="track.cover_url">
|
<template x-if="track.cover_url">
|
||||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
<img :src="track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!track.cover_url">
|
<template x-if="!track.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
@@ -868,7 +909,7 @@
|
|||||||
<div class="card" @click="$store.library.openRelease(release.id)">
|
<div class="card" @click="$store.library.openRelease(release.id)">
|
||||||
<div class="card-img">
|
<div class="card-img">
|
||||||
<template x-if="release.cover_url">
|
<template x-if="release.cover_url">
|
||||||
<img :src="release.cover_url" :alt="release.title"
|
<img :src="release.cover_url" alt="" aria-hidden="true"
|
||||||
loading="lazy" @error="release.cover_url = null">
|
loading="lazy" @error="release.cover_url = null">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!release.cover_url">
|
<template x-if="!release.cover_url">
|
||||||
@@ -904,7 +945,7 @@
|
|||||||
@click="$store.library.openFederatedRelease(release)">
|
@click="$store.library.openFederatedRelease(release)">
|
||||||
<div class="card-img">
|
<div class="card-img">
|
||||||
<template x-if="release.cover_url">
|
<template x-if="release.cover_url">
|
||||||
<img :src="release.cover_url" :alt="release.title"
|
<img :src="release.cover_url" alt="" aria-hidden="true"
|
||||||
loading="lazy" @error="release.cover_url = null">
|
loading="lazy" @error="release.cover_url = null">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!release.cover_url">
|
<template x-if="!release.cover_url">
|
||||||
@@ -1034,7 +1075,7 @@
|
|||||||
:title="track.release_title"
|
:title="track.release_title"
|
||||||
aria-label="{{ t.player_release }}">
|
aria-label="{{ t.player_release }}">
|
||||||
<template x-if="track.cover_url">
|
<template x-if="track.cover_url">
|
||||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
<img :src="track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!track.cover_url">
|
<template x-if="!track.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
@@ -1108,7 +1149,7 @@
|
|||||||
<div class="release-header">
|
<div class="release-header">
|
||||||
<div class="release-cover">
|
<div class="release-cover">
|
||||||
<template x-if="$store.library.currentRelease.cover_url">
|
<template x-if="$store.library.currentRelease.cover_url">
|
||||||
<img :src="$store.library.currentRelease.cover_url" :alt="$store.library.currentRelease.title">
|
<img :src="$store.library.currentRelease.cover_url" alt="" aria-hidden="true">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!$store.library.currentRelease.cover_url">
|
<template x-if="!$store.library.currentRelease.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
@@ -1486,11 +1527,9 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-track-cover">
|
<div class="queue-track-cover">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
<template x-if="item.track.cover_url">
|
<template x-if="item.track.cover_url">
|
||||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
<img class="artwork-image" :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||||
</template>
|
|
||||||
<template x-if="!item.track.cover_url">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
||||||
</template>
|
</template>
|
||||||
<span class="queue-federation-status federation-track-status"
|
<span class="queue-federation-status federation-track-status"
|
||||||
x-show="item.track.federation_pending"
|
x-show="item.track.federation_pending"
|
||||||
@@ -1570,7 +1609,7 @@
|
|||||||
<div class="player-cover"
|
<div class="player-cover"
|
||||||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||||||
<template x-if="$store.player.currentTrack.cover_url">
|
<template x-if="$store.player.currentTrack.cover_url">
|
||||||
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
|
<img :src="$store.player.currentTrack.cover_url" alt="" aria-hidden="true">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!$store.player.currentTrack.cover_url">
|
<template x-if="!$store.player.currentTrack.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
@@ -1868,11 +1907,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
|
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
|
||||||
<div class="mobile-expanded-queue-cover">
|
<div class="mobile-expanded-queue-cover">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
<template x-if="item.track.cover_url">
|
<template x-if="item.track.cover_url">
|
||||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
<img class="artwork-image" :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||||
</template>
|
|
||||||
<template x-if="!item.track.cover_url">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-expanded-queue-info">
|
<div class="mobile-expanded-queue-info">
|
||||||
|
|||||||
@@ -450,22 +450,6 @@ button.user-stat:hover {
|
|||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-bottom {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-bottom a {
|
|
||||||
color: var(--text-subdued);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-bottom a:hover { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
/* Center Content */
|
/* Center Content */
|
||||||
.center-content {
|
.center-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1411,7 +1395,7 @@ button.user-stat:hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
.queue-track-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||||
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
||||||
|
|
||||||
.queue-track-cover .queue-federation-status {
|
.queue-track-cover .queue-federation-status {
|
||||||
@@ -2751,6 +2735,41 @@ button.user-stat:hover {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
}
|
}
|
||||||
|
.federation-search-section.similarity-federation-merged {
|
||||||
|
margin-top: -24px;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.similarity-unified-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.similarity-unified-results > .search-section {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.similarity-unified-results .search-section-title { order: -1000002; }
|
||||||
|
.similarity-unified-results .track-list-header { order: -1000001; }
|
||||||
|
.similarity-unified-results .federation-search-status { order: 1000001; }
|
||||||
|
.similarity-search-inline-progress {
|
||||||
|
order: 1000000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 42px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.similarity-search-inline-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, .16);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
animation: federation-progress-spin .8s linear infinite;
|
||||||
|
}
|
||||||
.federation-live-badge {
|
.federation-live-badge {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -2872,12 +2891,20 @@ button.user-stat:hover {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-similarity-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
.search-similarity-title {
|
.search-similarity-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 0 0 20px;
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -2886,6 +2913,42 @@ button.user-stat:hover {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
.search-similarity-progress {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.search-similarity-progress.loading { border-color: rgba(29, 185, 84, .38); }
|
||||||
|
.search-progress-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.search-progress-live i {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: currentColor;
|
||||||
|
animation: similarity-search-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes similarity-search-pulse {
|
||||||
|
50% { opacity: .3; transform: scale(.75); }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.search-similarity-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.search-similarity-progress { max-width: 100%; flex-wrap: wrap; white-space: normal; }
|
||||||
|
}
|
||||||
|
|
||||||
.search-artists-row {
|
.search-artists-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2959,6 +3022,7 @@ button.user-stat:hover {
|
|||||||
.search-release-card:hover { background: var(--bg-elevated); }
|
.search-release-card:hover { background: var(--bg-elevated); }
|
||||||
|
|
||||||
.search-release-cover {
|
.search-release-cover {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -2970,9 +3034,12 @@ button.user-stat:hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-release-cover img { width: 100%; height: 100%; object-fit: cover; }
|
.search-release-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.search-release-cover svg { width: 40px; height: 40px; color: var(--text-subdued); }
|
.search-release-cover svg { width: 40px; height: 40px; color: var(--text-subdued); }
|
||||||
|
|
||||||
|
.artwork-image { z-index: 1; opacity: 0; }
|
||||||
|
.artwork-image.artwork-loaded { opacity: 1; }
|
||||||
|
|
||||||
/* Like button */
|
/* Like button */
|
||||||
.like-btn {
|
.like-btn {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -5585,6 +5652,7 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-expanded-queue-cover {
|
.mobile-expanded-queue-cover {
|
||||||
|
position: relative;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -5596,6 +5664,8 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-expanded-queue-cover img {
|
.mobile-expanded-queue-cover img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|||||||
Reference in New Issue
Block a user