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.
This commit is contained in:
cucadmuh
2026-06-28 05:08:02 +03:00
committed by GitHub
parent 979eb85ad1
commit 281e86fd3b
44 changed files with 1416 additions and 169 deletions
@@ -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<u32>,
since_ms: Option<i64>,
) -> Result<Vec<PlaySessionRecentTrackDto>, String> {
PlaySessionRepository::new(&runtime.store)
.recent_plays(limit.unwrap_or(50), since_ms)
}
#[tauri::command]
pub fn library_purge_server(
runtime: State<'_, LibraryRuntime>,
@@ -346,6 +346,9 @@ pub struct PlaySessionDayTrackDto {
pub listened_sec: f64,
pub completion: String,
pub started_at_ms: i64,
pub album: Option<String>,
pub album_id: Option<String>,
pub cover_art_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -355,6 +358,9 @@ pub struct PlaySessionDayDetailDto {
pub tracks: Vec<PlaySessionDayTrackDto>,
}
/// 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")]
@@ -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<PlaySessionDayTrackDto> {
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::<rusqlite::Result<Vec<_>>>()?;
let plays: Vec<PlaySpan> = 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<i64>,
) -> Result<Vec<PlaySessionRecentTrackDto>, 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::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
}
@@ -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"));
}