From 4c9a6e312a938a3d185f9938b57d0fa5a4ac98c9 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:29:19 -0500 Subject: [PATCH] library: one record, one recents row, however it was played MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/library/src/lib.rs | 106 +++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index 78be81a..e3986d3 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -329,19 +329,42 @@ impl Library { pub fn recent_albums(&self, limit: u32, min_secs: u32) -> Result> { 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 { 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");