Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d68724f3 | ||
|
|
d71845509d | ||
|
|
4efcfdc539 | ||
|
|
d61d7a6bac | ||
|
|
cad8b2280f | ||
|
|
3a75c7d848 | ||
|
|
dc1fac1a94 | ||
|
|
122214a896 | ||
|
|
134f89fc9d | ||
|
|
232c171aac | ||
|
|
845df4e031 | ||
|
|
b0d8929b4c | ||
|
|
291265be7d |
Generated
+14
-22
@@ -806,15 +806,6 @@ version = "0.4.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "concurrent-queue"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -1673,11 +1664,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "5.4.1"
|
version = "5.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"concurrent-queue",
|
|
||||||
"parking",
|
"parking",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
@@ -1717,8 +1707,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15a8707baeccb46b5935138f9cb3df3c988c0730b807a2634d901f26b39250d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1845,7 +1836,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.8.4"
|
version = "0.9.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -3623,8 +3614,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f32daa9edf769fb5686e92ae6884e9fda6ea082452e4208309fc14301e26aef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -5296,9 +5288,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "1.2.1"
|
version = "1.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"ref-cast",
|
"ref-cast",
|
||||||
@@ -5559,7 +5551,7 @@ dependencies = [
|
|||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"schemars 0.9.0",
|
"schemars 0.9.0",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.2",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with_macros",
|
"serde_with_macros",
|
||||||
@@ -6616,9 +6608,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "1.1.3+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.9.0"
|
version = "0.9.8"
|
||||||
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"
|
||||||
|
|
||||||
@@ -39,4 +39,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 = { git = "https://gt.hexor.cy/ab/frid.git" }
|
music-dht = "0.3"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! Informational publication of the protocol versions exposed by this peer.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use music_dht::StreamAcceptor;
|
||||||
|
use music_dht::capabilities::{
|
||||||
|
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, read_message,
|
||||||
|
write_message,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::serve::AUDIO_PROTOCOL_VERSION;
|
||||||
|
|
||||||
|
fn local_manifest() -> CapabilityManifest {
|
||||||
|
CapabilityManifest::frid("furumusic", env!("CARGO_PKG_VERSION"))
|
||||||
|
// The web server does not expose federation Jam yet.
|
||||||
|
.without_protocol(JAM_ID)
|
||||||
|
.with_protocol("audio", AUDIO_PROTOCOL_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(mut acceptor: StreamAcceptor) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(error) = serve_one(stream).await {
|
||||||
|
tracing::debug!("capability stream failed: {error:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_one(mut stream: music_dht::ByteStream) -> Result<()> {
|
||||||
|
let response = match read_message(&mut stream).await? {
|
||||||
|
CapabilityMessage::Get {
|
||||||
|
version: CAPABILITIES_PROTOCOL_VERSION,
|
||||||
|
} => CapabilityMessage::Manifest {
|
||||||
|
manifest: local_manifest(),
|
||||||
|
},
|
||||||
|
CapabilityMessage::Get { version } => CapabilityMessage::Error {
|
||||||
|
message: format!("unsupported capability protocol {version}"),
|
||||||
|
},
|
||||||
|
_ => CapabilityMessage::Error {
|
||||||
|
message: "expected capability request".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write_message(&mut stream, &response).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_describes_only_supported_player_protocols() {
|
||||||
|
let manifest = local_manifest();
|
||||||
|
assert_eq!(manifest.application, "furumusic");
|
||||||
|
assert_eq!(
|
||||||
|
manifest.protocols.get("audio"),
|
||||||
|
Some(&AUDIO_PROTOCOL_VERSION)
|
||||||
|
);
|
||||||
|
assert!(!manifest.protocols.contains_key(JAM_ID));
|
||||||
|
manifest.validate().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
-10
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::device_sync::{ListenEndReason, ListenEvent, ListenTrackMetadata};
|
||||||
use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor};
|
use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
@@ -20,10 +21,10 @@ use crate::player::PlayerDeviceHub;
|
|||||||
|
|
||||||
use super::{TransportStats, record_stream_transport};
|
use super::{TransportStats, record_stream_transport};
|
||||||
|
|
||||||
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1";
|
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
|
||||||
|
|
||||||
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
const PROTOCOL_VERSION: u16 = 1;
|
const PROTOCOL_VERSION: u16 = 2;
|
||||||
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
|
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
|
||||||
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
|
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
|
||||||
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
|
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||||
@@ -31,6 +32,9 @@ const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
|
|||||||
const DEVICE_SYNC_INTERVAL: Duration = Duration::from_secs(2);
|
const DEVICE_SYNC_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
const LOCAL_SEED_RECHECK_MS: i64 = 60 * 1000;
|
const LOCAL_SEED_RECHECK_MS: i64 = 60 * 1000;
|
||||||
const MAX_LINE: usize = 8 * 1024 * 1024;
|
const MAX_LINE: usize = 8 * 1024 * 1024;
|
||||||
|
/// Playback commands carry queue state but are useful only briefly and only
|
||||||
|
/// to their addressed device. They must not inflate a new peer's catch-up.
|
||||||
|
const PLAYBACK_COMMAND_TTL_MS: i64 = 5 * 60 * 1_000;
|
||||||
const MAX_OPS_PER_BATCH: i64 = 1000;
|
const MAX_OPS_PER_BATCH: i64 = 1000;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -83,6 +87,7 @@ struct Identity {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct StoredDevice {
|
struct StoredDevice {
|
||||||
device_id: String,
|
device_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
endpoint_ticket: String,
|
endpoint_ticket: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +274,9 @@ enum SyncOpPayload {
|
|||||||
target_device_id: String,
|
target_device_id: String,
|
||||||
command: PlaybackCommand,
|
command: PlaybackCommand,
|
||||||
},
|
},
|
||||||
|
ListenRecorded {
|
||||||
|
event: ListenEvent,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncOpPayload {
|
impl SyncOpPayload {
|
||||||
@@ -771,6 +779,41 @@ pub async fn record_track_like(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn record_track_listen(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
track_id: i64,
|
||||||
|
listen_id: String,
|
||||||
|
started_at_ms: i64,
|
||||||
|
listened_ms: i64,
|
||||||
|
ended_reason: ListenEndReason,
|
||||||
|
) -> Result<()> {
|
||||||
|
let content_id = track_content_id(pool, track_id)
|
||||||
|
.await?
|
||||||
|
.context("track content id is not ready")?;
|
||||||
|
let track = synced_fed_track_for_track(pool, track_id, &content_id)
|
||||||
|
.await?
|
||||||
|
.context("track metadata is not available")?;
|
||||||
|
let event = ListenEvent {
|
||||||
|
listen_id,
|
||||||
|
content_id,
|
||||||
|
started_at_ms,
|
||||||
|
listened_ms,
|
||||||
|
track_duration_ms: track.duration_seconds.map(|seconds| seconds * 1_000),
|
||||||
|
ended_reason,
|
||||||
|
track: ListenTrackMetadata {
|
||||||
|
title: track.title,
|
||||||
|
artist_names: track.artist_names,
|
||||||
|
featured_artist_names: track.featured_artist_names,
|
||||||
|
release_title: track.release_title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if event.should_record() {
|
||||||
|
record_local_op(pool, user_id, SyncOpPayload::ListenRecorded { event }).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Records a like for a content-addressed track that need not be local yet.
|
/// Records a like for a content-addressed track that need not be local yet.
|
||||||
/// The ordinary local likes table remains a projection for materialized
|
/// The ordinary local likes table remains a projection for materialized
|
||||||
/// tracks; the durable user intent lives under `content_id`.
|
/// tracks; the durable user intent lives under `content_id`.
|
||||||
@@ -1534,8 +1577,7 @@ async fn sync_device(
|
|||||||
user_id: i64,
|
user_id: i64,
|
||||||
device: &StoredDevice,
|
device: &StoredDevice,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
let peer = resolve_device_peer(&service, device).await?;
|
||||||
let peer = service.connect(ticket).await?;
|
|
||||||
let own_ticket = service.ticket().await?.to_string();
|
let own_ticket = service.ticket().await?.to_string();
|
||||||
let identity = ensure_identity(pool, user_id, "").await?;
|
let identity = ensure_identity(pool, user_id, "").await?;
|
||||||
let profile = own_profile(pool, user_id, "", &own_ticket).await?;
|
let profile = own_profile(pool, user_id, "", &own_ticket).await?;
|
||||||
@@ -1712,7 +1754,7 @@ async fn local_seed_needs_metadata_backfill(pool: &sqlx::PgPool, user_id: i64) -
|
|||||||
async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> {
|
async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> {
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let like_rows = sqlx::query(
|
let like_rows = sqlx::query(
|
||||||
"SELECT ult.track_id, c.content_id
|
"SELECT ult.track_id, ult.created_at, c.content_id
|
||||||
FROM furumusic__user_liked_track ult
|
FROM furumusic__user_liked_track ult
|
||||||
JOIN furumusic__track t ON t.id = ult.track_id
|
JOIN furumusic__track t ON t.id = ult.track_id
|
||||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
@@ -1729,6 +1771,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let track_id: i64 = row.get("track_id");
|
let track_id: i64 = row.get("track_id");
|
||||||
|
let liked_hlc_ms = timestamp_from_iso(&row.get::<String, _>("created_at")).unwrap_or(now);
|
||||||
let fed_json = synced_fed_track_for_track(pool, track_id, &content_id)
|
let fed_json = synced_fed_track_for_track(pool, track_id, &content_id)
|
||||||
.await?
|
.await?
|
||||||
.map(serde_json::to_value)
|
.map(serde_json::to_value)
|
||||||
@@ -1743,7 +1786,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
|
|||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(&content_id)
|
.bind(&content_id)
|
||||||
.bind(now)
|
.bind(liked_hlc_ms)
|
||||||
.bind(format!("local_seed:like:{track_id}"))
|
.bind(format!("local_seed:like:{track_id}"))
|
||||||
.bind(track_id)
|
.bind(track_id)
|
||||||
.bind(fed_json)
|
.bind(fed_json)
|
||||||
@@ -2158,9 +2201,108 @@ async fn apply_op(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
SyncOpPayload::ListenRecorded { event } => {
|
||||||
|
apply_listen_event(pool, user_id, &op.origin_device_id, event).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn apply_listen_event(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
origin_device_id: &str,
|
||||||
|
event: &ListenEvent,
|
||||||
|
) -> Result<bool> {
|
||||||
|
if !event.should_record() || origin_device_id.trim().is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let content_id =
|
||||||
|
music_dht::normalize_content_id(&event.content_id).context("invalid listen content id")?;
|
||||||
|
let local_track_id: Option<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT local_track_id
|
||||||
|
FROM furumusic__track_ref
|
||||||
|
WHERE content_id = $1",
|
||||||
|
)
|
||||||
|
.bind(&content_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.flatten();
|
||||||
|
let inserted = sqlx::query(
|
||||||
|
"INSERT INTO furumusic__listen_event
|
||||||
|
(user_id, listen_id, content_id, local_track_id, origin_device_id,
|
||||||
|
started_at_ms, listened_ms, track_duration_ms, ended_reason,
|
||||||
|
qualified, metadata_json, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
ON CONFLICT (user_id, listen_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(&event.listen_id)
|
||||||
|
.bind(&content_id)
|
||||||
|
.bind(local_track_id)
|
||||||
|
.bind(origin_device_id)
|
||||||
|
.bind(event.started_at_ms)
|
||||||
|
.bind(event.listened_ms)
|
||||||
|
.bind(event.track_duration_ms)
|
||||||
|
.bind(serde_json::to_string(&event.ended_reason)?)
|
||||||
|
.bind(event.qualifies_as_play())
|
||||||
|
.bind(serde_json::to_value(&event.track)?)
|
||||||
|
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||||
|
.execute(pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected()
|
||||||
|
> 0;
|
||||||
|
if !inserted || !event.qualifies_as_play() {
|
||||||
|
return Ok(inserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(artist_name) = event
|
||||||
|
.track
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.find(|name| !name.trim().is_empty())
|
||||||
|
else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
let duration_seconds = event
|
||||||
|
.track_duration_ms
|
||||||
|
.unwrap_or_default()
|
||||||
|
.div_euclid(1_000)
|
||||||
|
.clamp(0, i64::from(i32::MAX)) as i32;
|
||||||
|
if duration_seconds <= 30 {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let listened_seconds = event
|
||||||
|
.listened_ms
|
||||||
|
.div_euclid(1_000)
|
||||||
|
.clamp(0, i64::from(i32::MAX)) as i32;
|
||||||
|
let started_at = event.started_at_ms.div_euclid(1_000);
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__lastfm_scrobble_outbox
|
||||||
|
(user_id, track_id, started_at, listened_seconds, duration_seconds,
|
||||||
|
status, created_at, updated_at, dedupe_key, track_title,
|
||||||
|
artist_name, album_title)
|
||||||
|
SELECT $1, $2, $3, $4, $5, 'pending', $6, $6, $7, $8, $9, $10
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM furumusic__lastfm_account WHERE user_id = $1
|
||||||
|
)
|
||||||
|
ON CONFLICT (dedupe_key) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(local_track_id)
|
||||||
|
.bind(started_at)
|
||||||
|
.bind(listened_seconds)
|
||||||
|
.bind(duration_seconds)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(format!("listen:{user_id}:{}", event.listen_id))
|
||||||
|
.bind(&event.track.title)
|
||||||
|
.bind(artist_name)
|
||||||
|
.bind(event.track.release_title.as_deref())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
async fn apply_like_state(
|
async fn apply_like_state(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
@@ -3281,12 +3423,16 @@ async fn local_playback_snapshot(
|
|||||||
user_id: i64,
|
user_id: i64,
|
||||||
identity: &Identity,
|
identity: &Identity,
|
||||||
) -> Option<PlaybackSnapshot> {
|
) -> Option<PlaybackSnapshot> {
|
||||||
let state = hub.current_playback_state_json(user_id)?;
|
// Keep publishing an inactive snapshot after a handoff. Omitting the
|
||||||
|
// snapshot left the last `active: true` value alive on trusted peers until
|
||||||
|
// its TTL elapsed, allowing the always-on web peer to reclaim playback.
|
||||||
|
let active = hub.federation_playback_is_local(user_id);
|
||||||
|
let state = hub.playback_state_json_for_commands(user_id)?;
|
||||||
let wire = playback_state_from_browser_json(pool, state).await.ok()?;
|
let wire = playback_state_from_browser_json(pool, state).await.ok()?;
|
||||||
Some(PlaybackSnapshot {
|
Some(PlaybackSnapshot {
|
||||||
device_id: identity.device_id.clone(),
|
device_id: identity.device_id.clone(),
|
||||||
device_name: identity.name.clone(),
|
device_name: identity.name.clone(),
|
||||||
active: true,
|
active,
|
||||||
updated_at_ms: now_ms(),
|
updated_at_ms: now_ms(),
|
||||||
state: wire,
|
state: wire,
|
||||||
})
|
})
|
||||||
@@ -3979,7 +4125,7 @@ pub async fn playlist_content_ids_for_removal(
|
|||||||
async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<StoredDevice>> {
|
async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<StoredDevice>> {
|
||||||
let identity = ensure_identity(pool, user_id, "").await?;
|
let identity = ensure_identity(pool, user_id, "").await?;
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT device_id, endpoint_ticket
|
"SELECT device_id, endpoint_id, endpoint_ticket
|
||||||
FROM furumusic__fed_device
|
FROM furumusic__fed_device
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND trusted_at_ms IS NOT NULL
|
AND trusted_at_ms IS NOT NULL
|
||||||
@@ -3995,11 +4141,31 @@ async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| StoredDevice {
|
.map(|row| StoredDevice {
|
||||||
device_id: row.get("device_id"),
|
device_id: row.get("device_id"),
|
||||||
|
endpoint_id: row.get("endpoint_id"),
|
||||||
endpoint_ticket: row.get("endpoint_ticket"),
|
endpoint_ticket: row.get("endpoint_ticket"),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_device_peer(
|
||||||
|
service: &MusicDhtService,
|
||||||
|
device: &StoredDevice,
|
||||||
|
) -> Result<music_dht::EndpointId> {
|
||||||
|
if let Ok(peer) = device.endpoint_id.parse::<music_dht::EndpointId>()
|
||||||
|
&& (service.connected_peers().contains(&peer)
|
||||||
|
|| service
|
||||||
|
.known_peers()
|
||||||
|
.iter()
|
||||||
|
.any(|contact| contact.peer_id == peer))
|
||||||
|
{
|
||||||
|
// Prefer the live/current-schema DHT contact over a persisted ticket
|
||||||
|
// that may have been issued before a schema upgrade.
|
||||||
|
return Ok(peer);
|
||||||
|
}
|
||||||
|
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||||
|
service.connect(ticket).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
async fn active_device_count(pool: &sqlx::PgPool, user_id: i64) -> Result<i64> {
|
async fn active_device_count(pool: &sqlx::PgPool, user_id: i64) -> Result<i64> {
|
||||||
sqlx::query_scalar(
|
sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM furumusic__fed_device
|
"SELECT COUNT(*) FROM furumusic__fed_device
|
||||||
@@ -4063,11 +4229,19 @@ async fn ops_for_peer(
|
|||||||
AND a.origin_device_id = o.origin_device_id
|
AND a.origin_device_id = o.origin_device_id
|
||||||
WHERE o.user_id = $1
|
WHERE o.user_id = $1
|
||||||
AND o.seq > COALESCE(a.max_seq, 0)
|
AND o.seq > COALESCE(a.max_seq, 0)
|
||||||
|
AND (
|
||||||
|
o.kind != 'playback_command'
|
||||||
|
OR (
|
||||||
|
o.hlc_ms >= $3
|
||||||
|
AND o.payload_json->>'target_device_id' = $2
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY o.hlc_ms, o.op_id
|
ORDER BY o.hlc_ms, o.op_id
|
||||||
LIMIT $3",
|
LIMIT $4",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(peer_device_id)
|
.bind(peer_device_id)
|
||||||
|
.bind(now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS))
|
||||||
.bind(MAX_OPS_PER_BATCH)
|
.bind(MAX_OPS_PER_BATCH)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -4421,6 +4595,7 @@ fn payload_kind(payload: &SyncOpPayload) -> &'static str {
|
|||||||
SyncOpPayload::DeviceTrusted { .. } => "device_trusted",
|
SyncOpPayload::DeviceTrusted { .. } => "device_trusted",
|
||||||
SyncOpPayload::DeviceRevoked { .. } => "device_revoked",
|
SyncOpPayload::DeviceRevoked { .. } => "device_revoked",
|
||||||
SyncOpPayload::PlaybackCommand { .. } => "playback_command",
|
SyncOpPayload::PlaybackCommand { .. } => "playback_command",
|
||||||
|
SyncOpPayload::ListenRecorded { .. } => "listen_recorded",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4536,6 +4711,12 @@ fn iso_from_ms(ms: i64) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn timestamp_from_iso(value: &str) -> Option<i64> {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(value)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.timestamp_millis())
|
||||||
|
}
|
||||||
|
|
||||||
fn now_label() -> String {
|
fn now_label() -> String {
|
||||||
let secs = (now_ms() / 1000).max(0);
|
let secs = (now_ms() / 1000).max(0);
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
|
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
|
||||||
//! starts, stops or re-joins the node without a server restart.
|
//! starts, stops or re-joins the node without a server restart.
|
||||||
|
|
||||||
|
mod capabilities;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
mod receive;
|
mod receive;
|
||||||
@@ -24,6 +25,7 @@ use std::sync::{Arc, OnceLock};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::capabilities::CAPABILITIES_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,
|
||||||
@@ -380,6 +382,7 @@ 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)
|
||||||
|
.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}"))?;
|
||||||
let (service, mut events) =
|
let (service, mut events) =
|
||||||
@@ -447,6 +450,10 @@ impl Federation {
|
|||||||
device_hub,
|
device_hub,
|
||||||
Arc::clone(&self.transport_stats),
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
|
let capabilities_acceptor = service
|
||||||
|
.stream_acceptor(CAPABILITIES_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the capabilities acceptor: {err}"))?;
|
||||||
|
let capabilities_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
|
||||||
|
|
||||||
*guard = Some(Running {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
@@ -458,6 +465,7 @@ impl Federation {
|
|||||||
catalog_task,
|
catalog_task,
|
||||||
device_task,
|
device_task,
|
||||||
device_sync_task,
|
device_sync_task,
|
||||||
|
capabilities_task,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
self.set_error(None);
|
self.set_error(None);
|
||||||
|
|||||||
+100
-8
@@ -229,6 +229,36 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn prepare_discovered_content_with_progress<F>(
|
||||||
|
self: &std::sync::Arc<Self>,
|
||||||
|
content_id: &str,
|
||||||
|
progress: F,
|
||||||
|
) -> Result<PreparedTrack>
|
||||||
|
where
|
||||||
|
F: FnMut(DownloadProgress) + Send,
|
||||||
|
{
|
||||||
|
let content_id =
|
||||||
|
music_dht::normalize_content_id(content_id).context("invalid content id")?;
|
||||||
|
let service = self.service().await?;
|
||||||
|
let outcome = service.search_content_id(&content_id).await?;
|
||||||
|
let item = outcome
|
||||||
|
.local_results
|
||||||
|
.into_iter()
|
||||||
|
.chain(outcome.network_results)
|
||||||
|
.find(|item| {
|
||||||
|
item.kind == music_dht::ItemKind::Track
|
||||||
|
&& item.content_id.as_deref() == Some(content_id.as_str())
|
||||||
|
})
|
||||||
|
.context("no peer currently advertises this content id")?;
|
||||||
|
self.prepare_content_with_progress(
|
||||||
|
&content_id,
|
||||||
|
&item.owner.to_string(),
|
||||||
|
&hex_encode(item.id.as_bytes()),
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn prepare_content_with_progress<F>(
|
pub async fn prepare_content_with_progress<F>(
|
||||||
self: &std::sync::Arc<Self>,
|
self: &std::sync::Arc<Self>,
|
||||||
content_id: &str,
|
content_id: &str,
|
||||||
@@ -295,7 +325,60 @@ impl Federation {
|
|||||||
let dir = storage_root.join("federation");
|
let dir = storage_root.join("federation");
|
||||||
tokio::fs::create_dir_all(&dir).await?;
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
let downloaded =
|
let downloaded =
|
||||||
download(&service, owner, item_id, &content_id, &dir, &mut progress).await?;
|
match download(&service, owner, item_id, &content_id, &dir, &mut progress).await {
|
||||||
|
Ok(downloaded) => downloaded,
|
||||||
|
Err(primary_error) => {
|
||||||
|
progress(DownloadProgress {
|
||||||
|
phase: "discovering",
|
||||||
|
received: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
let outcome = service
|
||||||
|
.search_content_id(&content_id)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
anyhow::anyhow!("{primary_error:#}; content lookup also failed: {err}")
|
||||||
|
})?;
|
||||||
|
let mut last_error = primary_error;
|
||||||
|
let mut downloaded = None;
|
||||||
|
for item in outcome
|
||||||
|
.local_results
|
||||||
|
.into_iter()
|
||||||
|
.chain(outcome.network_results)
|
||||||
|
{
|
||||||
|
if item.kind != music_dht::ItemKind::Track
|
||||||
|
|| item.content_id.as_deref() != Some(content_id.as_str())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let candidate_item_id = hex_encode(item.id.as_bytes());
|
||||||
|
if item.owner == owner && candidate_item_id == item_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match download(
|
||||||
|
&service,
|
||||||
|
item.owner,
|
||||||
|
&candidate_item_id,
|
||||||
|
&content_id,
|
||||||
|
&dir,
|
||||||
|
&mut progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(candidate) => {
|
||||||
|
downloaded = Some(candidate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => last_error = err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloaded.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"no reachable peer currently provides this track: {last_error:#}"
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
if !save {
|
if !save {
|
||||||
super::lock(&self.prepared_cache)
|
super::lock(&self.prepared_cache)
|
||||||
.insert(token.clone(), (downloaded.path, downloaded.mime));
|
.insert(token.clone(), (downloaded.path, downloaded.mime));
|
||||||
@@ -306,15 +389,20 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
progress(DownloadProgress {
|
progress(DownloadProgress {
|
||||||
phase: "saving",
|
phase: "saving",
|
||||||
received: 1,
|
received: 0,
|
||||||
total: 1,
|
total: 0,
|
||||||
});
|
});
|
||||||
let track_id = materialize(&pool, &storage_root, &content_id, downloaded).await?;
|
let track_id = materialize(&pool, &storage_root, &content_id, downloaded).await?;
|
||||||
// The normal periodic sync will publish it; this immediate sync keeps
|
// Materialization is the playback boundary: return the local track
|
||||||
// save-on-listen useful to the federation without waiting a minute.
|
// immediately so the browser can replace the pending queue entry and
|
||||||
if let Err(err) = self.sync_now().await {
|
// start it. Publishing must not keep the prepare stream stuck at 100%
|
||||||
tracing::warn!(track_id, "post-import federation publish failed: {err:#}");
|
// when a federation peer is slow or offline.
|
||||||
}
|
let federation = std::sync::Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = federation.sync_now().await {
|
||||||
|
tracing::warn!(track_id, "post-import federation publish failed: {err:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
Ok(PreparedTrack {
|
Ok(PreparedTrack {
|
||||||
local_track_id: Some(track_id),
|
local_track_id: Some(track_id),
|
||||||
stream_url: format!("/api/player/stream/{track_id}"),
|
stream_url: format!("/api/player/stream/{track_id}"),
|
||||||
@@ -362,6 +450,10 @@ fn cache_artwork(federation: &Federation, key: String, artwork: &(Vec<u8>, Strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn download(
|
async fn download(
|
||||||
service: &music_dht::MusicDhtService,
|
service: &music_dht::MusicDhtService,
|
||||||
owner: EndpointId,
|
owner: EndpointId,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use super::{TransportStats, record_stream_transport};
|
|||||||
|
|
||||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||||
|
/// Version of the peer-to-peer audio streaming protocol.
|
||||||
|
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
|
||||||
|
|
||||||
/// Maximum size of a JSON protocol line (request or response header).
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
|
|||||||
+5
-5
@@ -447,17 +447,17 @@ async fn fetch_pending_scrobbles(
|
|||||||
o.duration_seconds,
|
o.duration_seconds,
|
||||||
o.attempt_count,
|
o.attempt_count,
|
||||||
a.session_key::text AS session_key,
|
a.session_key::text AS session_key,
|
||||||
t.title::text AS title,
|
COALESCE(o.track_title, t.title::text) AS title,
|
||||||
r.title::text AS album_title,
|
COALESCE(o.album_title, r.title::text) AS album_title,
|
||||||
t.track_number,
|
t.track_number,
|
||||||
(
|
COALESCE(o.artist_name, (
|
||||||
SELECT ar.name::text
|
SELECT ar.name::text
|
||||||
FROM furumusic__track_artist ta
|
FROM furumusic__track_artist ta
|
||||||
JOIN furumusic__artist ar ON ar.id = ta.artist_id
|
JOIN furumusic__artist ar ON ar.id = ta.artist_id
|
||||||
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||||
ORDER BY ta.position
|
ORDER BY ta.position
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS artist_name,
|
)) AS artist_name,
|
||||||
(
|
(
|
||||||
SELECT ar.name::text
|
SELECT ar.name::text
|
||||||
FROM furumusic__release_artist ra
|
FROM furumusic__release_artist ra
|
||||||
@@ -468,7 +468,7 @@ async fn fetch_pending_scrobbles(
|
|||||||
) AS album_artist_name
|
) AS album_artist_name
|
||||||
FROM furumusic__lastfm_scrobble_outbox o
|
FROM furumusic__lastfm_scrobble_outbox o
|
||||||
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
||||||
JOIN furumusic__track t ON t.id = o.track_id
|
LEFT JOIN furumusic__track t ON t.id = o.track_id
|
||||||
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
WHERE o.user_id = $1
|
WHERE o.user_id = $1
|
||||||
AND o.status IN ('pending', 'retry')
|
AND o.status IN ('pending', 'retry')
|
||||||
|
|||||||
@@ -2322,6 +2322,160 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_content_addressed_music_refs).build()];
|
&[Operation::custom(create_content_addressed_music_refs).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_synced_listen_history(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__listen_event (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
listen_id TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
started_at_ms BIGINT NOT NULL,
|
||||||
|
listened_ms BIGINT NOT NULL,
|
||||||
|
track_duration_ms BIGINT,
|
||||||
|
ended_reason TEXT NOT NULL,
|
||||||
|
qualified BOOLEAN NOT NULL,
|
||||||
|
metadata_json JSONB NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, listen_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_listen_event_user_time
|
||||||
|
ON furumusic__listen_event (user_id, started_at_ms DESC, listen_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_listen_event_content
|
||||||
|
ON furumusic__listen_event (user_id, content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"INSERT INTO furumusic__listen_event
|
||||||
|
(user_id, listen_id, content_id, local_track_id,
|
||||||
|
origin_device_id, started_at_ms, listened_ms,
|
||||||
|
track_duration_ms, ended_reason, qualified,
|
||||||
|
metadata_json, created_at)
|
||||||
|
SELECT ph.user_id,
|
||||||
|
'legacy-web:' || ph.id::text,
|
||||||
|
tr.content_id,
|
||||||
|
ph.track_id,
|
||||||
|
ident.device_id,
|
||||||
|
(EXTRACT(EPOCH FROM ph.played_at::timestamptz) * 1000)::bigint,
|
||||||
|
COALESCE(ph.duration_listened, 0)::bigint * 1000,
|
||||||
|
(t.duration_seconds * 1000)::bigint,
|
||||||
|
CASE WHEN ph.completed THEN '\"finished\"' ELSE '\"unknown\"' END,
|
||||||
|
ph.completed,
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', t.title::text,
|
||||||
|
'artist_names', COALESCE((
|
||||||
|
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = t.id
|
||||||
|
AND ta.role <> 'featuring'
|
||||||
|
), '[]'::jsonb),
|
||||||
|
'featured_artist_names', COALESCE((
|
||||||
|
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = t.id
|
||||||
|
AND ta.role = 'featuring'
|
||||||
|
), '[]'::jsonb),
|
||||||
|
'release_title', r.title::text
|
||||||
|
),
|
||||||
|
ph.played_at::text
|
||||||
|
FROM furumusic__play_history ph
|
||||||
|
JOIN furumusic__track t ON t.id = ph.track_id
|
||||||
|
JOIN furumusic__track_ref tr ON tr.local_track_id = ph.track_id
|
||||||
|
JOIN furumusic__fed_device_identity ident
|
||||||
|
ON ident.user_id = ph.user_id
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
ON CONFLICT (user_id, listen_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__lastfm_scrobble_outbox
|
||||||
|
ALTER COLUMN track_id DROP NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__lastfm_scrobble_outbox
|
||||||
|
ADD COLUMN IF NOT EXISTS track_title TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS artist_name TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS album_title TEXT",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0041CreateSyncedListenHistory;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0041CreateSyncedListenHistory {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0041_create_synced_listen_history";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0040_create_content_addressed_music_refs",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_synced_listen_history).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn repair_legacy_listen_qualification(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"UPDATE furumusic__listen_event le
|
||||||
|
SET qualified = (
|
||||||
|
ph.completed
|
||||||
|
OR (
|
||||||
|
COALESCE(ph.duration_listened, 0) >= 5
|
||||||
|
AND COALESCE(t.duration_seconds, 0) > 0
|
||||||
|
AND COALESCE(ph.duration_listened, 0) >= LEAST(
|
||||||
|
COALESCE(t.duration_seconds, 0) / 2.0,
|
||||||
|
240.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM furumusic__play_history ph
|
||||||
|
JOIN furumusic__track t ON t.id = ph.track_id
|
||||||
|
WHERE le.user_id = ph.user_id
|
||||||
|
AND le.listen_id = 'legacy-web:' || ph.id::text",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0042RepairLegacyListenQualification;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0042RepairLegacyListenQualification {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0042_repair_legacy_listen_qualification";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0041_create_synced_listen_history",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(repair_legacy_listen_qualification).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -2353,5 +2507,7 @@ pub mod db_migrations {
|
|||||||
&M0038CreateFedDeviceSync,
|
&M0038CreateFedDeviceSync,
|
||||||
&M0039EnsureFederationContentIdCache,
|
&M0039EnsureFederationContentIdCache,
|
||||||
&M0040CreateContentAddressedMusicRefs,
|
&M0040CreateContentAddressedMusicRefs,
|
||||||
|
&M0041CreateSyncedListenHistory,
|
||||||
|
&M0042RepairLegacyListenQualification,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -78,6 +78,8 @@ pub(super) struct TrackItem {
|
|||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlaylistTrackItem {
|
pub(super) struct PlaylistTrackItem {
|
||||||
pub(super) playlist_track_id: Option<i64>,
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) sort_key: Option<i64>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub(super) track: TrackItem,
|
pub(super) track: TrackItem,
|
||||||
}
|
}
|
||||||
@@ -539,14 +541,16 @@ pub(super) struct UserUploadReviewUpdateRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlayHistoryItem {
|
pub(super) struct PlayHistoryItem {
|
||||||
pub(super) id: i64,
|
pub(super) id: String,
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
pub(super) track_title: String,
|
pub(super) track_title: String,
|
||||||
pub(super) release_title: Option<String>,
|
pub(super) release_title: Option<String>,
|
||||||
pub(super) track: TrackItem,
|
pub(super) track: serde_json::Value,
|
||||||
pub(super) played_at: String,
|
pub(super) played_at: String,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
|
pub(super) device_id: String,
|
||||||
|
pub(super) device_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -574,8 +578,8 @@ pub(super) struct ContentTrackMutation {
|
|||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub(super) struct PrepareFederatedTrackRequest {
|
pub(super) struct PrepareFederatedTrackRequest {
|
||||||
pub(super) content_id: String,
|
pub(super) content_id: String,
|
||||||
pub(super) owner: String,
|
pub(super) owner: Option<String>,
|
||||||
pub(super) item_id: String,
|
pub(super) item_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
|||||||
+488
-216
@@ -54,6 +54,7 @@ struct LocalUploadResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PLAYER_DEVICE_TTL_MS: i64 = 30_000;
|
const PLAYER_DEVICE_TTL_MS: i64 = 30_000;
|
||||||
|
const PLAYER_DEVICE_RETURN_TAKEOVER_MS: i64 = 30 * 60 * 1_000;
|
||||||
const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000;
|
const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000;
|
||||||
const PLAYER_DEVICE_MAX_COMMANDS: usize = 32;
|
const PLAYER_DEVICE_MAX_COMMANDS: usize = 32;
|
||||||
const PLAYER_JAM_IDLE_TTL_MS: i64 = 4 * 60 * 60 * 1000;
|
const PLAYER_JAM_IDLE_TTL_MS: i64 = 4 * 60 * 60 * 1000;
|
||||||
@@ -104,6 +105,7 @@ struct PlayerJamSession {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct PlayerDeviceHubState {
|
struct PlayerDeviceHubState {
|
||||||
devices_by_user: HashMap<i64, HashMap<String, PlayerDevice>>,
|
devices_by_user: HashMap<i64, HashMap<String, PlayerDevice>>,
|
||||||
|
device_last_seen_ms: HashMap<(i64, String), i64>,
|
||||||
active_device_by_user: HashMap<i64, String>,
|
active_device_by_user: HashMap<i64, String>,
|
||||||
commands_by_device: HashMap<(i64, String), VecDeque<PendingPlayerDeviceCommand>>,
|
commands_by_device: HashMap<(i64, String), VecDeque<PendingPlayerDeviceCommand>>,
|
||||||
playback_state_by_user: HashMap<i64, PlayerDevicePlaybackStateDto>,
|
playback_state_by_user: HashMap<i64, PlayerDevicePlaybackStateDto>,
|
||||||
@@ -153,19 +155,12 @@ impl PlayerDeviceHub {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn current_playback_state_json(&self, user_id: i64) -> Option<serde_json::Value> {
|
pub(crate) fn federation_playback_is_local(&self, user_id: i64) -> bool {
|
||||||
let state = self.state.lock().expect("player device hub lock");
|
let state = self.state.lock().expect("player device hub lock");
|
||||||
if state
|
!state
|
||||||
.active_device_by_user
|
.active_device_by_user
|
||||||
.get(&user_id)
|
.get(&user_id)
|
||||||
.is_some_and(|id| is_fed_virtual_device_id(id))
|
.is_some_and(|id| is_fed_virtual_device_id(id))
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
state
|
|
||||||
.playback_state_by_user
|
|
||||||
.get(&user_id)
|
|
||||||
.and_then(|playback| serde_json::to_value(playback).ok())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn playback_state_json_for_commands(
|
pub(crate) fn playback_state_json_for_commands(
|
||||||
@@ -264,14 +259,58 @@ impl PlayerDeviceHub {
|
|||||||
user_agent: Option<&str>,
|
user_agent: Option<&str>,
|
||||||
current_jam_id: Option<&str>,
|
current_jam_id: Option<&str>,
|
||||||
playback_state: Option<PlayerDevicePlaybackStateDto>,
|
playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||||
) -> PlayerDevicesResponse {
|
) -> (PlayerDevicesResponse, Option<String>) {
|
||||||
let now = current_millis();
|
let now = current_millis();
|
||||||
let mut state = self.state.lock().expect("player device hub lock");
|
let mut state = self.state.lock().expect("player device hub lock");
|
||||||
self.prune_locked(&mut state, now);
|
self.prune_locked(&mut state, now);
|
||||||
|
let is_new_or_returning = state
|
||||||
|
.device_last_seen_ms
|
||||||
|
.get(&(user_id, device_id.to_string()))
|
||||||
|
.is_none_or(|last_seen| {
|
||||||
|
now.saturating_sub(*last_seen) >= PLAYER_DEVICE_RETURN_TAKEOVER_MS
|
||||||
|
});
|
||||||
|
let previous_active_id = state.active_device_by_user.get(&user_id).cloned();
|
||||||
self.touch_locked(&mut state, user_id, device_id, user_agent, now);
|
self.touch_locked(&mut state, user_id, device_id, user_agent, now);
|
||||||
|
let active_is_playing = state
|
||||||
|
.playback_state_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.is_some_and(|playback| playback.track.is_some() && !playback.paused);
|
||||||
|
let should_claim_idle_playback = is_new_or_returning
|
||||||
|
&& previous_active_id.as_deref() != Some(device_id)
|
||||||
|
&& !active_is_playing;
|
||||||
|
if should_claim_idle_playback {
|
||||||
|
let transfer_state = state
|
||||||
|
.playback_state_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.cloned()
|
||||||
|
.map(|playback| playback_state_at(playback, now));
|
||||||
|
state
|
||||||
|
.active_device_by_user
|
||||||
|
.insert(user_id, device_id.to_string());
|
||||||
|
if let Some(transfer_state) = transfer_state {
|
||||||
|
state
|
||||||
|
.playback_state_by_user
|
||||||
|
.insert(user_id, transfer_state.clone());
|
||||||
|
if let Ok(payload) = serde_json::to_value(transfer_state) {
|
||||||
|
self.enqueue_command_locked(
|
||||||
|
&mut state,
|
||||||
|
user_id,
|
||||||
|
device_id,
|
||||||
|
"transfer_state",
|
||||||
|
payload,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now);
|
self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now);
|
||||||
self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now);
|
self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now);
|
||||||
self.snapshot_locked(&state, user_id, device_id, current_jam_id, now)
|
(
|
||||||
|
self.snapshot_locked(&state, user_id, device_id, current_jam_id, now),
|
||||||
|
should_claim_idle_playback
|
||||||
|
.then_some(previous_active_id)
|
||||||
|
.flatten(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll(
|
fn poll(
|
||||||
@@ -448,6 +487,9 @@ impl PlayerDeviceHub {
|
|||||||
last_seen_ms: now,
|
last_seen_ms: now,
|
||||||
};
|
};
|
||||||
devices.insert(device_id.to_string(), device);
|
devices.insert(device_id.to_string(), device);
|
||||||
|
state
|
||||||
|
.device_last_seen_ms
|
||||||
|
.insert((user_id, device_id.to_string()), now);
|
||||||
|
|
||||||
let active_online = state
|
let active_online = state
|
||||||
.active_device_by_user
|
.active_device_by_user
|
||||||
@@ -868,6 +910,9 @@ impl PlayerDeviceHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn prune_locked(&self, state: &mut PlayerDeviceHubState, now: i64) {
|
fn prune_locked(&self, state: &mut PlayerDeviceHubState, now: i64) {
|
||||||
|
state.device_last_seen_ms.retain(|_, last_seen| {
|
||||||
|
now.saturating_sub(*last_seen) <= PLAYER_DEVICE_RETURN_TAKEOVER_MS
|
||||||
|
});
|
||||||
state
|
state
|
||||||
.jams_by_id
|
.jams_by_id
|
||||||
.retain(|_, jam| now.saturating_sub(jam.host_last_seen_ms) <= PLAYER_JAM_IDLE_TTL_MS);
|
.retain(|_, jam| now.saturating_sub(jam.host_last_seen_ms) <= PLAYER_JAM_IDLE_TTL_MS);
|
||||||
@@ -1185,6 +1230,109 @@ mod device_tests {
|
|||||||
.is_some_and(|devices| devices.contains_key("fed:remote"))
|
.is_some_and(|devices| devices.contains_key("fed:remote"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_browser_claims_an_idle_federated_player() {
|
||||||
|
let hub = PlayerDeviceHub::default();
|
||||||
|
let user_id = 8;
|
||||||
|
hub.apply_fed_playback_state_json(
|
||||||
|
user_id,
|
||||||
|
"remote",
|
||||||
|
"Remote",
|
||||||
|
true,
|
||||||
|
serde_json::json!({
|
||||||
|
"track": {"id": 2},
|
||||||
|
"tracks": [],
|
||||||
|
"index": 0,
|
||||||
|
"position_seconds": 12.0,
|
||||||
|
"duration_seconds": 100.0,
|
||||||
|
"paused": true,
|
||||||
|
"shuffle": false,
|
||||||
|
"repeat_mode": "off",
|
||||||
|
"volume": 0.7
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("valid snapshot");
|
||||||
|
|
||||||
|
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
|
||||||
|
|
||||||
|
assert_eq!(response.active_device_id.as_deref(), Some("browser"));
|
||||||
|
assert_eq!(previous.as_deref(), Some("fed:remote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_browser_does_not_claim_a_playing_federated_player() {
|
||||||
|
let hub = PlayerDeviceHub::default();
|
||||||
|
let user_id = 9;
|
||||||
|
hub.apply_fed_playback_state_json(
|
||||||
|
user_id,
|
||||||
|
"remote",
|
||||||
|
"Remote",
|
||||||
|
true,
|
||||||
|
serde_json::json!({
|
||||||
|
"track": {"id": 2},
|
||||||
|
"tracks": [],
|
||||||
|
"index": 0,
|
||||||
|
"position_seconds": 12.0,
|
||||||
|
"duration_seconds": 100.0,
|
||||||
|
"paused": false,
|
||||||
|
"shuffle": false,
|
||||||
|
"repeat_mode": "off",
|
||||||
|
"volume": 0.7
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("valid snapshot");
|
||||||
|
|
||||||
|
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
|
||||||
|
|
||||||
|
assert_eq!(response.active_device_id.as_deref(), Some("fed:remote"));
|
||||||
|
assert_eq!(previous, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refreshed_control_browser_keeps_its_control_role() {
|
||||||
|
let hub = PlayerDeviceHub::default();
|
||||||
|
let user_id = 10;
|
||||||
|
hub.apply_fed_playback_state_json(
|
||||||
|
user_id,
|
||||||
|
"remote",
|
||||||
|
"Remote",
|
||||||
|
true,
|
||||||
|
serde_json::json!({
|
||||||
|
"track": {"id": 2},
|
||||||
|
"tracks": [],
|
||||||
|
"index": 0,
|
||||||
|
"position_seconds": 12.0,
|
||||||
|
"duration_seconds": 100.0,
|
||||||
|
"paused": true,
|
||||||
|
"shuffle": false,
|
||||||
|
"repeat_mode": "off",
|
||||||
|
"volume": 0.7
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("valid snapshot");
|
||||||
|
{
|
||||||
|
let mut state = hub.state.lock().expect("device hub");
|
||||||
|
let now = current_millis();
|
||||||
|
state.devices_by_user.entry(user_id).or_default().insert(
|
||||||
|
"browser".to_string(),
|
||||||
|
PlayerDevice {
|
||||||
|
id: "browser".to_string(),
|
||||||
|
name: "Browser".to_string(),
|
||||||
|
kind: "computer".to_string(),
|
||||||
|
last_seen_ms: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.device_last_seen_ms
|
||||||
|
.insert((user_id, "browser".to_string()), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
|
||||||
|
|
||||||
|
assert_eq!(response.active_device_id.as_deref(), Some("fed:remote"));
|
||||||
|
assert_eq!(previous, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
@@ -1259,15 +1407,19 @@ async fn me_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let plays: (i64,) =
|
let plays: (i64,) = sqlx::query_as(
|
||||||
sqlx::query_as("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1")
|
"SELECT COUNT(*) FROM furumusic__listen_event
|
||||||
.bind(user.id)
|
WHERE user_id = $1 AND qualified = true",
|
||||||
.fetch_one(pool)
|
)
|
||||||
.await
|
.bind(user.id)
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let listened_seconds: Option<i64> = sqlx::query_scalar(
|
let listened_seconds: Option<i64> = sqlx::query_scalar(
|
||||||
"SELECT COALESCE(SUM(duration_listened), 0) FROM furumusic__play_history WHERE user_id = $1",
|
"SELECT (COALESCE(SUM(listened_ms), 0) / 1000)::bigint
|
||||||
|
FROM furumusic__listen_event
|
||||||
|
WHERE user_id = $1 AND qualified = true",
|
||||||
)
|
)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
@@ -4111,6 +4263,7 @@ async fn build_playlist_track_items(
|
|||||||
.zip(playlist_track_ids)
|
.zip(playlist_track_ids)
|
||||||
.map(|(track, playlist_track_id)| PlaylistTrackItem {
|
.map(|(track, playlist_track_id)| PlaylistTrackItem {
|
||||||
playlist_track_id,
|
playlist_track_id,
|
||||||
|
sort_key: None,
|
||||||
track,
|
track,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -4309,14 +4462,50 @@ async fn likes_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let track_items = build_track_items(tracks, pool)
|
let order_rows = sqlx::query(
|
||||||
|
"SELECT ult.track_id,
|
||||||
|
COALESCE(
|
||||||
|
fsl.hlc_ms,
|
||||||
|
(EXTRACT(EPOCH FROM ult.created_at::timestamptz) * 1000)::bigint,
|
||||||
|
0
|
||||||
|
) AS sort_key
|
||||||
|
FROM furumusic__user_liked_track ult
|
||||||
|
JOIN furumusic__track t ON t.id = ult.track_id
|
||||||
|
LEFT JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
LEFT JOIN furumusic__federation_content_id_cache c
|
||||||
|
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||||
|
LEFT JOIN furumusic__fed_state_like fsl
|
||||||
|
ON fsl.user_id = ult.user_id
|
||||||
|
AND fsl.content_id = c.content_id
|
||||||
|
AND fsl.liked = true
|
||||||
|
WHERE ult.user_id = $1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let order_by_track = order_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.get::<i64, _>("track_id"), row.get::<i64, _>("sort_key")))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
|
let mut track_items = build_track_items(tracks, pool)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|track| PlaylistTrackItem {
|
.map(|track| PlaylistTrackItem {
|
||||||
playlist_track_id: None,
|
playlist_track_id: None,
|
||||||
|
sort_key: order_by_track.get(&track.id).copied(),
|
||||||
track,
|
track,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
|
track_items.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.sort_key
|
||||||
|
.cmp(&left.sort_key)
|
||||||
|
.then_with(|| left.track.content_id.cmp(&right.track.content_id))
|
||||||
|
.then_with(|| left.track.title.cmp(&right.track.title))
|
||||||
|
.then_with(|| left.track.id.cmp(&right.track.id))
|
||||||
|
});
|
||||||
|
|
||||||
Json(PlaylistDetail {
|
Json(PlaylistDetail {
|
||||||
id: -1,
|
id: -1,
|
||||||
@@ -4805,7 +4994,7 @@ async fn devices_heartbeat_handler(
|
|||||||
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id"));
|
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id"));
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = hub.heartbeat(
|
let (response, previous_active_id) = hub.heartbeat(
|
||||||
user.id,
|
user.id,
|
||||||
&device_id,
|
&device_id,
|
||||||
dto.user_agent.as_deref(),
|
dto.user_agent.as_deref(),
|
||||||
@@ -4815,6 +5004,22 @@ async fn devices_heartbeat_handler(
|
|||||||
.as_deref(),
|
.as_deref(),
|
||||||
dto.playback_state,
|
dto.playback_state,
|
||||||
);
|
);
|
||||||
|
if let Some(previous_fed_device_id) = previous_active_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(fed_device_id_from_virtual)
|
||||||
|
&& let Some(playback_state) = response.playback_state.clone()
|
||||||
|
{
|
||||||
|
let state = match serde_json::to_value(playback_state) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
};
|
||||||
|
if let Err(err) = crate::federation::handle()
|
||||||
|
.fed_device_web_active_takeover(user.id, previous_fed_device_id, state)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Json(response).into_response()
|
Json(response).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5526,139 +5731,187 @@ async fn history_list_handler(
|
|||||||
let per_page = query.0.limit.unwrap_or(20).clamp(1, 100);
|
let per_page = query.0.limit.unwrap_or(20).clamp(1, 100);
|
||||||
let offset = (page - 1) as i64 * per_page as i64;
|
let offset = (page - 1) as i64 * per_page as i64;
|
||||||
|
|
||||||
let total: i64 =
|
let total: i64 = sqlx::query_scalar(
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1")
|
"SELECT COUNT(*) FROM furumusic__listen_event
|
||||||
.bind(user.id)
|
WHERE user_id = $1 AND qualified = true",
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, PlayHistoryTrackRow>(
|
|
||||||
r#"SELECT ph.id AS history_id,
|
|
||||||
ph.played_at::text AS played_at,
|
|
||||||
ph.duration_listened,
|
|
||||||
ph.completed,
|
|
||||||
t.id,
|
|
||||||
t.title::text as title,
|
|
||||||
t.track_number,
|
|
||||||
t.disc_number,
|
|
||||||
t.duration_seconds,
|
|
||||||
t.cover_file_id,
|
|
||||||
r.cover_file_id as release_cover_file_id,
|
|
||||||
t.release_id,
|
|
||||||
COALESCE(r.title::text, '') as release_title,
|
|
||||||
r.year as release_year,
|
|
||||||
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
|
||||||
mf.audio_format,
|
|
||||||
mf.audio_bitrate,
|
|
||||||
mf.audio_sample_rate,
|
|
||||||
mf.audio_bit_depth,
|
|
||||||
mf.file_size_bytes,
|
|
||||||
t.lastfm_listeners,
|
|
||||||
t.lastfm_playcount,
|
|
||||||
t.lastfm_rating,
|
|
||||||
t.lastfm_updated_at
|
|
||||||
FROM furumusic__play_history ph
|
|
||||||
JOIN furumusic__track t ON t.id = ph.track_id
|
|
||||||
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
|
||||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
|
||||||
WHERE ph.user_id = $1
|
|
||||||
ORDER BY ph.played_at DESC, ph.id DESC
|
|
||||||
LIMIT $2 OFFSET $3"#,
|
|
||||||
)
|
)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(per_page as i64)
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT le.listen_id, le.content_id,
|
||||||
|
COALESCE(le.local_track_id, tr.local_track_id) AS local_track_id,
|
||||||
|
le.origin_device_id, le.started_at_ms, le.listened_ms,
|
||||||
|
le.track_duration_ms, le.metadata_json,
|
||||||
|
COALESCE(NULLIF(d.name, ''), le.origin_device_id) AS device_name,
|
||||||
|
t.release_id, r.year AS release_year, t.cover_file_id,
|
||||||
|
r.cover_file_id AS release_cover_file_id
|
||||||
|
FROM furumusic__listen_event le
|
||||||
|
LEFT JOIN furumusic__fed_device d
|
||||||
|
ON d.user_id = le.user_id AND d.device_id = le.origin_device_id
|
||||||
|
LEFT JOIN furumusic__track_ref tr ON tr.content_id = le.content_id
|
||||||
|
LEFT JOIN furumusic__track t
|
||||||
|
ON t.id = COALESCE(le.local_track_id, tr.local_track_id)
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
WHERE le.user_id = $1 AND le.qualified = true
|
||||||
|
ORDER BY le.started_at_ms DESC, le.listen_id DESC
|
||||||
|
LIMIT $2 OFFSET $3",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(i64::from(per_page))
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let track_ids: Vec<i64> = rows.iter().map(|t| t.id).collect();
|
// Listen metadata is an immutable snapshot. Once federated content has
|
||||||
let track_artists = if track_ids.is_empty() {
|
// been materialized, resolve it through the content cache and use the
|
||||||
|
// normal local TrackItem as the presentation/action authority.
|
||||||
|
let content_ids = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.get::<String, _>("content_id"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let local_rows = if content_ids.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, TrackArtistRow>(
|
sqlx::query(
|
||||||
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role
|
r#"SELECT DISTINCT ON (c.content_id) c.content_id, t.id AS track_id
|
||||||
FROM furumusic__track_artist ta
|
FROM furumusic__federation_content_id_cache c
|
||||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
JOIN furumusic__media_file m
|
||||||
WHERE ta.track_id = ANY($1)
|
ON m.id = c.media_file_id AND m.sha256_hash = c.sha256_hash
|
||||||
ORDER BY ta.track_id, ta.position"#,
|
JOIN furumusic__track t ON t.audio_file_id = m.id
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
WHERE c.content_id = ANY($1)
|
||||||
|
AND t.is_hidden = false
|
||||||
|
AND r.is_hidden = false
|
||||||
|
ORDER BY c.content_id, t.id"#,
|
||||||
)
|
)
|
||||||
.bind(&track_ids)
|
.bind(&content_ids)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||||
};
|
};
|
||||||
|
let local_by_content = local_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
(
|
||||||
|
row.get::<String, _>("content_id"),
|
||||||
|
row.get::<i64, _>("track_id"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let local_ids = local_by_content.values().copied().collect::<Vec<_>>();
|
||||||
|
let local_tracks = load_track_items_by_ids(pool, &local_ids)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|track| (track.id, track))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
let mut track_main_artists: HashMap<i64, Vec<ArtistRef>> = HashMap::new();
|
let items = rows
|
||||||
let mut track_feat_artists: HashMap<i64, Vec<ArtistRef>> = HashMap::new();
|
.into_iter()
|
||||||
for ta in &track_artists {
|
.map(|row| {
|
||||||
let artist_ref = ArtistRef {
|
let listen_id: String = row.get("listen_id");
|
||||||
id: ta.artist_id,
|
let content_id: String = row.get("content_id");
|
||||||
name: ta.artist_name.clone(),
|
let local_track_id = local_by_content.get(&content_id).copied();
|
||||||
};
|
let metadata: serde_json::Value = row.get("metadata_json");
|
||||||
if ta.role == "featuring" {
|
let snapshot_title = metadata
|
||||||
track_feat_artists
|
.get("title")
|
||||||
.entry(ta.track_id)
|
.and_then(serde_json::Value::as_str)
|
||||||
.or_default()
|
.unwrap_or("Unknown track")
|
||||||
.push(artist_ref);
|
.to_string();
|
||||||
} else {
|
let snapshot_release_title = metadata
|
||||||
track_main_artists
|
.get("release_title")
|
||||||
.entry(ta.track_id)
|
.and_then(serde_json::Value::as_str)
|
||||||
.or_default()
|
.map(ToOwned::to_owned);
|
||||||
.push(artist_ref);
|
let artist_values = |key: &str| {
|
||||||
}
|
metadata
|
||||||
}
|
.get(key)
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(serde_json::Value::as_str)
|
||||||
|
.map(|name| serde_json::json!({"id": null, "name": name}))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let duration_ms: Option<i64> = row.get("track_duration_ms");
|
||||||
|
let release_id: Option<i64> = row.get("release_id");
|
||||||
|
let cover_url = track_cover_variant_url(
|
||||||
|
row.get("cover_file_id"),
|
||||||
|
row.get("release_cover_file_id"),
|
||||||
|
"medium",
|
||||||
|
);
|
||||||
|
let fallback_track = serde_json::json!({
|
||||||
|
"id": local_track_id.map_or_else(
|
||||||
|
|| format!("history:{content_id}"),
|
||||||
|
|id| id.to_string(),
|
||||||
|
),
|
||||||
|
"content_id": content_id,
|
||||||
|
"title": snapshot_title,
|
||||||
|
"track_number": null,
|
||||||
|
"disc_number": null,
|
||||||
|
"duration_seconds": duration_ms.unwrap_or_default() as f64 / 1000.0,
|
||||||
|
"artists": artist_values("artist_names"),
|
||||||
|
"featured_artists": artist_values("featured_artist_names"),
|
||||||
|
"release_id": release_id,
|
||||||
|
"release_title": snapshot_release_title.clone().unwrap_or_default(),
|
||||||
|
"release_year": row.get::<Option<i32>, _>("release_year"),
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"stream_url": local_track_id
|
||||||
|
.map(|id| format!("/api/player/stream/{id}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
"uploader_name": if local_track_id.is_some() { "Web" } else { "Federation" },
|
||||||
|
"federation_pending": local_track_id.is_none(),
|
||||||
|
"_federationTrack": if local_track_id.is_none() {
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"key": {"content_id": content_id},
|
||||||
|
"metadata": metadata,
|
||||||
|
"availability": {
|
||||||
|
"state": "federated",
|
||||||
|
"local": null,
|
||||||
|
"federation": [{"owner": "", "item_id": ""}],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let local_track = local_track_id.and_then(|id| local_tracks.get(&id));
|
||||||
|
let track = local_track
|
||||||
|
.and_then(|track| serde_json::to_value(track).ok())
|
||||||
|
.unwrap_or(fallback_track);
|
||||||
|
let title = local_track
|
||||||
|
.map(|track| track.title.clone())
|
||||||
|
.unwrap_or(snapshot_title);
|
||||||
|
let release_title = local_track
|
||||||
|
.map(|track| track.release_title.clone())
|
||||||
|
.or(snapshot_release_title);
|
||||||
|
let started_at_ms: i64 = row.get("started_at_ms");
|
||||||
|
let played_at = chrono::DateTime::from_timestamp_millis(started_at_ms)
|
||||||
|
.unwrap_or_else(chrono::Utc::now)
|
||||||
|
.to_rfc3339();
|
||||||
|
PlayHistoryItem {
|
||||||
|
id: listen_id,
|
||||||
|
track_id: local_track_id,
|
||||||
|
track_title: title,
|
||||||
|
release_title,
|
||||||
|
track,
|
||||||
|
played_at,
|
||||||
|
duration_listened: Some(
|
||||||
|
row.get::<i64, _>("listened_ms")
|
||||||
|
.div_euclid(1_000)
|
||||||
|
.clamp(0, i64::from(i32::MAX)) as i32,
|
||||||
|
),
|
||||||
|
completed: true,
|
||||||
|
device_id: row.get("origin_device_id"),
|
||||||
|
device_name: row.get("device_name"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Json(PlayHistoryPage {
|
Json(PlayHistoryPage {
|
||||||
items: rows
|
items,
|
||||||
.into_iter()
|
|
||||||
.map(|row| PlayHistoryItem {
|
|
||||||
id: row.history_id,
|
|
||||||
track_id: row.id,
|
|
||||||
track_title: row.title.clone(),
|
|
||||||
release_title: if row.release_title.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(row.release_title.clone())
|
|
||||||
},
|
|
||||||
track: {
|
|
||||||
let tid = row.id;
|
|
||||||
TrackItem {
|
|
||||||
id: row.id,
|
|
||||||
content_id: None,
|
|
||||||
title: row.title,
|
|
||||||
track_number: row.track_number,
|
|
||||||
disc_number: row.disc_number,
|
|
||||||
duration_seconds: row.duration_seconds,
|
|
||||||
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
|
||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
|
||||||
release_id: row.release_id,
|
|
||||||
release_title: row.release_title,
|
|
||||||
release_year: row.release_year,
|
|
||||||
cover_url: track_cover_variant_url(
|
|
||||||
row.cover_file_id,
|
|
||||||
row.release_cover_file_id,
|
|
||||||
"medium",
|
|
||||||
),
|
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
|
||||||
uploader_name: row.uploader_name,
|
|
||||||
audio_format: row.audio_format,
|
|
||||||
audio_bitrate: row.audio_bitrate,
|
|
||||||
audio_sample_rate: row.audio_sample_rate,
|
|
||||||
audio_bit_depth: row.audio_bit_depth,
|
|
||||||
file_size_bytes: row.file_size_bytes,
|
|
||||||
lastfm_listeners: row.lastfm_listeners,
|
|
||||||
lastfm_playcount: row.lastfm_playcount,
|
|
||||||
lastfm_rating: row.lastfm_rating,
|
|
||||||
lastfm_updated_at: row.lastfm_updated_at,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
played_at: row.played_at,
|
|
||||||
duration_listened: row.duration_listened,
|
|
||||||
completed: row.completed,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
per_page,
|
per_page,
|
||||||
@@ -5677,61 +5930,33 @@ async fn history_handler(
|
|||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
let listened_seconds = entry.duration_listened.unwrap_or_default().max(0);
|
||||||
|
let started_at = entry
|
||||||
sqlx::query(
|
.started_at
|
||||||
r#"INSERT INTO furumusic__play_history (user_id, track_id, played_at, duration_listened, completed)
|
.unwrap_or_else(|| chrono::Utc::now().timestamp() - i64::from(listened_seconds));
|
||||||
VALUES ($1, $2, $3, $4, $5)"#,
|
let listen_id = entry
|
||||||
|
.listen_id
|
||||||
|
.unwrap_or_else(|| format!("legacy-web:{}:{}:{started_at}", user.id, entry.track_id));
|
||||||
|
let ended_reason = match entry.ended_reason.as_deref() {
|
||||||
|
Some("finished") => music_dht::device_sync::ListenEndReason::Finished,
|
||||||
|
Some("skipped") => music_dht::device_sync::ListenEndReason::Skipped,
|
||||||
|
Some("replaced") => music_dht::device_sync::ListenEndReason::Replaced,
|
||||||
|
Some("stopped") => music_dht::device_sync::ListenEndReason::Stopped,
|
||||||
|
_ => music_dht::device_sync::ListenEndReason::Unknown,
|
||||||
|
};
|
||||||
|
crate::federation::devices::record_track_listen(
|
||||||
|
pool,
|
||||||
|
user.id,
|
||||||
|
entry.track_id,
|
||||||
|
listen_id,
|
||||||
|
started_at.saturating_mul(1_000),
|
||||||
|
i64::from(listened_seconds).saturating_mul(1_000),
|
||||||
|
ended_reason,
|
||||||
)
|
)
|
||||||
.bind(user.id)
|
|
||||||
.bind(entry.track_id)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(entry.duration_listened)
|
|
||||||
.bind(entry.completed)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
crate::metrics::record_play_history(entry.duration_listened, entry.completed);
|
crate::metrics::record_play_history(entry.duration_listened, entry.completed);
|
||||||
|
|
||||||
if let Some(listened_seconds) = entry.duration_listened {
|
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
||||||
match enqueue_lastfm_scrobble(
|
|
||||||
pool,
|
|
||||||
&config,
|
|
||||||
user.id,
|
|
||||||
entry.track_id,
|
|
||||||
entry.started_at,
|
|
||||||
listened_seconds,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) if result.queued => {
|
|
||||||
tracing::info!(
|
|
||||||
user_id = user.id,
|
|
||||||
track_id = entry.track_id,
|
|
||||||
sent = result.sent,
|
|
||||||
"Queued Last.fm scrobble from play history"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(result) => {
|
|
||||||
tracing::debug!(
|
|
||||||
user_id = user.id,
|
|
||||||
track_id = entry.track_id,
|
|
||||||
message = ?result.message,
|
|
||||||
"Play history did not queue Last.fm scrobble"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!(
|
|
||||||
user_id = user.id,
|
|
||||||
track_id = entry.track_id,
|
|
||||||
error = %err,
|
|
||||||
"Failed to queue Last.fm scrobble from play history"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Json(serde_json::json!({"ok": true})).into_response()
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5759,6 +5984,10 @@ async fn search_handler(
|
|||||||
})
|
})
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
let normalized_q = match music_dht::normalize_name(&q) {
|
||||||
|
normalized if !normalized.is_empty() => normalized,
|
||||||
|
_ => q.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
let limit = query.0.limit.unwrap_or(10).clamp(1, 50) as i64;
|
let limit = query.0.limit.unwrap_or(10).clamp(1, 50) as i64;
|
||||||
let short = q.chars().count() < 3;
|
let short = q.chars().count() < 3;
|
||||||
@@ -5774,11 +6003,14 @@ async fn search_handler(
|
|||||||
JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
||||||
WHERE ra.artist_id = a.id), 0) AS track_count
|
WHERE ra.artist_id = a.id), 0) AS track_count
|
||||||
FROM furumusic__artist a
|
FROM furumusic__artist a
|
||||||
WHERE a.is_hidden = false AND a.name_sort ILIKE '%' || $1 || '%'
|
WHERE a.is_hidden = false
|
||||||
|
AND (a.name_sort ILIKE '%' || $1 || '%'
|
||||||
|
OR a.name_sort ILIKE '%' || $3 || '%')
|
||||||
ORDER BY a.name_sort LIMIT $2"#,
|
ORDER BY a.name_sort LIMIT $2"#,
|
||||||
)
|
)
|
||||||
.bind(&q)
|
.bind(&q)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(&normalized_q)
|
||||||
.fetch_all(pool);
|
.fetch_all(pool);
|
||||||
|
|
||||||
let r = sqlx::query_as::<_, SearchReleaseRow>(
|
let r = sqlx::query_as::<_, SearchReleaseRow>(
|
||||||
@@ -5786,11 +6018,14 @@ async fn search_handler(
|
|||||||
r.year, r.cover_file_id,
|
r.year, r.cover_file_id,
|
||||||
COALESCE((SELECT COUNT(*) FROM furumusic__track t WHERE t.release_id = r.id AND t.is_hidden = false), 0) AS track_count
|
COALESCE((SELECT COUNT(*) FROM furumusic__track t WHERE t.release_id = r.id AND t.is_hidden = false), 0) AS track_count
|
||||||
FROM furumusic__release r
|
FROM furumusic__release r
|
||||||
WHERE r.is_hidden = false AND r.title_sort ILIKE '%' || $1 || '%'
|
WHERE r.is_hidden = false
|
||||||
|
AND (r.title_sort ILIKE '%' || $1 || '%'
|
||||||
|
OR r.title_sort ILIKE '%' || $3 || '%')
|
||||||
ORDER BY r.title_sort LIMIT $2"#,
|
ORDER BY r.title_sort LIMIT $2"#,
|
||||||
)
|
)
|
||||||
.bind(&q)
|
.bind(&q)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(&normalized_q)
|
||||||
.fetch_all(pool);
|
.fetch_all(pool);
|
||||||
|
|
||||||
let t = sqlx::query_as::<_, SearchTrackRow>(
|
let t = sqlx::query_as::<_, SearchTrackRow>(
|
||||||
@@ -5813,11 +6048,14 @@ async fn search_handler(
|
|||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release rel ON rel.id = t.release_id
|
JOIN furumusic__release rel ON rel.id = t.release_id
|
||||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
WHERE t.is_hidden = false AND t.title_sort ILIKE '%' || $1 || '%'
|
WHERE t.is_hidden = false
|
||||||
|
AND (t.title_sort ILIKE '%' || $1 || '%'
|
||||||
|
OR t.title_sort ILIKE '%' || $3 || '%')
|
||||||
ORDER BY t.title_sort LIMIT $2"#,
|
ORDER BY t.title_sort LIMIT $2"#,
|
||||||
)
|
)
|
||||||
.bind(&q)
|
.bind(&q)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(&normalized_q)
|
||||||
.fetch_all(pool);
|
.fetch_all(pool);
|
||||||
|
|
||||||
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
|
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
|
||||||
@@ -5834,11 +6072,16 @@ async fn search_handler(
|
|||||||
WHERE ra.artist_id = a.id), 0) AS track_count,
|
WHERE ra.artist_id = a.id), 0) AS track_count,
|
||||||
MAX(sim) AS similarity
|
MAX(sim) AS similarity
|
||||||
FROM (
|
FROM (
|
||||||
SELECT id, name, image_file_id, name_sort, similarity(name_sort, $1) AS sim
|
SELECT id, name, image_file_id, name_sort,
|
||||||
FROM furumusic__artist WHERE is_hidden = false AND name_sort % $1
|
GREATEST(similarity(name_sort, $1), similarity(name_sort, $3)) AS sim
|
||||||
|
FROM furumusic__artist
|
||||||
|
WHERE is_hidden = false AND (name_sort % $1 OR name_sort % $3)
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT id, name, image_file_id, name_sort, 0.01::real AS sim
|
SELECT id, name, image_file_id, name_sort, 0.01::real AS sim
|
||||||
FROM furumusic__artist WHERE is_hidden = false AND name_sort ILIKE '%' || $1 || '%'
|
FROM furumusic__artist
|
||||||
|
WHERE is_hidden
|
||||||
|
= false AND (name_sort ILIKE '%' || $1 || '%'
|
||||||
|
OR name_sort ILIKE '%' || $3 || '%')
|
||||||
) a
|
) a
|
||||||
GROUP BY a.id, a.name, a.image_file_id
|
GROUP BY a.id, a.name, a.image_file_id
|
||||||
ORDER BY similarity DESC
|
ORDER BY similarity DESC
|
||||||
@@ -5847,6 +6090,7 @@ async fn search_handler(
|
|||||||
)
|
)
|
||||||
.bind(&q)
|
.bind(&q)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(&normalized_q)
|
||||||
.fetch_all(pool);
|
.fetch_all(pool);
|
||||||
|
|
||||||
let r = sqlx::query_as::<_, SearchReleaseRow>(
|
let r = sqlx::query_as::<_, SearchReleaseRow>(
|
||||||
@@ -5856,11 +6100,16 @@ async fn search_handler(
|
|||||||
COALESCE((SELECT COUNT(*) FROM furumusic__track t WHERE t.release_id = r.id AND t.is_hidden = false), 0) AS track_count,
|
COALESCE((SELECT COUNT(*) FROM furumusic__track t WHERE t.release_id = r.id AND t.is_hidden = false), 0) AS track_count,
|
||||||
MAX(sim) AS similarity
|
MAX(sim) AS similarity
|
||||||
FROM (
|
FROM (
|
||||||
SELECT id, title, release_type, year, cover_file_id, title_sort, similarity(title_sort, $1) AS sim
|
SELECT id, title, release_type, year, cover_file_id, title_sort,
|
||||||
FROM furumusic__release WHERE is_hidden = false AND title_sort % $1
|
GREATEST(similarity(title_sort, $1), similarity(title_sort, $3)) AS sim
|
||||||
|
FROM furumusic__release
|
||||||
|
WHERE is_hidden = false AND (title_sort % $1 OR title_sort % $3)
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT id, title, release_type, year, cover_file_id, title_sort, 0.01::real AS sim
|
SELECT id, title, release_type, year, cover_file_id, title_sort, 0.01::real AS sim
|
||||||
FROM furumusic__release WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
FROM furumusic__release
|
||||||
|
WHERE is_hidden = false
|
||||||
|
AND (title_sort ILIKE '%' || $1 || '%'
|
||||||
|
OR title_sort ILIKE '%' || $3 || '%')
|
||||||
) r
|
) r
|
||||||
GROUP BY r.id, r.title, r.release_type, r.year, r.cover_file_id
|
GROUP BY r.id, r.title, r.release_type, r.year, r.cover_file_id
|
||||||
ORDER BY similarity DESC
|
ORDER BY similarity DESC
|
||||||
@@ -5869,6 +6118,7 @@ async fn search_handler(
|
|||||||
)
|
)
|
||||||
.bind(&q)
|
.bind(&q)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(&normalized_q)
|
||||||
.fetch_all(pool);
|
.fetch_all(pool);
|
||||||
|
|
||||||
let t = sqlx::query_as::<_, SearchTrackRow>(
|
let t = sqlx::query_as::<_, SearchTrackRow>(
|
||||||
@@ -5895,13 +6145,17 @@ async fn search_handler(
|
|||||||
FROM (
|
FROM (
|
||||||
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
||||||
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
|
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
|
||||||
similarity(title_sort, $1) AS sim
|
GREATEST(similarity(title_sort, $1), similarity(title_sort, $3)) AS sim
|
||||||
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
|
FROM furumusic__track
|
||||||
|
WHERE is_hidden = false AND (title_sort % $1 OR title_sort % $3)
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
||||||
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
|
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
|
||||||
0.01::real AS sim
|
0.01::real AS sim
|
||||||
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
FROM furumusic__track
|
||||||
|
WHERE is_hidden = false
|
||||||
|
AND (title_sort ILIKE '%' || $1 || '%'
|
||||||
|
OR title_sort ILIKE '%' || $3 || '%')
|
||||||
) t
|
) t
|
||||||
JOIN furumusic__release rel ON rel.id = t.release_id
|
JOIN furumusic__release rel ON rel.id = t.release_id
|
||||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
@@ -5914,6 +6168,7 @@ async fn search_handler(
|
|||||||
)
|
)
|
||||||
.bind(&q)
|
.bind(&q)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(&normalized_q)
|
||||||
.fetch_all(pool);
|
.fetch_all(pool);
|
||||||
|
|
||||||
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
|
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
|
||||||
@@ -6263,7 +6518,7 @@ async fn federation_playlist_tracks_handler(
|
|||||||
};
|
};
|
||||||
let rows = if path.id == -1 {
|
let rows = if path.id == -1 {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"SELECT content_id, 0::bigint AS position, fed_json
|
"SELECT content_id, hlc_ms AS position, fed_json
|
||||||
FROM furumusic__fed_state_like
|
FROM furumusic__fed_state_like
|
||||||
WHERE user_id = $1 AND liked = true
|
WHERE user_id = $1 AND liked = true
|
||||||
AND local_track_id IS NULL AND fed_json IS NOT NULL
|
AND local_track_id IS NULL AND fed_json IS NOT NULL
|
||||||
@@ -6330,23 +6585,40 @@ async fn prepare_federated_track_handler(
|
|||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<serde_json::Value>();
|
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<serde_json::Value>();
|
||||||
let progress_sender = sender.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = crate::federation::handle()
|
let federation = crate::federation::handle();
|
||||||
.prepare_content_with_progress(
|
let progress_sender = sender.clone();
|
||||||
&body.content_id,
|
let result = match (body.owner.as_deref(), body.item_id.as_deref()) {
|
||||||
&body.owner,
|
(Some(owner), Some(item_id)) if !owner.is_empty() && !item_id.is_empty() => {
|
||||||
&body.item_id,
|
federation
|
||||||
move |progress| {
|
.prepare_content_with_progress(
|
||||||
let _ = progress_sender.send(serde_json::json!({
|
&body.content_id,
|
||||||
"kind": "progress",
|
owner,
|
||||||
"phase": progress.phase,
|
item_id,
|
||||||
"received": progress.received,
|
move |progress| {
|
||||||
"total": progress.total,
|
let _ = progress_sender.send(serde_json::json!({
|
||||||
}));
|
"kind": "progress",
|
||||||
},
|
"phase": progress.phase,
|
||||||
)
|
"received": progress.received,
|
||||||
.await;
|
"total": progress.total,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
federation
|
||||||
|
.prepare_discovered_content_with_progress(&body.content_id, move |progress| {
|
||||||
|
let _ = progress_sender.send(serde_json::json!({
|
||||||
|
"kind": "progress",
|
||||||
|
"phase": progress.phase,
|
||||||
|
"received": progress.received,
|
||||||
|
"total": progress.total,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
let event = match result {
|
let event = match result {
|
||||||
Ok(prepared) => serde_json::json!({
|
Ok(prepared) => serde_json::json!({
|
||||||
"kind": "completed",
|
"kind": "completed",
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ use serde::Deserialize;
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct HistoryEntry {
|
pub(super) struct HistoryEntry {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: i64,
|
||||||
|
pub(super) listen_id: Option<String>,
|
||||||
pub(super) started_at: Option<i64>,
|
pub(super) started_at: Option<i64>,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
|
pub(super) ended_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -254,34 +254,6 @@ pub(super) struct ReleaseUploaderRow {
|
|||||||
pub(super) track_count: i64,
|
pub(super) track_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
pub(super) struct PlayHistoryTrackRow {
|
|
||||||
pub(super) history_id: i64,
|
|
||||||
pub(super) played_at: String,
|
|
||||||
pub(super) duration_listened: Option<i32>,
|
|
||||||
pub(super) completed: bool,
|
|
||||||
pub(super) id: i64,
|
|
||||||
pub(super) title: String,
|
|
||||||
pub(super) track_number: Option<i32>,
|
|
||||||
pub(super) disc_number: Option<i32>,
|
|
||||||
pub(super) duration_seconds: f64,
|
|
||||||
pub(super) cover_file_id: Option<i64>,
|
|
||||||
pub(super) release_cover_file_id: Option<i64>,
|
|
||||||
pub(super) release_id: i64,
|
|
||||||
pub(super) release_title: String,
|
|
||||||
pub(super) release_year: Option<i32>,
|
|
||||||
pub(super) uploader_name: String,
|
|
||||||
pub(super) audio_format: Option<String>,
|
|
||||||
pub(super) audio_bitrate: Option<i32>,
|
|
||||||
pub(super) audio_sample_rate: Option<i32>,
|
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
|
||||||
pub(super) lastfm_listeners: Option<i64>,
|
|
||||||
pub(super) lastfm_playcount: Option<i64>,
|
|
||||||
pub(super) lastfm_rating: Option<f64>,
|
|
||||||
pub(super) lastfm_updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct ReleaseInfoRow {
|
pub(super) struct ReleaseInfoRow {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111827'/%3E%3Cpath d='M27 15v31.5a9 9 0 1 1-5-8.1V22l27-6v24.5a9 9 0 1 1-5-8.1V15.9L27 20.5' fill='%2367e8f9'/%3E%3C/svg%3E">
|
||||||
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
||||||
{% block head_extra %}{% endblock head_extra %}
|
{% block head_extra %}{% endblock head_extra %}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -821,7 +821,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<div class="track-title" x-text="item.track?.title || item.track_title"></div>
|
<div class="track-title">
|
||||||
|
<span x-text="item.track?.title || item.track_title"></span>
|
||||||
|
<span class="history-device-badge"
|
||||||
|
:title="item.device_id"
|
||||||
|
x-text="item.device_name"></span>
|
||||||
|
</div>
|
||||||
<div class="track-artists-inline">
|
<div class="track-artists-inline">
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -726,6 +726,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_historyRecorded: false,
|
_historyRecorded: false,
|
||||||
_nowPlayingSent: false,
|
_nowPlayingSent: false,
|
||||||
_playbackStartedAt: null,
|
_playbackStartedAt: null,
|
||||||
|
_listenId: null,
|
||||||
_listenedSeconds: 0,
|
_listenedSeconds: 0,
|
||||||
_lastAudioTime: 0,
|
_lastAudioTime: 0,
|
||||||
_localSourceTrackId: null,
|
_localSourceTrackId: null,
|
||||||
@@ -734,6 +735,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_remoteStateReceivedAt: 0,
|
_remoteStateReceivedAt: 0,
|
||||||
_remoteStateTimer: null,
|
_remoteStateTimer: null,
|
||||||
_prefetchedQueueKey: null,
|
_prefetchedQueueKey: null,
|
||||||
|
_baseDocumentTitle: '',
|
||||||
|
|
||||||
_hasInitialShareLink() {
|
_hasInitialShareLink() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -743,6 +745,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
audio.volume = this.volume;
|
audio.volume = this.volume;
|
||||||
|
this._baseDocumentTitle = document.title.replace(/^▶\s*/, '');
|
||||||
|
Alpine.effect(() => {
|
||||||
|
document.title = `${this.isPlaying ? '▶ ' : ''}${this._baseDocumentTitle}`;
|
||||||
|
});
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', () => {
|
audio.addEventListener('timeupdate', () => {
|
||||||
this.currentTime = audio.currentTime;
|
this.currentTime = audio.currentTime;
|
||||||
@@ -754,7 +760,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
audio.addEventListener('ended', () => {
|
audio.addEventListener('ended', () => {
|
||||||
this._trackListenedDelta();
|
this._trackListenedDelta();
|
||||||
this._recordHistory(true);
|
this._recordHistory('finished');
|
||||||
if (Alpine.store('devices')?.shouldPlayJamLocally()) {
|
if (Alpine.store('devices')?.shouldPlayJamLocally()) {
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
return;
|
return;
|
||||||
@@ -1437,21 +1443,24 @@ document.addEventListener('alpine:init', () => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|
||||||
_recordHistory(completed) {
|
_recordHistory(endedReason) {
|
||||||
if (this._historyRecorded || !this.currentTrack) return;
|
if (this._historyRecorded || !this.currentTrack) return;
|
||||||
if (!Number.isInteger(Number(this.currentTrack.id)) || this.currentTrack.federated_cache) {
|
if (!Number.isInteger(Number(this.currentTrack.id)) || this.currentTrack.federated_cache) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._historyRecorded = true;
|
this._historyRecorded = true;
|
||||||
|
const completed = endedReason === 'finished';
|
||||||
const listenedSeconds = this._historyListenedSeconds(completed);
|
const listenedSeconds = this._historyListenedSeconds(completed);
|
||||||
fetch('/api/player/history', {
|
fetch('/api/player/history', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
track_id: this.currentTrack.id,
|
track_id: this.currentTrack.id,
|
||||||
|
listen_id: this._listenId,
|
||||||
started_at: this._playbackStartedAt,
|
started_at: this._playbackStartedAt,
|
||||||
duration_listened: listenedSeconds,
|
duration_listened: listenedSeconds,
|
||||||
completed: completed,
|
completed: completed,
|
||||||
|
ended_reason: endedReason,
|
||||||
}),
|
}),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
@@ -1464,13 +1473,15 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const listened = Math.floor(Number(this._listenedSeconds || 0));
|
const listened = Math.floor(Number(this._listenedSeconds || 0));
|
||||||
const threshold = Math.ceil(duration / 2);
|
const threshold = Math.ceil(duration / 2);
|
||||||
if (threshold <= 0 || listened < threshold) return false;
|
if (threshold <= 0 || listened < threshold) return false;
|
||||||
this._recordHistory(true);
|
this._recordHistory('unknown');
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
_resetPlaybackTracking() {
|
_resetPlaybackTracking() {
|
||||||
this._nowPlayingSent = false;
|
this._nowPlayingSent = false;
|
||||||
this._playbackStartedAt = null;
|
this._playbackStartedAt = null;
|
||||||
|
this._listenId = (globalThis.crypto?.randomUUID?.()
|
||||||
|
|| `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||||
this._listenedSeconds = 0;
|
this._listenedSeconds = 0;
|
||||||
this._lastAudioTime = 0;
|
this._lastAudioTime = 0;
|
||||||
},
|
},
|
||||||
@@ -2495,6 +2506,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
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: {},
|
||||||
|
federationFailures: {},
|
||||||
trackAvailability: {},
|
trackAvailability: {},
|
||||||
_federatedReleaseCache: {},
|
_federatedReleaseCache: {},
|
||||||
federationLiked: [],
|
federationLiked: [],
|
||||||
@@ -3288,10 +3300,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (federationResponse.ok) {
|
if (federationResponse.ok) {
|
||||||
const references = await federationResponse.json();
|
const references = await federationResponse.json();
|
||||||
const federated = (references || [])
|
const federated = (references || [])
|
||||||
.map(reference => this.federationTrackFromWire(reference.federation))
|
.map(reference => {
|
||||||
.filter(Boolean)
|
const track = this.federationTrackFromWire(reference.federation);
|
||||||
.map(track => this.federationQueueTrack(track));
|
if (!track) return null;
|
||||||
|
return {
|
||||||
|
...this.federationQueueTrack(track),
|
||||||
|
sort_key: Number(reference.position || 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
playlist.tracks = [...(playlist.tracks || []), ...federated];
|
playlist.tracks = [...(playlist.tracks || []), ...federated];
|
||||||
|
if (Number(id) === -1) playlist.tracks = this.sortLikeTracks(playlist.tracks);
|
||||||
}
|
}
|
||||||
this.currentPlaylist = playlist;
|
this.currentPlaylist = playlist;
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -3777,7 +3796,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (liked && !this.currentPlaylist.tracks.some(item => item.content_id === contentId)) {
|
if (liked && !this.currentPlaylist.tracks.some(item => item.content_id === contentId)) {
|
||||||
this.currentPlaylist = {
|
this.currentPlaylist = {
|
||||||
...this.currentPlaylist,
|
...this.currentPlaylist,
|
||||||
tracks: [...this.currentPlaylist.tracks, this.federationQueueTrack(track)],
|
tracks: this.sortLikeTracks([
|
||||||
|
...this.currentPlaylist.tracks,
|
||||||
|
{
|
||||||
|
...this.federationQueueTrack(track),
|
||||||
|
sort_key: Date.now(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
} else if (!liked) {
|
} else if (!liked) {
|
||||||
this.currentPlaylist = {
|
this.currentPlaylist = {
|
||||||
@@ -3788,6 +3813,22 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sortLikeTracks(tracks) {
|
||||||
|
return [...(tracks || [])].sort((left, right) => {
|
||||||
|
const order = Number(right.sort_key || 0) - Number(left.sort_key || 0);
|
||||||
|
if (order) return order;
|
||||||
|
const stable = String(left.content_id || left.id || '')
|
||||||
|
.localeCompare(String(right.content_id || right.id || ''));
|
||||||
|
if (stable) return stable;
|
||||||
|
const title = String(left.title || '').localeCompare(
|
||||||
|
String(right.title || ''),
|
||||||
|
undefined,
|
||||||
|
{ sensitivity: 'base' },
|
||||||
|
);
|
||||||
|
return title;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
isMixedReleaseLiked(release) {
|
isMixedReleaseLiked(release) {
|
||||||
const tracks = release?.tracks || [];
|
const tracks = release?.tracks || [];
|
||||||
if (!tracks.length) return false;
|
if (!tracks.length) return false;
|
||||||
@@ -3834,6 +3875,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!contentId || sources.length === 0) return;
|
if (!contentId || sources.length === 0) return;
|
||||||
|
if (this.federationFailures[contentId]) {
|
||||||
|
const failures = { ...this.federationFailures };
|
||||||
|
delete failures[contentId];
|
||||||
|
this.federationFailures = failures;
|
||||||
|
}
|
||||||
if (this.federationPreparing[contentId]) {
|
if (this.federationPreparing[contentId]) {
|
||||||
if (options.playAfterPrepare !== false) {
|
if (options.playAfterPrepare !== false) {
|
||||||
this.federationPreparing = {
|
this.federationPreparing = {
|
||||||
@@ -3978,6 +4024,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
playable,
|
playable,
|
||||||
});
|
});
|
||||||
this.refreshMaterializedArtwork(track);
|
this.refreshMaterializedArtwork(track);
|
||||||
|
Alpine.store('likes')?.reload();
|
||||||
|
const history = Alpine.store('history');
|
||||||
|
if (history?.modal) history.load(history.page);
|
||||||
}
|
}
|
||||||
const finalProgress = this.federationPreparing[contentId] || {};
|
const finalProgress = this.federationPreparing[contentId] || {};
|
||||||
const queueIndex = Number.isInteger(finalProgress.queueIndex)
|
const queueIndex = Number.isInteger(finalProgress.queueIndex)
|
||||||
@@ -3994,7 +4043,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
Alpine.store('queue').playRelease([playable], 0);
|
Alpine.store('queue').playRelease([playable], 0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error?.message || 'Federated track download failed');
|
this.federationFailures = {
|
||||||
|
...this.federationFailures,
|
||||||
|
[contentId]: {
|
||||||
|
message: error?.message || 'Federated track is temporarily unavailable',
|
||||||
|
failedAt: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
const next = { ...this.federationPreparing };
|
const next = { ...this.federationPreparing };
|
||||||
delete next[contentId];
|
delete next[contentId];
|
||||||
@@ -4006,12 +4061,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return this.federationPreparing[contentId] || null;
|
return this.federationPreparing[contentId] || null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
federationFailure(contentId) {
|
||||||
|
return this.federationFailures[contentId] || null;
|
||||||
|
},
|
||||||
|
|
||||||
federationDownloadTooltip(contentId) {
|
federationDownloadTooltip(contentId) {
|
||||||
const progress = this.federationDownload(contentId);
|
const progress = this.federationDownload(contentId);
|
||||||
if (!progress) return '';
|
if (!progress) return '';
|
||||||
const phases = {
|
const phases = {
|
||||||
checking: 'Checking local library',
|
checking: 'Checking local library',
|
||||||
connecting: 'Connecting to peer',
|
connecting: 'Connecting to peer',
|
||||||
|
discovering: 'Looking for another peer',
|
||||||
downloading: 'Downloading',
|
downloading: 'Downloading',
|
||||||
verifying: 'Verifying content ID',
|
verifying: 'Verifying content ID',
|
||||||
saving: 'Adding to library',
|
saving: 'Adding to library',
|
||||||
@@ -4077,10 +4137,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_set: new Set(),
|
_set: new Set(),
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
fetch('/api/player/likes')
|
this.reload();
|
||||||
.then(r => r.json())
|
},
|
||||||
.then(d => { this._set = new Set(d.track_ids || []); })
|
|
||||||
.catch(() => {});
|
async reload() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/player/likes');
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
this._set = new Set(data.track_ids || []);
|
||||||
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|
||||||
has(trackId) {
|
has(trackId) {
|
||||||
|
|||||||
@@ -562,13 +562,17 @@
|
|||||||
<div class="track-row federation-track-row"
|
<div class="track-row federation-track-row"
|
||||||
@dblclick="$store.library.playFederatedTrack(track)">
|
@dblclick="$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.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">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||||
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
<template x-if="$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||||
|
<span class="federation-error-badge"
|
||||||
|
:title="$store.library.federationFailure(track.key.content_id).message">!</span>
|
||||||
|
</template>
|
||||||
<template x-if="$store.library.federationDownload(track.key.content_id)">
|
<template x-if="$store.library.federationDownload(track.key.content_id)">
|
||||||
<span class="federation-download-progress"
|
<span class="federation-download-progress"
|
||||||
:class="{ indeterminate: !$store.library.federationDownload(track.key.content_id).total }">
|
:class="{ indeterminate: !$store.library.federationDownload(track.key.content_id).total }">
|
||||||
@@ -753,8 +757,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
|
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
|
||||||
<section class="artist-release-group">
|
<section class="artist-release-group" x-data="{ expanded: false }">
|
||||||
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
<div class="artist-release-group-heading">
|
||||||
|
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
||||||
|
<button class="artist-top-tracks-toggle"
|
||||||
|
type="button"
|
||||||
|
x-show="$store.library.currentArtist.top_tracks.length > 5"
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
:aria-expanded="expanded"
|
||||||
|
x-cloak>
|
||||||
|
<span x-text="expanded ? '{{ t.player_collapse }}' : '{{ t.player_expand_all }}'"></span>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
:class="{ expanded: expanded }">
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="track-list-header">
|
<div class="track-list-header">
|
||||||
<span>#</span>
|
<span>#</span>
|
||||||
<span>{{ t.player_title }}</span>
|
<span>{{ t.player_title }}</span>
|
||||||
@@ -762,7 +780,9 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
|
<template x-for="(track, idx) in (expanded
|
||||||
|
? $store.library.currentArtist.top_tracks
|
||||||
|
: $store.library.currentArtist.top_tracks.slice(0, 5))" :key="track.id">
|
||||||
<div class="track-row artist-appearance-row"
|
<div class="track-row artist-appearance-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.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
|
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
|
||||||
@@ -905,12 +925,16 @@
|
|||||||
<div class="track-row federation-track-row"
|
<div class="track-row federation-track-row"
|
||||||
@dblclick="$store.library.playFederatedTrack(track)">
|
@dblclick="$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.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">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
<template x-if="$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||||
|
<span class="federation-error-badge"
|
||||||
|
:title="$store.library.federationFailure(track.key.content_id).message">!</span>
|
||||||
|
</template>
|
||||||
<template x-if="$store.library.federationDownload(track.key.content_id)">
|
<template x-if="$store.library.federationDownload(track.key.content_id)">
|
||||||
<span class="federation-download-progress"
|
<span class="federation-download-progress"
|
||||||
:class="{ indeterminate: !$store.library.federationDownload(track.key.content_id).total }">
|
:class="{ indeterminate: !$store.library.federationDownload(track.key.content_id).total }">
|
||||||
@@ -1158,13 +1182,17 @@
|
|||||||
<template x-if="!track.federation_pending">
|
<template x-if="!track.federation_pending">
|
||||||
<span x-text="track.track_number || (idx + 1)"></span>
|
<span x-text="track.track_number || (idx + 1)"></span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && !$store.library.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
||||||
<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"/>
|
||||||
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||||
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
<template x-if="track.federation_pending && $store.library.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
||||||
|
<span class="federation-error-badge"
|
||||||
|
:title="$store.library.federationFailure(track.content_id).message">!</span>
|
||||||
|
</template>
|
||||||
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && $store.library.isTrackLocal(track._federationTrack)">
|
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && $store.library.isTrackLocal(track._federationTrack)">
|
||||||
<span x-text="track.track_number || (idx + 1)"></span>
|
<span x-text="track.track_number || (idx + 1)"></span>
|
||||||
</template>
|
</template>
|
||||||
@@ -1312,13 +1340,17 @@
|
|||||||
<template x-if="!track.federation_pending || $store.library.isTrackLocal(track._federationTrack)">
|
<template x-if="!track.federation_pending || $store.library.isTrackLocal(track._federationTrack)">
|
||||||
<span x-text="idx + 1"></span>
|
<span x-text="idx + 1"></span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && !$store.library.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
||||||
<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"/>
|
||||||
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||||
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
<template x-if="track.federation_pending && $store.library.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
||||||
|
<span class="federation-error-badge"
|
||||||
|
:title="$store.library.federationFailure(track.content_id).message">!</span>
|
||||||
|
</template>
|
||||||
<template x-if="track.federation_pending && $store.library.federationDownload(track.content_id)">
|
<template x-if="track.federation_pending && $store.library.federationDownload(track.content_id)">
|
||||||
<span class="federation-download-progress"
|
<span class="federation-download-progress"
|
||||||
:class="{ indeterminate: !$store.library.federationDownload(track.content_id).total }">
|
:class="{ indeterminate: !$store.library.federationDownload(track.content_id).total }">
|
||||||
@@ -1452,12 +1484,16 @@
|
|||||||
<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"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
<template x-if="!$store.library.federationDownload(item.track.content_id)">
|
<template x-if="!$store.library.federationDownload(item.track.content_id) && !$store.library.federationFailure(item.track.content_id)">
|
||||||
<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"/>
|
||||||
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
<template x-if="$store.library.federationFailure(item.track.content_id)">
|
||||||
|
<span class="federation-error-badge"
|
||||||
|
:title="$store.library.federationFailure(item.track.content_id).message">!</span>
|
||||||
|
</template>
|
||||||
<template x-if="$store.library.federationDownload(item.track.content_id)">
|
<template x-if="$store.library.federationDownload(item.track.content_id)">
|
||||||
<span class="federation-download-progress"
|
<span class="federation-download-progress"
|
||||||
:class="{ indeterminate: !$store.library.federationDownload(item.track.content_id).total }">
|
:class="{ indeterminate: !$store.library.federationDownload(item.track.content_id).total }">
|
||||||
@@ -1606,7 +1642,11 @@
|
|||||||
x-text="'-' + formatTime(Math.max(0, $store.player.duration - $store.player.currentTime)) + ' / ' + formatTime($store.player.duration)"></div>
|
x-text="'-' + formatTime(Math.max(0, $store.player.duration - $store.player.currentTime)) + ' / ' + formatTime($store.player.duration)"></div>
|
||||||
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
|
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="player-version-chip">v{{ t.app_version() }}</div>
|
<a class="player-version-chip"
|
||||||
|
href="https://github.com/house-of-vanity/furumusic"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Furumusic on GitHub">v{{ t.app_version() }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-right">
|
<div class="player-right">
|
||||||
|
|||||||
@@ -676,6 +676,42 @@ button.user-stat:hover {
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
.artist-release-group-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.artist-release-group-heading .artist-release-group-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.artist-top-tracks-toggle {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.artist-top-tracks-toggle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
.artist-top-tracks-toggle svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.artist-top-tracks-toggle svg.expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
/* Release detail header */
|
/* Release detail header */
|
||||||
.release-header {
|
.release-header {
|
||||||
@@ -2437,7 +2473,16 @@ button.user-stat:hover {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: none;
|
text-decoration: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-version-chip:hover,
|
||||||
|
.player-version-chip:focus-visible {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
opacity: 0.9;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-account-chip {
|
.mobile-account-chip {
|
||||||
@@ -2729,6 +2774,26 @@ button.user-stat:hover {
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.federation-error-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px solid rgba(235, 105, 105, .7);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(190, 55, 55, .18);
|
||||||
|
color: #ffb0b0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.queue-federation-status .federation-error-badge {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
.federation-download-progress {
|
.federation-download-progress {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -3681,6 +3746,18 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-row:last-child { border-bottom: 0; }
|
.history-row:last-child { border-bottom: 0; }
|
||||||
|
.history-device-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: .45rem;
|
||||||
|
padding: .08rem .38rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
vertical-align: .12rem;
|
||||||
|
}
|
||||||
|
|
||||||
.history-cover {
|
.history-cover {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
Reference in New Issue
Block a user