From 06c58b339967a7c5996ba2ce3784145ed9d48127 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:56:53 +0300 Subject: [PATCH] fix(new-releases): dedupe freshness overlay --- CHANGELOG.md | 6 + .../psysonic-library/src/album_overlay.rs | 301 ++++++++++++++++++ .../crates/psysonic-library/src/commands.rs | 14 +- src-tauri/crates/psysonic-library/src/dto.rs | 30 ++ .../psysonic-library/src/identity/keys.rs | 2 +- .../psysonic-library/src/identity/mod.rs | 1 + src-tauri/crates/psysonic-library/src/lib.rs | 1 + .../psysonic-library/src/scope_merge.rs | 2 +- src-tauri/src/lib.rs | 2 + .../album/hooks/useHotNewReleaseOverlay.ts | 13 +- .../album/utils/hotNewReleases.test.ts | 81 ++++- src/features/album/utils/hotNewReleases.ts | 79 ++++- src/generated/bindings.ts | 29 ++ src/lib/api/library/scopeReads.test.ts | 48 +++ src/lib/api/library/scopeReads.ts | 30 ++ 15 files changed, 608 insertions(+), 31 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/src/album_overlay.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 13eb93e1..6d8483ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Large fresh libraries now refresh SQLite's query-planner statistics after bulk indexing. Restarting after an interrupted multi-server sync no longer enters a minutes-long single-core identity rebuild, and existing affected databases repair their stale statistics automatically. +### New Releases — remove duplicate albums from the freshness overlay + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)** + +* The first New Releases page now keeps the local catalogue's logical album de-duplication when fresh network results arrive, including alternate physical copies on one server and matching copies across several servers. + ## [1.50.0] diff --git a/src-tauri/crates/psysonic-library/src/album_overlay.rs b/src-tauri/crates/psysonic-library/src/album_overlay.rs new file mode 100644 index 00000000..eb5cfa71 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/album_overlay.rs @@ -0,0 +1,301 @@ +//! Identity-aware reconciliation for the network-only New Releases overlay. + +use std::collections::HashMap; + +use rusqlite::params_from_iter; +use rusqlite::types::Value as SqlValue; + +use crate::dto::{ + LibraryAlbumOverlayResolutionDto, LibraryResolveAlbumOverlayRequest, LibraryScopePair, +}; +use crate::scope_merge::{lookup_album_key, non_empty_scopes, scope_cte_sql}; +use crate::store::LibraryStore; + +const MAX_OVERLAY_ALBUMS: usize = 128; + +fn indexed_album_exists( + conn: &rusqlite::Connection, + server_id: &str, + album_id: &str, +) -> rusqlite::Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM track INDEXED BY idx_track_album \ + WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0)", + rusqlite::params![server_id, album_id], + |row| row.get(0), + ) +} + +fn resolve_representatives( + conn: &rusqlite::Connection, + scopes: &[LibraryScopePair], + indexed_groups: &[(u32, String)], +) -> rusqlite::Result> { + if indexed_groups.is_empty() { + return Ok(HashMap::new()); + } + let (scope_cte, mut binds) = scope_cte_sql(scopes); + let values = indexed_groups + .iter() + .map(|_| "(?, ?)") + .collect::>() + .join(", "); + for (group, identity_key) in indexed_groups { + binds.push(SqlValue::Integer(i64::from(*group))); + binds.push(SqlValue::Text(identity_key.clone())); + } + let sql = format!( + "{scope_cte}, \ + overlay_identity(group_id, album_key) AS (VALUES {values}), \ + representative_candidates AS ( \ + SELECT identity.group_id, t.server_id, t.album_id, s.pr, t.id \ + FROM overlay_identity identity \ + CROSS JOIN scope s \ + CROSS JOIN cluster.track_cluster_key ck INDEXED BY idx_ck_scope_album \ + ON ck.server_id = s.server_id AND ck.library_id = s.library_id \ + AND ck.album_key = identity.album_key \ + INNER JOIN track t INDEXED BY sqlite_autoindex_track_1 \ + ON t.server_id = ck.server_id AND t.id = ck.track_id \ + WHERE t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \ + ), \ + ranked AS ( \ + SELECT group_id, server_id, album_id, \ + ROW_NUMBER() OVER ( \ + PARTITION BY group_id \ + ORDER BY pr ASC, album_id ASC, id ASC, server_id ASC \ + ) AS rn \ + FROM representative_candidates \ + ) \ + SELECT group_id, server_id, album_id FROM ranked WHERE rn = 1" + ); + let mut statement = conn.prepare(&sql)?; + let rows = statement.query_map(params_from_iter(binds.iter()), |row| { + Ok(( + row.get::<_, u32>(0)?, + (row.get::<_, String>(1)?, row.get::<_, String>(2)?), + )) + })?; + rows.collect() +} + +/// Reconcile the network-only freshness overlay with the same album identity +/// partitions used by local multi-server browse. The returned group ids are +/// request-local so internal cluster keys never cross the IPC boundary. +pub fn resolve_album_overlay( + store: &LibraryStore, + request: &LibraryResolveAlbumOverlayRequest, +) -> Result, String> { + let scopes = non_empty_scopes(&request.scopes)?; + if request.albums.len() > MAX_OVERLAY_ALBUMS { + return Err(format!( + "album overlay is limited to {MAX_OVERLAY_ALBUMS} rows" + )); + } + + store + .with_mainstage_read_conn(|conn| { + let mut group_by_identity = HashMap::::new(); + let mut representative_group_keys = HashMap::::new(); + let mut direct_representatives = HashMap::::new(); + let mut groups = Vec::with_capacity(request.albums.len()); + + for album in &request.albums { + let server_id = album.server_id.trim(); + let album_id = album.id.trim(); + let name = album.name.trim(); + if server_id.is_empty() || album_id.is_empty() || name.is_empty() { + return Err(rusqlite::Error::InvalidParameterName( + "overlay album server_id, id, and name are required".into(), + )); + } + + let indexed_key = lookup_album_key(conn, server_id, album_id)?; + let exists = + indexed_key.is_some() || indexed_album_exists(conn, server_id, album_id)?; + let artist = album + .artist + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let normalized_key = (!exists) + .then(|| crate::identity::build_album_key(artist, name)) + .flatten(); + let identity_key = indexed_key + .clone() + .or_else(|| normalized_key.clone()) + .unwrap_or_else(|| { + crate::identity::concrete_physical_album_key(server_id, album_id) + }); + let next_group = u32::try_from(group_by_identity.len()).unwrap_or(u32::MAX); + let group = *group_by_identity + .entry(identity_key.clone()) + .or_insert(next_group); + if indexed_key.is_some() || normalized_key.is_some() { + representative_group_keys + .entry(group) + .or_insert(identity_key); + } else if exists { + direct_representatives + .entry(group) + .or_insert_with(|| (server_id.to_string(), album_id.to_string())); + } + groups.push(group); + } + + let representative_groups = representative_group_keys.into_iter().collect::>(); + let representatives = resolve_representatives(conn, scopes, &representative_groups)?; + Ok(groups + .into_iter() + .map(|group| { + let representative = representatives + .get(&group) + .or_else(|| direct_representatives.get(&group)); + LibraryAlbumOverlayResolutionDto { + group, + representative_server_id: representative.map(|value| value.0.clone()), + representative_id: representative.map(|value| value.1.clone()), + } + }) + .collect()) + }) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::LibraryAlbumOverlayCandidateDto; + use crate::identity::ensure_cluster_keys_built; + use rusqlite::params; + + fn scope(server_id: &str, library_id: &str) -> LibraryScopePair { + LibraryScopePair { + server_id: server_id.into(), + library_id: Some(library_id.into()), + } + } + + fn insert_album_track( + store: &LibraryStore, + server_id: &str, + track_id: &str, + album_id: &str, + library_id: &str, + ) { + store + .with_conn_mut("test.album_overlay_seed", |conn| { + conn.execute( + "INSERT INTO artist (server_id, id, name, synced_at) \ + VALUES (?1, ?2, 'Artist', 1) ON CONFLICT(server_id, id) DO NOTHING", + params![server_id, format!("artist-{server_id}")], + )?; + conn.execute( + "INSERT INTO track (server_id, id, title, artist, artist_id, album, album_id, \ + album_artist, duration_sec, library_id, server_created_at, synced_at, raw_json) \ + VALUES (?1, ?2, ?2, 'Artist', ?3, 'Shared', ?4, 'Artist', 180, ?5, 1, 1, '{}')", + params![server_id, track_id, format!("artist-{server_id}"), album_id, library_id], + )?; + Ok(()) + }) + .unwrap(); + } + + #[test] + fn maps_physical_copies_to_the_scope_representative() { + let store = LibraryStore::open_in_memory(); + insert_album_track(&store, "s1", "t-priority", "a-priority", "l1"); + insert_album_track(&store, "s1", "t-alternate", "z-alternate", "l1"); + insert_album_track(&store, "s2", "t-second", "b-second", "l2"); + ensure_cluster_keys_built(&store, "s1").unwrap(); + ensure_cluster_keys_built(&store, "s2").unwrap(); + + let resolutions = resolve_album_overlay( + &store, + &LibraryResolveAlbumOverlayRequest { + scopes: vec![scope("s1", "l1"), scope("s2", "l2")], + albums: vec![ + LibraryAlbumOverlayCandidateDto { + server_id: "s1".into(), + id: "z-alternate".into(), + name: "Shared".into(), + artist: Some("Artist".into()), + }, + LibraryAlbumOverlayCandidateDto { + server_id: "s2".into(), + id: "b-second".into(), + name: "Shared".into(), + artist: Some("Artist".into()), + }, + ], + }, + ) + .unwrap(); + + assert_eq!(resolutions[0].group, resolutions[1].group); + assert!(resolutions.iter().all(|resolution| { + resolution.representative_server_id.as_deref() == Some("s1") + && resolution.representative_id.as_deref() == Some("a-priority") + })); + } + + #[test] + fn groups_unindexed_copies_with_rust_normalization() { + let store = LibraryStore::open_in_memory(); + let resolutions = resolve_album_overlay( + &store, + &LibraryResolveAlbumOverlayRequest { + scopes: vec![scope("s1", "l1"), scope("s2", "l2")], + albums: vec![ + LibraryAlbumOverlayCandidateDto { + server_id: "s1".into(), + id: "fresh-a".into(), + name: "My Arms, Your Hearse".into(), + artist: Some("Opeth".into()), + }, + LibraryAlbumOverlayCandidateDto { + server_id: "s2".into(), + id: "fresh-b".into(), + name: "My Arms Your Hearse".into(), + artist: Some("Opeth".into()), + }, + ], + }, + ) + .unwrap(); + + assert_eq!(resolutions[0].group, resolutions[1].group); + assert!(resolutions.iter().all(|resolution| { + resolution.representative_server_id.is_none() && resolution.representative_id.is_none() + })); + } + + #[test] + fn maps_an_unindexed_copy_to_an_existing_scope_representative() { + let store = LibraryStore::open_in_memory(); + insert_album_track(&store, "s1", "t-canonical", "a-canonical", "l1"); + ensure_cluster_keys_built(&store, "s1").unwrap(); + + let resolutions = resolve_album_overlay( + &store, + &LibraryResolveAlbumOverlayRequest { + scopes: vec![scope("s1", "l1"), scope("s2", "l2")], + albums: vec![LibraryAlbumOverlayCandidateDto { + server_id: "s2".into(), + id: "fresh-copy".into(), + name: "Shared".into(), + artist: Some("Artist".into()), + }], + }, + ) + .unwrap(); + + assert_eq!( + resolutions[0].representative_server_id.as_deref(), + Some("s1") + ); + assert_eq!( + resolutions[0].representative_id.as_deref(), + Some("a-canonical") + ); + } +} diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 5a2df290..27759cea 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -28,7 +28,8 @@ use crate::dto::{ LibraryScopeArtistDetailRequest, LibraryScopeArtistDetailResponse, LibraryScopeBrowseRequest, LibraryScopeBrowseResponse, LibraryScopeComposerDetailRequest, LibraryScopeComposerDetailResponse, LibraryScopeListRequest, LibraryScopeSearchRequest, - LibraryEntitySourceDto, LibraryResolveEntitySourcesRequest, LibraryStatisticsDto, + LibraryAlbumOverlayResolutionDto, LibraryEntitySourceDto, + LibraryResolveAlbumOverlayRequest, LibraryResolveEntitySourcesRequest, LibraryStatisticsDto, LibraryStatisticsRequest, LibraryTrackDto, LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, @@ -838,6 +839,17 @@ pub async fn library_resolve_entity_sources( library_spawn_blocking(move || scope_merge::resolve_entity_sources(&store, &request)).await } +#[tauri::command] +#[specta::specta] +pub async fn library_resolve_album_overlay( + runtime: State<'_, LibraryRuntime>, + request: LibraryResolveAlbumOverlayRequest, +) -> Result, String> { + let store = Arc::clone(&runtime.store); + library_spawn_blocking(move || crate::album_overlay::resolve_album_overlay(&store, &request)) + .await +} + // NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!. #[tauri::command] pub async fn library_scope_list_albums( diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index a651c9aa..e6fa2886 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -759,6 +759,36 @@ pub struct LibraryEntitySourceDto { pub user_rating: Option, } +/// One raw network album that the New Releases freshness overlay needs to +/// reconcile with the local scope identity graph. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAlbumOverlayCandidateDto { + pub server_id: String, + pub id: String, + pub name: String, + pub artist: Option, +} + +/// Resolve a bounded network overlay batch against one ordered browse scope. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LibraryResolveAlbumOverlayRequest { + pub scopes: Vec, + pub albums: Vec, +} + +/// Order-preserving overlay resolution. `group` is valid only within this +/// response and lets the frontend collapse logical copies without persisting +/// internal cluster identity keys. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAlbumOverlayResolutionDto { + pub group: u32, + pub representative_server_id: Option, + pub representative_id: Option, +} + /// One selected server and its optional music-folder filter for aggregate index reads. /// An empty `library_ids` list includes every indexed folder on that server. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] diff --git a/src-tauri/crates/psysonic-library/src/identity/keys.rs b/src-tauri/crates/psysonic-library/src/identity/keys.rs index d9d0f446..ff359081 100644 --- a/src-tauri/crates/psysonic-library/src/identity/keys.rs +++ b/src-tauri/crates/psysonic-library/src/identity/keys.rs @@ -17,7 +17,7 @@ fn album_identity_source<'a>(album_artist: Option<&'a str>, artist: Option<&'a s .or_else(|| artist.map(str::trim).filter(|s| !s.is_empty())) } -pub(super) fn build_album_key(artist: Option<&str>, album: &str) -> Option { +pub(crate) fn build_album_key(artist: Option<&str>, album: &str) -> Option { join_norm_parts([norm_part(artist.unwrap_or("")), norm_part(album)]) } diff --git a/src-tauri/crates/psysonic-library/src/identity/mod.rs b/src-tauri/crates/psysonic-library/src/identity/mod.rs index d9c27c2d..9b2eca5a 100644 --- a/src-tauri/crates/psysonic-library/src/identity/mod.rs +++ b/src-tauri/crates/psysonic-library/src/identity/mod.rs @@ -24,3 +24,4 @@ pub(crate) use rebuild::{ }; pub use keys::{build_track_cluster_keys, TrackClusterKeys}; +pub(crate) use keys::build_album_key; diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index 503fd0a9..299d3a15 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -9,6 +9,7 @@ pub mod advanced_search; mod advanced_search_mood; +pub mod album_overlay; pub mod album_compilation_filter; pub mod analysis_backfill; pub mod analysis_backfill_policy; diff --git a/src-tauri/crates/psysonic-library/src/scope_merge.rs b/src-tauri/crates/psysonic-library/src/scope_merge.rs index 4dc40622..0a935ff9 100644 --- a/src-tauri/crates/psysonic-library/src/scope_merge.rs +++ b/src-tauri/crates/psysonic-library/src/scope_merge.rs @@ -1638,7 +1638,7 @@ pub fn search_tracks( }) } -fn lookup_album_key( +pub(crate) fn lookup_album_key( conn: &rusqlite::Connection, server_id: &str, album_id: &str, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6a6c2c0..96a3fc60 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -177,6 +177,7 @@ fn specta_builder() -> tauri_specta::Builder { psysonic_library::commands::library_genre_tags_run, psysonic_library::commands::library_cluster_rebuild, psysonic_library::commands::library_resolve_entity_sources, + psysonic_library::commands::library_resolve_album_overlay, psysonic_library::commands::library_sync_bind_session, psysonic_library::commands::library_sync_clear_session, psysonic_library::commands::library_set_playback_hint, @@ -1352,6 +1353,7 @@ pub fn run() { psysonic_library::commands::library_genre_tags_run, psysonic_library::commands::library_cluster_rebuild, psysonic_library::commands::library_resolve_entity_sources, + psysonic_library::commands::library_resolve_album_overlay, psysonic_library::commands::library_scope_list_albums, psysonic_library::commands::library_scope_browse, psysonic_library::commands::library_scope_browse_projection_inspect, diff --git a/src/features/album/hooks/useHotNewReleaseOverlay.ts b/src/features/album/hooks/useHotNewReleaseOverlay.ts index 4ef021a6..ca7bd5c4 100644 --- a/src/features/album/hooks/useHotNewReleaseOverlay.ts +++ b/src/features/album/hooks/useHotNewReleaseOverlay.ts @@ -1,15 +1,20 @@ import { useEffect, useState } from 'react'; -import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import type { LibraryScopePair } from '@/lib/api/library/scopeReads'; -import { fetchHotNewReleases } from '@/features/album/utils/hotNewReleases'; +import { + fetchHotNewReleases, + type ResolvedHotNewRelease, +} from '@/features/album/utils/hotNewReleases'; /** Network-only first-page overlay; stale results are discarded when scope changes. */ export function useHotNewReleaseOverlay( scopes: LibraryScopePair[], scopeFingerprint: string, active: boolean, -): { scopeFingerprint: string; albums: SubsonicAlbum[] } { - const [result, setResult] = useState({ scopeFingerprint: '', albums: [] as SubsonicAlbum[] }); +): { scopeFingerprint: string; albums: ResolvedHotNewRelease[] } { + const [result, setResult] = useState({ + scopeFingerprint: '', + albums: [] as ResolvedHotNewRelease[], + }); useEffect(() => { let cancelled = false; diff --git a/src/features/album/utils/hotNewReleases.test.ts b/src/features/album/utils/hotNewReleases.test.ts index a9450e2f..341988b8 100644 --- a/src/features/album/utils/hotNewReleases.test.ts +++ b/src/features/album/utils/hotNewReleases.test.ts @@ -3,32 +3,72 @@ import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import { fetchHotNewReleases, mergeHotNewReleases } from './hotNewReleases'; const getAlbumListForServer = vi.fn(); +const libraryResolveAlbumOverlay = vi.fn(); vi.mock('@/lib/api/subsonicLibrary', () => ({ getAlbumListForServer: (...args: unknown[]) => getAlbumListForServer(...args), })); +vi.mock('@/lib/api/library/scopeReads', () => ({ + libraryResolveAlbumOverlay: (...args: unknown[]) => libraryResolveAlbumOverlay(...args), +})); function album(id: string, created: string, serverId = 's1'): SubsonicAlbum { return { id, created, serverId, name: id, artist: 'Artist', artistId: 'artist', songCount: 1, duration: 1 }; } describe('hot New Releases overlay', () => { - beforeEach(() => getAlbumListForServer.mockReset()); + beforeEach(() => { + getAlbumListForServer.mockReset(); + libraryResolveAlbumOverlay.mockReset(); + }); - it('merges only equal owner identities and orders by catalog creation time', () => { + it('keeps the local representative while collapsing physical copies', () => { const merged = mergeHotNewReleases( - [album('local', '2026-01-01T00:00:00Z'), album('same', '2026-01-01T00:00:00Z')], + [album('canonical', '2026-01-01T00:00:00Z', 's1')], [ - album('hot', '2026-01-03T00:00:00Z', 's2'), - album('same', '2026-01-02T00:00:00Z', 's2'), - album('same', '2026-01-04T00:00:00Z', 's1'), + { + album: album('alternate', '2026-01-03T00:00:00Z', 's1'), + group: 0, + representativeServerId: 's1', + representativeId: 'canonical', + }, + { + album: album('other-server-copy', '2026-01-04T00:00:00Z', 's2'), + group: 0, + representativeServerId: 's1', + representativeId: 'canonical', + }, ], ); - expect(merged.map(item => `${item.serverId}:${item.id}`)).toEqual([ - 's1:same', - 's2:hot', - 's2:same', - 's1:local', + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ + serverId: 's1', + id: 'canonical', + created: '2026-01-04T00:00:00Z', + }); + }); + + it('collapses unindexed hot copies by their request-local identity group', () => { + const merged = mergeHotNewReleases([], [ + { album: album('older', '2026-01-02T00:00:00Z', 's1'), group: 3 }, + { album: album('newer', '2026-01-03T00:00:00Z', 's2'), group: 3 }, + { album: album('distinct', '2026-01-01T00:00:00Z', 's2'), group: 4 }, ]); + expect(merged.map(item => item.id)).toEqual(['newer', 'distinct']); + }); + + it('uses the canonical owner when the local first page does not contain it', () => { + const merged = mergeHotNewReleases([], [{ + album: album('fresh-copy', '2026-01-03T00:00:00Z', 's2'), + group: 0, + representativeServerId: 's1', + representativeId: 'canonical', + }]); + + expect(merged).toEqual([expect.objectContaining({ + serverId: 's1', + id: 'canonical', + created: '2026-01-03T00:00:00Z', + })]); }); it('requests each selected library and keeps only recent valid dates', async () => { @@ -38,6 +78,13 @@ describe('hot New Releases overlay', () => { album(`${serverId}-old`, '2026-07-12T11:00:00Z', serverId), album(`${serverId}-invalid`, 'not-a-date', serverId), ]); + libraryResolveAlbumOverlay.mockImplementation(async ({ albums }: { albums: SubsonicAlbum[] }) => ( + albums.map((_item, group) => ({ + group, + representativeServerId: null, + representativeId: null, + })) + )); const result = await fetchHotNewReleases([ { serverId: 's1', libraryId: 'l1' }, @@ -50,6 +97,16 @@ describe('hot New Releases overlay', () => { expect(getAlbumListForServer).toHaveBeenCalledWith( 's2', 'newest', 24, 0, { musicFolderId: 'l2' }, 8000, ); - expect(result.map(item => item.id).sort()).toEqual(['s1-fresh', 's2-fresh']); + expect(libraryResolveAlbumOverlay).toHaveBeenCalledWith({ + scopes: [ + { serverId: 's1', libraryId: 'l1' }, + { serverId: 's2', libraryId: 'l2' }, + ], + albums: [ + { serverId: 's1', id: 's1-fresh', name: 's1-fresh', artist: 'Artist' }, + { serverId: 's2', id: 's2-fresh', name: 's2-fresh', artist: 'Artist' }, + ], + }); + expect(result.map(item => item.album.id).sort()).toEqual(['s1-fresh', 's2-fresh']); }); }); diff --git a/src/features/album/utils/hotNewReleases.ts b/src/features/album/utils/hotNewReleases.ts index 174297ea..c87399d3 100644 --- a/src/features/album/utils/hotNewReleases.ts +++ b/src/features/album/utils/hotNewReleases.ts @@ -1,6 +1,9 @@ import { getAlbumListForServer } from '@/lib/api/subsonicLibrary'; import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; -import type { LibraryScopePair } from '@/lib/api/library/scopeReads'; +import { + libraryResolveAlbumOverlay, + type LibraryScopePair, +} from '@/lib/api/library/scopeReads'; import { ownedEntityKey } from '@/lib/util/ownedEntityKey'; export const HOT_NEW_RELEASE_WINDOW_MS = 2 * 24 * 60 * 60 * 1000; @@ -12,18 +15,52 @@ function createdAtMs(album: SubsonicAlbum): number | null { return Number.isFinite(value) ? value : null; } +export interface ResolvedHotNewRelease { + album: SubsonicAlbum; + group: number; + representativeServerId?: string | null; + representativeId?: string | null; +} + +function overlayCreatedAt(local: SubsonicAlbum, hot: SubsonicAlbum): SubsonicAlbum { + const localCreated = createdAtMs(local) ?? -Infinity; + const hotCreated = createdAtMs(hot) ?? -Infinity; + return hotCreated > localCreated ? { ...local, created: hot.created } : local; +} + export function mergeHotNewReleases( local: SubsonicAlbum[], - hot: SubsonicAlbum[], + hot: ResolvedHotNewRelease[], ): SubsonicAlbum[] { - const byId = new Map(); - for (const album of local) byId.set(ownedEntityKey(album), album); - for (const album of hot) { - const key = ownedEntityKey(album); - const prior = byId.get(key); - byId.set(key, prior ? { ...prior, ...album } : album); + const merged = new Map(); + const localEntryByOwner = new Map(); + for (const album of local) { + const entryKey = `local:${ownedEntityKey(album)}`; + merged.set(entryKey, album); + localEntryByOwner.set(ownedEntityKey(album), entryKey); } - return [...byId.values()].sort((left, right) => ( + const orderedHot = [...hot].sort((left, right) => ( + (createdAtMs(right.album) ?? -Infinity) - (createdAtMs(left.album) ?? -Infinity) + || ownedEntityKey(left.album).localeCompare(ownedEntityKey(right.album)) + )); + for (const resolved of orderedHot) { + const representativeKey = resolved.representativeServerId && resolved.representativeId + ? ownedEntityKey({ id: resolved.representativeId, serverId: resolved.representativeServerId }) + : null; + const localEntry = representativeKey ? localEntryByOwner.get(representativeKey) : undefined; + const entryKey = localEntry ?? `hot:${resolved.group}`; + const prior = merged.get(entryKey); + if (!prior) { + merged.set(entryKey, representativeKey ? { + ...resolved.album, + serverId: resolved.representativeServerId ?? resolved.album.serverId, + id: resolved.representativeId ?? resolved.album.id, + } : resolved.album); + } else if (localEntry) { + merged.set(entryKey, overlayCreatedAt(prior, resolved.album)); + } + } + return [...merged.values()].sort((left, right) => ( (createdAtMs(right) ?? -Infinity) - (createdAtMs(left) ?? -Infinity) )); } @@ -32,7 +69,7 @@ export function mergeHotNewReleases( export async function fetchHotNewReleases( scopes: LibraryScopePair[], now = Date.now(), -): Promise { +): Promise { const cutoff = now - HOT_NEW_RELEASE_WINDOW_MS; const results: SubsonicAlbum[] = []; let next = 0; @@ -49,12 +86,30 @@ export async function fetchHotNewReleases( { musicFolderId: scope.libraryId }, 8000, ); - results.push(...albums.filter(album => (createdAtMs(album) ?? -Infinity) >= cutoff)); + results.push(...albums + .filter(album => (createdAtMs(album) ?? -Infinity) >= cutoff) + .map(album => ({ ...album, serverId: scope.serverId }))); } catch { // Local results remain useful when one selected server is unavailable. } } }; await Promise.all(Array.from({ length: Math.min(HOT_NEW_RELEASE_CONCURRENCY, scopes.length) }, worker)); - return mergeHotNewReleases([], results); + if (results.length === 0) return []; + try { + const resolutions = await libraryResolveAlbumOverlay({ + scopes, + albums: results.map(album => ({ + serverId: album.serverId ?? '', + id: album.id, + name: album.name, + artist: album.displayArtist?.trim() || album.artist?.trim() || null, + })), + }); + if (resolutions.length !== results.length) return []; + return results.map((album, index) => ({ album, ...resolutions[index]! })); + } catch { + // The overlay is optional; never reintroduce raw, identity-unsafe rows. + return []; + } } diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index 3dec9e4a..a5f7786c 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -55,6 +55,7 @@ export const commands = { /** Ensure precomputed cluster identity keys are current without blocking Tauri's main thread. */ libraryClusterRebuild: (serverId: string | null) => typedError(__TAURI_INVOKE("library_cluster_rebuild", { serverId })), libraryResolveEntitySources: (request: LibraryResolveEntitySourcesRequest) => typedError(__TAURI_INVOKE("library_resolve_entity_sources", { request })), + libraryResolveAlbumOverlay: (request: LibraryResolveAlbumOverlayRequest) => typedError(__TAURI_INVOKE("library_resolve_album_overlay", { request })), librarySyncBindSession: (serverId: string, baseUrl: string, username: string, password: string, libraryScope: string | null) => typedError(__TAURI_INVOKE("library_sync_bind_session", { serverId, baseUrl, username, password, libraryScope })), librarySyncClearSession: (serverId: string) => typedError(__TAURI_INVOKE("library_sync_clear_session", { serverId })), librarySetPlaybackHint: (hint: string) => typedError(__TAURI_INVOKE("library_set_playback_hint", { hint })), @@ -1033,6 +1034,28 @@ export type LegacyOfflineMigrationResult = { skippedReason: string | null, }; +/** + * One raw network album that the New Releases freshness overlay needs to + * reconcile with the local scope identity graph. + */ +export type LibraryAlbumOverlayCandidateDto = { + serverId: string, + id: string, + name: string, + artist: string | null, +}; + +/** + * Order-preserving overlay resolution. `group` is valid only within this + * response and lets the frontend collapse logical copies without persisting + * internal cluster identity keys. + */ +export type LibraryAlbumOverlayResolutionDto = { + group: number, + representativeServerId: string | null, + representativeId: string | null, +}; + export type LibraryAnalysisBackfillBatchDto = { trackIds: string[], nextCursor: string | null, @@ -1112,6 +1135,12 @@ export type LibraryMostPlayedResponse = { hasMore: boolean, }; +/** Resolve a bounded network overlay batch against one ordered browse scope. */ +export type LibraryResolveAlbumOverlayRequest = { + scopes: LibraryScopePair[], + albums: LibraryAlbumOverlayCandidateDto[], +}; + /** * Resolve one concrete browse entity to every matching concrete source in an * explicitly ordered scope. diff --git a/src/lib/api/library/scopeReads.test.ts b/src/lib/api/library/scopeReads.test.ts index b8952cf4..2122667e 100644 --- a/src/lib/api/library/scopeReads.test.ts +++ b/src/lib/api/library/scopeReads.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { onInvoke } from '@/test/mocks/tauri'; import { + libraryResolveAlbumOverlay, libraryResolveEntitySources, libraryScopeAlbumDetail, libraryScopeArtistDetail, @@ -281,6 +282,53 @@ describe('libraryResolveEntitySources', () => { }); }); +describe('libraryResolveAlbumOverlay', () => { + it('maps candidate owners into index keys and representatives back to profiles', async () => { + let captured: unknown; + onInvoke('library_resolve_album_overlay', (args) => { + captured = args; + return [{ + group: 0, + representativeServerId: 's2.example', + representativeId: 'canonical-album', + }]; + }); + + const response = await libraryResolveAlbumOverlay({ + scopes: [ + { serverId: 'profile-s1', libraryId: 'lib-a' }, + { serverId: 'profile-s2', libraryId: null }, + ], + albums: [{ + serverId: 'profile-s2', + id: 'physical-album', + name: 'Album', + artist: 'Artist', + }], + }); + + expect(captured).toEqual({ + request: { + scopes: [ + { serverId: 's1.example', libraryId: 'lib-a' }, + { serverId: 's2.example', libraryId: null }, + ], + albums: [{ + serverId: 's2.example', + id: 'physical-album', + name: 'Album', + artist: 'Artist', + }], + }, + }); + expect(response).toEqual([{ + group: 0, + representativeServerId: 'profile-s2', + representativeId: 'canonical-album', + }]); + }); +}); + describe('libraryScopeAlbumDetail', () => { it('invokes library_scope_album_detail with mapped anchor server id', async () => { let captured: unknown; diff --git a/src/lib/api/library/scopeReads.ts b/src/lib/api/library/scopeReads.ts index 78104659..9f15b6c2 100644 --- a/src/lib/api/library/scopeReads.ts +++ b/src/lib/api/library/scopeReads.ts @@ -8,7 +8,9 @@ import { type LibraryMostPlayedAlbumDto as LibraryScopeMostPlayedAlbum, type LibraryMostPlayedArtistDto as LibraryScopeMostPlayedArtist, type LibraryMostPlayedResponse as LibraryScopeMostPlayedResponse, + type LibraryAlbumOverlayResolutionDto, type LibraryEntitySourceDto, + type LibraryResolveAlbumOverlayRequest, type LibraryResolveEntitySourcesRequest, type LibrarySourceEntityType, } from '@/generated/bindings'; @@ -59,7 +61,9 @@ export interface LibraryScopeMostPlayedRequest { } export type { + LibraryAlbumOverlayResolutionDto, LibraryEntitySourceDto, + LibraryResolveAlbumOverlayRequest, LibraryResolveEntitySourcesRequest, LibraryScopeMostPlayedAlbum, LibraryScopeMostPlayedArtist, @@ -67,6 +71,32 @@ export type { LibrarySourceEntityType, }; +export async function libraryResolveAlbumOverlay( + request: LibraryResolveAlbumOverlayRequest, +): Promise { + const ownerByIndexKey = new Map( + request.scopes.map(scope => [serverIndexKeyForId(scope.serverId), scope.serverId]), + ); + const result = await commands.libraryResolveAlbumOverlay({ + scopes: request.scopes.map(scope => ({ + ...scope, + serverId: serverIndexKeyForId(scope.serverId), + })), + albums: request.albums.map(album => ({ + ...album, + serverId: serverIndexKeyForId(album.serverId), + })), + }); + if (result.status === 'error') throw new Error(result.error); + return result.data.map(resolution => ({ + ...resolution, + representativeServerId: resolution.representativeServerId + ? ownerByIndexKey.get(resolution.representativeServerId) + ?? mapServerIdFromIndexKey(resolution.representativeServerId) + : null, + })); +} + export interface LibraryScopeSearchRequest { scopes: LibraryScopePair[]; query: string;