From 281e86fd3bdd3627eea519a2bb09a399ce9b6e26 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:08:02 +0300 Subject: [PATCH] fix(queue): persist timeline play history across queue replace (#1096) (#1204) * fix(queue): persist timeline play history across queue replace (#1096) Add a session-scoped play-history buffer (with play_session cold bootstrap) and timeline UI that shows history + current + upcoming without mutating the canonical queue or Subsonic sync. * fix(queue): pin timeline current to top and replay history in-place Timeline scroll matches queue mode (current at top). History clicks insert after the playing track instead of replacing the queue, and replayed tracks stay visible in the history strip. * docs: add CHANGELOG and credits for timeline play history (PR #1204) * fix(queue): resolve cross-server cover art for timeline history Include album/cover ids in play_session bootstrap rows, prefetch history refs through the queue resolver per server, and resolve before replay so Now Playing artwork works for inactive-server tracks. * fix(now-playing): stop playbackReport on cross-server track switch Send stopped to the previous server's playbackReport session when the playback server changes (queue click, history replay, etc.) so Who is listening clears on the server that was showing the prior track. * fix(queue): close timeline history review gaps for PR #1204 Defer play_session bootstrap until the library index is ready with retry while timeline mode is active; resolve history and queue rows by serverId + trackId for mixed-server queues; add tests for bootstrap defer and ref lookup. * chore(queue): remove dead timeline scroll guard in QueueList Timeline scroll is handled in the virtual-rows effect; the legacy branch is queue/playlist only. * fix(queue): address PR review nits for timeline play history Use authoritative row.ref.serverId for history clicks before resolver fill; simplify empty bootstrap seed; tighten completion types; unify recent_plays SQL. * fix(queue): immutable session history append for useSyncExternalStore Replace in-place push with a fresh array so getSnapshot returns a new reference and React re-renders on live appends without a playerStore update. --- CHANGELOG.md | 9 + .../crates/psysonic-library/src/commands.rs | 12 +- src-tauri/crates/psysonic-library/src/dto.rs | 6 + .../src/repos/play_session/mod.rs | 57 ++- .../src/repos/play_session/tests.rs | 94 +++++ src-tauri/src/lib.rs | 1 + src/api/library.ts | 30 +- src/components/QueuePanel.test.tsx | 13 +- src/components/QueuePanel.tsx | 25 +- src/components/queuePanel/QueueList.tsx | 372 ++++++++++++------ src/config/settingsCredits.ts | 1 + src/hooks/useTimelinePlayHistory.test.ts | 60 +++ src/hooks/useTimelinePlayHistory.ts | 112 ++++++ src/locales/de/settings.ts | 2 +- src/locales/en/settings.ts | 2 +- src/locales/es/settings.ts | 2 +- src/locales/fr/settings.ts | 2 +- src/locales/hu/settings.ts | 2 +- src/locales/ja/settings.ts | 2 +- src/locales/nb/settings.ts | 2 +- src/locales/nl/settings.ts | 2 +- src/locales/pl/settings.ts | 2 +- src/locales/ro/settings.ts | 2 +- src/locales/ru/settings.ts | 2 +- src/locales/zh/settings.ts | 2 +- src/store/audioEventHandlers.ts | 15 +- src/store/playTrackAction.ts | 32 +- src/store/playbackReportSession.test.ts | 19 + src/store/playbackReportSession.ts | 46 ++- src/store/playerStore.queue.test.ts | 11 + src/store/queueMutationActions.ts | 2 + .../timelinePlayHistory.integration.test.ts | 34 ++ src/store/timelineSessionHistory.test.ts | 65 +++ src/store/timelineSessionHistory.ts | 140 +++++++ src/test/helpers/storeReset.ts | 2 + src/utils/playback/queueIdentity.ts | 18 +- .../queue/buildTimelineDisplayRows.test.ts | 31 ++ src/utils/queue/buildTimelineDisplayRows.ts | 80 ++++ .../queue/playTimelineHistoryTrack.test.ts | 99 +++++ src/utils/queue/playTimelineHistoryTrack.ts | 52 +++ .../queue/timelineBootstrapReady.test.ts | 43 ++ src/utils/queue/timelineBootstrapReady.ts | 23 ++ src/utils/queue/timelineHistoryRefs.test.ts | 32 ++ src/utils/queue/timelineHistoryRefs.ts | 25 ++ 44 files changed, 1416 insertions(+), 169 deletions(-) create mode 100644 src/hooks/useTimelinePlayHistory.test.ts create mode 100644 src/hooks/useTimelinePlayHistory.ts create mode 100644 src/store/timelinePlayHistory.integration.test.ts create mode 100644 src/store/timelineSessionHistory.test.ts create mode 100644 src/store/timelineSessionHistory.ts create mode 100644 src/utils/queue/buildTimelineDisplayRows.test.ts create mode 100644 src/utils/queue/buildTimelineDisplayRows.ts create mode 100644 src/utils/queue/playTimelineHistoryTrack.test.ts create mode 100644 src/utils/queue/playTimelineHistoryTrack.ts create mode 100644 src/utils/queue/timelineBootstrapReady.test.ts create mode 100644 src/utils/queue/timelineBootstrapReady.ts create mode 100644 src/utils/queue/timelineHistoryRefs.test.ts create mode 100644 src/utils/queue/timelineHistoryRefs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b7acfd..bfb235a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -330,6 +330,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart). +### Timeline play history disappeared on album/playlist play + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1204](https://github.com/Psychotoxical/psysonic/pull/1204)**, closes [#1096](https://github.com/Psychotoxical/psysonic/issues/1096) + +* Timeline mode now keeps a session play-history strip (plus cold bootstrap of the last 50 plays from statistics) when Play album/playlist replaces the queue; canonical queue sync is unchanged. +* The current track stays pinned to the top of the list; clicking a history row inserts after the playing track instead of replacing the queue, and replayed tracks remain in the history strip. +* History rows from other servers resolve album/cover metadata per server so Now Playing artwork loads when replaying cross-server plays. +* Cross-server queue switches now send `playbackReport` **stopped** to the previous server so its Who is listening entry clears promptly. + ## Under the Hood ### ESLint setup and a strict lint pass over the frontend diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index dbaa0d24..f81d7f50 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -24,7 +24,7 @@ use crate::dto::{ FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto, LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto, - PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto, + PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto, TrackArtifactDto, TrackFactDto, TrackRefDto, }; use crate::live_search; @@ -1234,6 +1234,16 @@ pub fn library_get_player_stats_recent_days( PlaySessionRepository::new(&runtime.store).recent_days(limit.unwrap_or(30)) } +#[tauri::command] +pub fn library_get_recent_play_sessions( + runtime: State<'_, LibraryRuntime>, + limit: Option, + since_ms: Option, +) -> Result, String> { + PlaySessionRepository::new(&runtime.store) + .recent_plays(limit.unwrap_or(50), since_ms) +} + #[tauri::command] pub fn library_purge_server( runtime: State<'_, LibraryRuntime>, diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index 89170a30..559138a9 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -346,6 +346,9 @@ pub struct PlaySessionDayTrackDto { pub listened_sec: f64, pub completion: String, pub started_at_ms: i64, + pub album: Option, + pub album_id: Option, + pub cover_art_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -355,6 +358,9 @@ pub struct PlaySessionDayDetailDto { pub tracks: Vec, } +/// One row from `library_get_recent_play_sessions` (timeline cold bootstrap). +pub type PlaySessionRecentTrackDto = PlaySessionDayTrackDto; + /// Summary for one day in the recent-days list (no track rows). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/crates/psysonic-library/src/repos/play_session/mod.rs b/src-tauri/crates/psysonic-library/src/repos/play_session/mod.rs index e3765485..78b9f4fc 100644 --- a/src-tauri/crates/psysonic-library/src/repos/play_session/mod.rs +++ b/src-tauri/crates/psysonic-library/src/repos/play_session/mod.rs @@ -13,7 +13,7 @@ use rusqlite::{params, OptionalExtension}; use crate::dto::{ PlaySessionDayDetailDto, PlaySessionDayTrackDto, PlaySessionDayTotalsDto, PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto, - PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, + PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, }; use crate::store::LibraryStore; @@ -22,6 +22,21 @@ use completion::{ completion_from_position, effective_duration_sec, MIN_LISTENED_SEC, }; +fn map_play_session_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(PlaySessionDayTrackDto { + server_id: row.get(0)?, + track_id: row.get(1)?, + title: row.get(2)?, + artist: row.get(3)?, + listened_sec: row.get(4)?, + completion: row.get(5)?, + started_at_ms: row.get(6)?, + album: row.get(7)?, + album_id: row.get(8)?, + cover_art_id: row.get(9)?, + }) +} + struct DayAgg { total_listened_sec: f64, track_play_count: u32, @@ -223,24 +238,15 @@ impl<'a> PlaySessionRepository<'a> { let mut stmt = conn.prepare( "SELECT ps.server_id, ps.track_id, t.title, t.artist, \ - ps.listened_sec, ps.completion, ps.started_at_ms \ + ps.listened_sec, ps.completion, ps.started_at_ms, \ + t.album, t.album_id, t.cover_art_id \ FROM play_session ps \ JOIN track t ON t.server_id = ps.server_id AND t.id = ps.track_id \ WHERE date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \ ORDER BY ps.started_at_ms DESC", )?; let tracks = stmt - .query_map(params![date_iso], |row| { - Ok(PlaySessionDayTrackDto { - server_id: row.get(0)?, - track_id: row.get(1)?, - title: row.get(2)?, - artist: row.get(3)?, - listened_sec: row.get(4)?, - completion: row.get(5)?, - started_at_ms: row.get(6)?, - }) - })? + .query_map(params![date_iso], map_play_session_track_row)? .collect::>>()?; let plays: Vec = tracks @@ -346,4 +352,29 @@ impl<'a> PlaySessionRepository<'a> { }) .map_err(|e| e.to_string()) } + + /// Most recent track plays across all servers (newest first). Used for timeline cold bootstrap. + pub fn recent_plays( + &self, + limit: u32, + since_ms: Option, + ) -> Result, String> { + let limit = limit.clamp(1, 200); + self.store + .with_read_conn(|conn| { + let sql = "SELECT ps.server_id, ps.track_id, t.title, t.artist, \ + ps.listened_sec, ps.completion, ps.started_at_ms, \ + t.album, t.album_id, t.cover_art_id \ + FROM play_session ps \ + INNER JOIN track t \ + ON t.server_id = ps.server_id AND t.id = ps.track_id AND t.deleted = 0 \ + WHERE (?2 IS NULL OR ps.started_at_ms >= ?2) \ + ORDER BY ps.started_at_ms DESC \ + LIMIT ?1"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map(params![limit, since_ms], map_play_session_track_row)?; + rows.collect::>>() + }) + .map_err(|e| e.to_string()) + } } diff --git a/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs b/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs index b8c1f814..795e8f0c 100644 --- a/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs +++ b/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs @@ -424,3 +424,97 @@ fn purge_deletes_play_session_rows_for_server() { assert_eq!(s1_count, 0); assert_eq!(s2_count, 1); } + +#[test] +fn recent_plays_returns_newest_first_and_respects_limit() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 200); + seed_track(&store, "s1", "t2", 200); + seed_track(&store, "s2", "t3", 200); + let repo = PlaySessionRepository::new(&store); + for (sid, tid, ms) in [("s1", "t1", 1_000_i64), ("s1", "t2", 2_000), ("s2", "t3", 3_000)] { + repo.insert(&PlaySessionInputDto { + server_id: sid.into(), + track_id: tid.into(), + started_at_ms: ms, + listened_sec: 20.0, + position_max_sec: 15.0, + end_reason: "ended".into(), + duration_sec_hint: None, + }) + .expect("insert"); + } + let rows = repo.recent_plays(2, None).expect("recent"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].track_id, "t3"); + assert_eq!(rows[1].track_id, "t2"); +} + +#[test] +fn recent_plays_excludes_deleted_tracks() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 200); + let repo = PlaySessionRepository::new(&store); + repo.insert(&sample_input("s1", "t1")).expect("insert"); + store + .with_conn_mut("test.soft_delete", |conn| { + conn.execute( + "UPDATE track SET deleted = 1 WHERE server_id = ?1 AND id = ?2", + rusqlite::params!["s1", "t1"], + )?; + Ok(()) + }) + .expect("soft delete"); + let rows = repo.recent_plays(10, None).expect("recent"); + assert!(rows.is_empty()); +} + +#[test] +fn recent_plays_includes_album_cover_metadata() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[TrackRow { + server_id: "s1".into(), + id: "t1".into(), + title: "Song".into(), + title_sort: None, + artist: Some("Artist".into()), + artist_id: None, + album: "Album Name".into(), + album_id: Some("al-1".into()), + album_artist: None, + duration_sec: 200, + track_number: None, + disc_number: None, + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: Some("al-1".into()), + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + }]) + .expect("seed track"); + let repo = PlaySessionRepository::new(&store); + repo.insert(&sample_input("s1", "t1")).expect("insert"); + let rows = repo.recent_plays(1, None).expect("recent"); + assert_eq!(rows[0].album.as_deref(), Some("Album Name")); + assert_eq!(rows[0].album_id.as_deref(), Some("al-1")); + assert_eq!(rows[0].cover_art_id.as_deref(), Some("al-1")); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b052af08..0c3bb4c8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -837,6 +837,7 @@ pub fn run() { psysonic_library::commands::library_get_player_stats_day_detail, psysonic_library::commands::library_get_player_stats_year_bounds, psysonic_library::commands::library_get_player_stats_recent_days, + psysonic_library::commands::library_get_recent_play_sessions, psysonic_library::commands::library_purge_server, psysonic_library::commands::library_migrate_server_index_keys, psysonic_library::commands::library_delete_server_data, diff --git a/src/api/library.ts b/src/api/library.ts index ba3a7d1a..8d1ea909 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -709,7 +709,7 @@ export type PlaySessionDayTrack = { title: string; artist: string | null; listenedSec: number; - completion: 'partial' | 'full' | string; + completion: 'partial' | 'full'; startedAtMs: number; }; @@ -840,6 +840,34 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise('library_get_player_stats_recent_days', { limit }); } +export type PlaySessionRecentTrack = { + serverId: string; + trackId: string; + title: string; + artist: string | null; + album: string | null; + albumId: string | null; + coverArtId: string | null; + startedAtMs: number; + listenedSec: number; + completion: 'partial' | 'full'; +}; + +export function libraryGetRecentPlaySessions(args?: { + limit?: number; + sinceMs?: number; +}): Promise { + return invoke('library_get_recent_play_sessions', { + limit: args?.limit, + sinceMs: args?.sinceMs, + }).then(rows => + rows.map(row => ({ + ...row, + serverId: mapServerIdFromIndexKey(row.serverId), + })), + ); +} + // ── Event subscriptions ─────────────────────────────────────────────── export interface LibrarySyncProgressPayload { diff --git a/src/components/QueuePanel.test.tsx b/src/components/QueuePanel.test.tsx index ca28093c..b74eaef7 100644 --- a/src/components/QueuePanel.test.tsx +++ b/src/components/QueuePanel.test.tsx @@ -55,6 +55,7 @@ beforeEach(() => { onInvoke('audio_get_state', () => ({ playing: false })); onInvoke('audio_update_replay_gain', () => undefined); onInvoke('discord_update_presence', () => undefined); + onInvoke('library_get_recent_play_sessions', () => []); }); describe('QueuePanel — render surface', () => { @@ -154,14 +155,20 @@ describe('QueuePanel — display mode', () => { it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => { seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() }); const { container } = renderWithProviders(); - // The mode toggle is the first .queue-action-btn in the header (the - // collapse chevron is the second). The toggle's label names its target; - // from the default 'queue' that is the next mode in the cycle, "Timeline". const toggle = container.querySelector('.queue-header .queue-action-btn'); expect(toggle?.getAttribute('aria-label')).toBe('Timeline'); toggle!.click(); expect(useAuthStore.getState().queueDisplayMode).toBe('timeline'); }); + + it('timeline mode: renders current + upcoming only (not played queue prefix)', () => { + const tracks = makeTracks(4); + useAuthStore.getState().setQueueDisplayMode('timeline'); + seedQueue(tracks, { index: 1, currentTrack: tracks[1] }); + const { container } = renderWithProviders(); + const idxs = [...container.querySelectorAll('[data-queue-idx]')].map(r => r.getAttribute('data-queue-idx')); + expect(idxs).toEqual(['1', '2', '3']); + }); }); describe('QueuePanel — toolbar', () => { diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 05768bd7..727a1a0a 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -33,6 +33,8 @@ import { QueueToolbar } from './queuePanel/QueueToolbar'; import { QueueList } from './queuePanel/QueueList'; import { QueueTabBar } from './queuePanel/QueueTabBar'; import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll'; +import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '../hooks/useTimelinePlayHistory'; +import { buildTimelineDisplayRows } from '../utils/queue/buildTimelineDisplayRows'; import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope'; export default function QueuePanel() { @@ -120,6 +122,17 @@ function QueuePanelHostOrSolo() { const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed); const queueDisplayMode = useAuthStore(s => s.queueDisplayMode); const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode); + useTimelineBootstrapOnMode(queueDisplayMode === 'timeline'); + const timelineHistoryRefs = useTimelinePlayHistory(); + useTimelineHistoryResolver(timelineHistoryRefs, queueDisplayMode === 'timeline'); + const timelineRows = useMemo(() => { + if (queueDisplayMode !== 'timeline') return undefined; + return buildTimelineDisplayRows({ + historyRefs: timelineHistoryRefs, + queueItems, + queueIndex, + }); + }, [queueDisplayMode, timelineHistoryRefs, queueItems, queueIndex]); const toolbarButtons = useQueueToolbarStore(s => s.buttons); const durationMode = useAuthStore(s => s.queueDurationDisplayMode); const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode); @@ -221,11 +234,11 @@ function QueuePanelHostOrSolo() { // index for every index-based handler (play / remove / reorder / drag). const displayBaseIndex = queueDisplayMode === 'queue' ? Math.max(0, queueIndex + 1) : 0; const displayItems = displayBaseIndex > 0 ? queueItems.slice(displayBaseIndex) : queueItems; - // In queue mode the list can be empty while the queue still holds the - // now-playing (last) track — say "no upcoming" rather than "queue is empty". - const queueEmptyLabel = queueDisplayMode === 'queue' && queueItems.length > 0 - ? t('queue.noUpcoming') - : t('queue.emptyQueue'); + const queueEmptyLabel = queueDisplayMode === 'timeline' + ? (timelineRows && timelineRows.length > 0 ? '' : t('queue.emptyQueue')) + : queueDisplayMode === 'queue' && queueItems.length > 0 + ? t('queue.noUpcoming') + : t('queue.emptyQueue'); return (