library: one record, one recents row, however it was played

A saved album that also streams showed up twice in the sidebar's
recently-played — once as the library album, once as the catalog album —
because a play row carries album_id only when the played FILE was a
library track, so streamed plays of the same record grouped separately
under their catalog id.

The plays table already holds the bridge: any play that carried BOTH ids
(a local track played with the catalog page on screen) pins that catalog
album to that library album, by exact ids, never by name. recent_albums
now folds linked catalog plays into the library row — which also keeps the
row's timestamp honest when the newest listen was the streamed one — and
drops them from the catalog arm. Deleting the album from the library
dissolves the link, and its catalog plays stand alone again.

Regression test reproduces the exact two-Origins shape: dual-id play plus
a newer streamed play must come back as one library row wearing the
streamed play's timestamp, sorted above an older unrelated record.
This commit is contained in:
KeelrLeek committed 2026-08-03 16:29:19 -05:00
1 parent 5ac456102f
commit 4c9a6e312a
1 file changed
+99 -7
+99 -7
View File
@@ -329,19 +329,42 @@ impl Library {
pub fn recent_albums(&self, limit: u32, min_secs: u32) -> Result<Vec<RecentAlbum>> {
let conn = self.conn.lock().unwrap();
// Two sources, one list. The catalog arm excludes rows that already
// have a library album so a local track with catalog identity cannot
// appear twice. Within one catalog_album_id the title/artist/cover are
// identical on every row, so MAX() is just "pick one".
// Two sources, one list — and one RECORD must stay one row even when
// it was played both ways. A play carries `album_id` only when the
// played file was a library track, so a STREAMED play of a record the
// user also saved groups under its catalog id with album_id NULL, and
// the old per-row exclusion let the same record appear twice (library
// "Origin" over catalog "Origin", 2026-08-03).
//
// The bridge is `link`: any play that carried BOTH ids — a local track
// played with catalog identity on screen — pins that catalog album to
// that library album, by exact ids, never by name. The library arm
// then absorbs the linked catalog plays (so streaming the record last
// still bubbles it to the top), and the catalog arm drops them. An
// album later deleted from the library falls out of `link` with it,
// and its catalog plays stand on their own again. Within one
// catalog_album_id the title/artist/cover are identical on every row,
// so MAX() is just "pick one".
let mut stmt = conn.prepare(
"SELECT * FROM (
"WITH link AS (
SELECT DISTINCT p.album_id, p.catalog_album_id
FROM plays p
JOIN albums a ON a.id = p.album_id
WHERE p.catalog_album_id IS NOT NULL
)
SELECT * FROM (
SELECT 'library' AS kind, a.id AS lib_id, NULL AS cat_id,
a.artist AS artist, a.title AS title,
a.art_path AS art_path, NULL AS art_url,
MAX(p.started_at) AS last_at
FROM plays p
JOIN albums a ON a.id = p.album_id
WHERE p.album_id IS NOT NULL AND p.seconds >= ?2
JOIN albums a
ON a.id = COALESCE(
p.album_id,
(SELECT l.album_id FROM link l
WHERE l.catalog_album_id = p.catalog_album_id
LIMIT 1))
WHERE p.seconds >= ?2
GROUP BY a.id
UNION ALL
SELECT 'catalog', NULL, p.catalog_album_id,
@@ -352,6 +375,8 @@ impl Library {
WHERE p.catalog_album_id IS NOT NULL
AND p.album_id IS NULL
AND p.seconds >= ?2
AND NOT EXISTS (SELECT 1 FROM link l
WHERE l.catalog_album_id = p.catalog_album_id)
GROUP BY p.catalog_album_id
)
ORDER BY last_at DESC
@@ -787,6 +812,73 @@ fn cached_art_path(path: &str, art_dir: &Path) -> Option<String> {
mod tests {
use super::*;
/// One record, played both as library files and as a stream, is ONE
/// recents row. This is the "two Origins in the sidebar" bug: a saved
/// album's streamed plays carry only the catalog id (album_id NULL), and
/// the old query let them stand as a second row beside the library one.
/// The dual-id play a local track records when the catalog page is on
/// screen is the bridge that folds them together — and the merged row
/// must carry the NEWEST play from either side, so a record streamed
/// five minutes ago sorts above one saved-and-played an hour ago.
#[test]
fn recents_merge_streamed_plays_of_a_saved_album() {
let dir = std::env::temp_dir().join("ssplus-lib-recents-merge");
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("t.db");
let _ = std::fs::remove_file(&db);
let lib = Library::open(&db).unwrap();
let album = lib
.upsert_album("Vanilla", "Origin", Some(2015), Some("origin.jpg"), None, None, 100)
.unwrap();
lib.upsert_track(
album, Some(1), "Intro", "Vanilla", Some(120), None, None, None, None,
"C:\\m\\origin\\01.mp3", 100,
)
.unwrap();
let cat = CatalogPlay {
album_id: Some("794088211"),
title: Some("Origin"),
artist: Some("Vanilla"),
cover: Some("https://cdn/origin.jpg"),
};
// A local track played with the catalog page on screen: BOTH ids.
let p1 = lib
.record_play("Vanilla", "Intro", Some("C:\\m\\origin\\01.mp3"), cat, 1000)
.unwrap();
lib.add_play_seconds(p1, 60).unwrap();
// The same record STREAMED later: catalog id only, newer timestamp.
let cat2 = CatalogPlay {
album_id: Some("794088211"),
title: Some("Origin"),
artist: Some("Vanilla"),
cover: Some("https://cdn/origin.jpg"),
};
let p2 = lib.record_play("Vanilla", "Intro", None, cat2, 2000).unwrap();
lib.add_play_seconds(p2, 60).unwrap();
// An unrelated stream-only record keeps its own row.
let other = CatalogPlay {
album_id: Some("555"),
title: Some("Elsewhere"),
artist: Some("Someone"),
cover: None,
};
let p3 = lib.record_play("Someone", "Song", None, other, 1500).unwrap();
lib.add_play_seconds(p3, 60).unwrap();
let rows = lib.recent_albums(10, 45).unwrap();
let origins: Vec<_> = rows.iter().filter(|r| r.title == "Origin").collect();
assert_eq!(origins.len(), 1, "one record, one row: {rows:?}");
assert_eq!(origins[0].kind, "library");
assert_eq!(origins[0].album_id, Some(album));
// The streamed play is the newest — the merged row must carry it.
assert_eq!(origins[0].last_played_at, 2000);
assert!(rows.iter().any(|r| r.catalog_album_id.as_deref() == Some("555")));
// And the merged record sorts ABOVE the older unrelated one.
assert_eq!(rows[0].title, "Origin");
}
#[test]
fn roundtrip() {
let dir = std::env::temp_dir().join("ssplus-lib-test");