fix(new-releases): dedupe freshness overlay

This commit is contained in:
cucadmuh
2026-07-22 02:56:53 +03:00
parent df7bf8ef3b
commit 06c58b3399
15 changed files with 608 additions and 31 deletions
+6
View File
@@ -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]
@@ -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<bool> {
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<HashMap<u32, (String, String)>> {
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::<Vec<_>>()
.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<Vec<LibraryAlbumOverlayResolutionDto>, 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::<String, u32>::new();
let mut representative_group_keys = HashMap::<u32, String>::new();
let mut direct_representatives = HashMap::<u32, (String, String)>::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::<Vec<_>>();
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")
);
}
}
@@ -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<Vec<LibraryAlbumOverlayResolutionDto>, 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(
@@ -759,6 +759,36 @@ pub struct LibraryEntitySourceDto {
pub user_rating: Option<i64>,
}
/// 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<String>,
}
/// 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<LibraryScopePair>,
pub albums: Vec<LibraryAlbumOverlayCandidateDto>,
}
/// 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<String>,
pub representative_id: Option<String>,
}
/// 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)]
@@ -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<String> {
pub(crate) fn build_album_key(artist: Option<&str>, album: &str) -> Option<String> {
join_norm_parts([norm_part(artist.unwrap_or("")), norm_part(album)])
}
@@ -24,3 +24,4 @@ pub(crate) use rebuild::{
};
pub use keys::{build_track_cluster_keys, TrackClusterKeys};
pub(crate) use keys::build_album_key;
@@ -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;
@@ -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,
+2
View File
@@ -177,6 +177,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
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,
@@ -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;
+69 -12
View File
@@ -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']);
});
});
+67 -12
View File
@@ -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<string, SubsonicAlbum>();
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<string, SubsonicAlbum>();
const localEntryByOwner = new Map<string, string>();
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<SubsonicAlbum[]> {
): Promise<ResolvedHotNewRelease[]> {
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 [];
}
}
+29
View File
@@ -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<number, string>(__TAURI_INVOKE("library_cluster_rebuild", { serverId })),
libraryResolveEntitySources: (request: LibraryResolveEntitySourcesRequest) => typedError<LibraryEntitySourceDto[], string>(__TAURI_INVOKE("library_resolve_entity_sources", { request })),
libraryResolveAlbumOverlay: (request: LibraryResolveAlbumOverlayRequest) => typedError<LibraryAlbumOverlayResolutionDto[], string>(__TAURI_INVOKE("library_resolve_album_overlay", { request })),
librarySyncBindSession: (serverId: string, baseUrl: string, username: string, password: string, libraryScope: string | null) => typedError<null, string>(__TAURI_INVOKE("library_sync_bind_session", { serverId, baseUrl, username, password, libraryScope })),
librarySyncClearSession: (serverId: string) => typedError<null, string>(__TAURI_INVOKE("library_sync_clear_session", { serverId })),
librarySetPlaybackHint: (hint: string) => typedError<null, string>(__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.
+48
View File
@@ -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;
+30
View File
@@ -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<LibraryAlbumOverlayResolutionDto[]> {
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;