diff --git a/Cargo.lock b/Cargo.lock index 37573f2..23474cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.9.0" +version = "0.9.1" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 21515a7..f2f74f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.9.1" +version = "0.9.2" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/federation/devices.rs b/src/federation/devices.rs index 5186832..15c3e58 100644 --- a/src/federation/devices.rs +++ b/src/federation/devices.rs @@ -1712,7 +1712,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<()> { let now = now_ms(); 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 JOIN furumusic__track t ON t.id = ult.track_id JOIN furumusic__media_file m ON m.id = t.audio_file_id @@ -1729,6 +1729,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> continue; }; let track_id: i64 = row.get("track_id"); + let liked_hlc_ms = timestamp_from_iso(&row.get::("created_at")).unwrap_or(now); let fed_json = synced_fed_track_for_track(pool, track_id, &content_id) .await? .map(serde_json::to_value) @@ -1743,7 +1744,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> ) .bind(user_id) .bind(&content_id) - .bind(now) + .bind(liked_hlc_ms) .bind(format!("local_seed:like:{track_id}")) .bind(track_id) .bind(fed_json) @@ -4536,6 +4537,12 @@ fn iso_from_ms(ms: i64) -> String { .to_string() } +fn timestamp_from_iso(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|value| value.timestamp_millis()) +} + fn now_label() -> String { let secs = (now_ms() / 1000).max(0); format!( diff --git a/src/player/dto.rs b/src/player/dto.rs index b7457b7..a145d1e 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -78,6 +78,8 @@ pub(super) struct TrackItem { #[derive(Debug, Serialize, JsonSchema)] pub(super) struct PlaylistTrackItem { pub(super) playlist_track_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) sort_key: Option, #[serde(flatten)] pub(super) track: TrackItem, } diff --git a/src/player/mod.rs b/src/player/mod.rs index 5a43362..4e7266a 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -4111,6 +4111,7 @@ async fn build_playlist_track_items( .zip(playlist_track_ids) .map(|(track, playlist_track_id)| PlaylistTrackItem { playlist_track_id, + sort_key: None, track, }) .collect()) @@ -4309,14 +4310,50 @@ async fn likes_playlist_handler( .await .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::("track_id"), row.get::("sort_key"))) + .collect::>(); + + let mut track_items = build_track_items(tracks, pool) .await? .into_iter() .map(|track| PlaylistTrackItem { playlist_track_id: None, + sort_key: order_by_track.get(&track.id).copied(), track, }) - .collect(); + .collect::>(); + 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 { id: -1, @@ -6293,7 +6330,7 @@ async fn federation_playlist_tracks_handler( }; let rows = if path.id == -1 { 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 WHERE user_id = $1 AND liked = true AND local_track_id IS NULL AND fed_json IS NOT NULL diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 5d83d9c..15b6abe 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -3288,10 +3288,17 @@ document.addEventListener('alpine:init', () => { if (federationResponse.ok) { const references = await federationResponse.json(); const federated = (references || []) - .map(reference => this.federationTrackFromWire(reference.federation)) - .filter(Boolean) - .map(track => this.federationQueueTrack(track)); + .map(reference => { + const track = this.federationTrackFromWire(reference.federation); + if (!track) return null; + return { + ...this.federationQueueTrack(track), + sort_key: Number(reference.position || 0), + }; + }) + .filter(Boolean); playlist.tracks = [...(playlist.tracks || []), ...federated]; + if (Number(id) === -1) playlist.tracks = this.sortLikeTracks(playlist.tracks); } this.currentPlaylist = playlist; } catch {} @@ -3777,7 +3784,13 @@ document.addEventListener('alpine:init', () => { if (liked && !this.currentPlaylist.tracks.some(item => item.content_id === contentId)) { 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) { this.currentPlaylist = { @@ -3788,6 +3801,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) { const tracks = release?.tracks || []; if (!tracks.length) return false;