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");