mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(new-releases): dedupe freshness overlay
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user