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 (