Compare commits

...
7 Commits
Author SHA1 Message Date
Ultradesu dc1fac1a94 hotfix
Build and Publish / Build and Publish Docker Image (push) Successful in 5m51s
2026-07-28 00:01:36 +01:00
Ultradesu 122214a896 Fixed playback history window
Build and Publish / Build and Publish Docker Image (push) Successful in 5m20s
2026-07-27 23:41:29 +01:00
Ultradesu 134f89fc9d Bump libs
Build and Publish / Build and Publish Docker Image (push) Successful in 5m18s
2026-07-27 23:17:07 +01:00
Ultradesu 232c171aac Add synced listening history 2026-07-27 23:16:03 +01:00
Ultradesu 845df4e031 Fixed autoplay after fed download 2026-07-27 17:33:48 +01:00
Ultradesu b0d8929b4c Fixed liked track sorting
Build and Publish / Build and Publish Docker Image (push) Successful in 10m14s
2026-07-27 17:09:59 +01:00
Ultradesu 291265be7d hotfix search
Build and Publish / Build and Publish Docker Image (push) Successful in 7m32s
2026-07-27 17:02:20 +01:00
14 changed files with 839 additions and 300 deletions
Generated
+11 -21
View File
@@ -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",
] ]
@@ -1718,7 +1708,7 @@ dependencies = [
[[package]] [[package]]
name = "federation-net" name = "federation-net"
version = "0.1.0" version = "0.1.0"
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
dependencies = [ dependencies = [
"blake3", "blake3",
"data-encoding", "data-encoding",
@@ -1845,7 +1835,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.8.4" version = "0.9.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@@ -3623,8 +3613,8 @@ dependencies = [
[[package]] [[package]]
name = "music-dht" name = "music-dht"
version = "0.1.0" version = "0.2.0"
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"blake3", "blake3",
@@ -5296,9 +5286,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 +5549,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 +6606,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",
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.9.0" version = "0.9.5"
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"
+150 -4
View File
@@ -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);
@@ -269,6 +270,9 @@ enum SyncOpPayload {
target_device_id: String, target_device_id: String,
command: PlaybackCommand, command: PlaybackCommand,
}, },
ListenRecorded {
event: ListenEvent,
},
} }
impl SyncOpPayload { impl SyncOpPayload {
@@ -771,6 +775,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`.
@@ -1712,7 +1751,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 +1768,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 +1783,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 +2198,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,
@@ -4421,6 +4560,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 +4676,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!(
+100 -8
View File
@@ -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,
+5 -5
View File
@@ -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')
+156
View File
@@ -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
View File
@@ -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)]
+267 -212
View File
@@ -1259,15 +1259,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 +4115,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 +4314,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,
@@ -5526,139 +5567,134 @@ 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(); let items = rows
let track_artists = if track_ids.is_empty() { .into_iter()
Vec::new() .map(|row| {
} else { let listen_id: String = row.get("listen_id");
sqlx::query_as::<_, TrackArtistRow>( let content_id: String = row.get("content_id");
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role let local_track_id: Option<i64> = row.get("local_track_id");
FROM furumusic__track_artist ta let metadata: serde_json::Value = row.get("metadata_json");
JOIN furumusic__artist a ON a.id = ta.artist_id let title = metadata
WHERE ta.track_id = ANY($1) .get("title")
ORDER BY ta.track_id, ta.position"#, .and_then(serde_json::Value::as_str)
) .unwrap_or("Unknown track")
.bind(&track_ids) .to_string();
.fetch_all(pool) let release_title = metadata
.await .get("release_title")
.map_err(|e| cot::Error::internal(e.to_string()))? .and_then(serde_json::Value::as_str)
}; .map(ToOwned::to_owned);
let artist_values = |key: &str| {
let mut track_main_artists: HashMap<i64, Vec<ArtistRef>> = HashMap::new(); metadata
let mut track_feat_artists: HashMap<i64, Vec<ArtistRef>> = HashMap::new(); .get(key)
for ta in &track_artists { .and_then(serde_json::Value::as_array)
let artist_ref = ArtistRef { .into_iter()
id: ta.artist_id, .flatten()
name: ta.artist_name.clone(), .filter_map(serde_json::Value::as_str)
}; .map(|name| serde_json::json!({"id": null, "name": name}))
if ta.role == "featuring" { .collect::<Vec<_>>()
track_feat_artists };
.entry(ta.track_id) let duration_ms: Option<i64> = row.get("track_duration_ms");
.or_default() let release_id: Option<i64> = row.get("release_id");
.push(artist_ref); let cover_url = track_cover_variant_url(
} else { row.get("cover_file_id"),
track_main_artists row.get("release_cover_file_id"),
.entry(ta.track_id) "medium",
.or_default() );
.push(artist_ref); let track = serde_json::json!({
} "id": local_track_id.map_or_else(
} || format!("history:{content_id}"),
|id| id.to_string(),
),
"content_id": content_id,
"title": 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": 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 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 +5713,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 +5767,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 +5786,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 +5801,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 +5831,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 +5855,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 +5873,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 +5883,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 +5901,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 +5928,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 +5951,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 +6301,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 +6368,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",
+2
View File
@@ -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)]
-28
View File
@@ -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,
+6 -1
View File
@@ -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>
+60 -8
View File
@@ -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,
@@ -754,7 +755,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 +1438,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 +1468,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 +2501,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 +3295,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 +3791,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 +3808,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 +3870,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 = {
@@ -3994,7 +4035,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 +4053,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',
+30 -6
View File
@@ -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 }">
@@ -905,12 +909,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 +1166,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 +1324,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 +1468,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 +1626,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">
+42 -1
View File
@@ -2437,7 +2437,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 +2738,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 +3710,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;