perf(ratings): resolve mix filters from library cache

This commit is contained in:
cucadmuh
2026-07-17 00:20:39 +03:00
parent 5074f75c5e
commit c98193ae09
15 changed files with 610 additions and 398 deletions
@@ -0,0 +1,10 @@
-- Owner-scoped ratings for tracks, albums, and artists. The composite primary
-- key covers the batch cache lookups, so a secondary index is unnecessary.
CREATE TABLE IF NOT EXISTS entity_user_rating (
server_id TEXT NOT NULL,
entity_kind TEXT NOT NULL CHECK (entity_kind IN ('track', 'album', 'artist')),
entity_id TEXT NOT NULL,
rating INTEGER NOT NULL,
fetched_at INTEGER NOT NULL,
PRIMARY KEY (server_id, entity_kind, entity_id)
);
@@ -21,7 +21,8 @@ use crate::cover_resolve::CoverEntryDto;
use crate::cross_server;
use crate::dto::{
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
EntityUserRatingDto, EntityUserRatingRefDto, FactInputDto, LibraryAdvancedSearchRequest,
LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse,
LibraryMainstageAlbumsRequest, LibraryMainstageAlbumsResponse,
LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest,
@@ -61,6 +62,8 @@ where
/// Cap for `library_get_tracks_batch` per spec §7.1 ("max 100 refs/call").
const TRACKS_BATCH_LIMIT: usize = 100;
/// Shared cache callers can request or update at most 300 entity ratings per call.
const ENTITY_USER_RATINGS_BATCH_LIMIT: usize = 300;
const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
@@ -406,6 +409,42 @@ pub async fn library_get_tracks_batch(
hydrate_refs(&runtime, &refs)
}
/// Read cached owner-scoped ratings. Invalid keys and cache misses are omitted.
#[tauri::command]
#[specta::specta]
pub async fn library_get_entity_user_ratings(
runtime: State<'_, LibraryRuntime>,
refs: Vec<EntityUserRatingRefDto>,
) -> Result<Vec<EntityUserRatingDto>, String> {
if refs.len() > ENTITY_USER_RATINGS_BATCH_LIMIT {
return Err(format!(
"library_get_entity_user_ratings: refs exceeds cap ({} > {})",
refs.len(),
ENTITY_USER_RATINGS_BATCH_LIMIT
));
}
let store = runtime.store.clone();
library_spawn_blocking(move || get_entity_user_ratings(&store, &refs)).await
}
/// Upsert cached owner-scoped ratings. Invalid keys are ignored.
#[tauri::command]
#[specta::specta]
pub async fn library_put_entity_user_ratings(
runtime: State<'_, LibraryRuntime>,
ratings: Vec<EntityUserRatingDto>,
) -> Result<(), String> {
if ratings.len() > ENTITY_USER_RATINGS_BATCH_LIMIT {
return Err(format!(
"library_put_entity_user_ratings: ratings exceeds cap ({} > {})",
ratings.len(),
ENTITY_USER_RATINGS_BATCH_LIMIT
));
}
let store = runtime.store.clone();
library_spawn_blocking(move || put_entity_user_ratings(&store, &ratings, now_unix_ms())).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_get_tracks_by_album(
@@ -1690,6 +1729,83 @@ fn now_unix_ms() -> i64 {
.unwrap_or(0)
}
fn valid_entity_user_rating_key(server_id: &str, entity_kind: &str, entity_id: &str) -> bool {
!server_id.is_empty()
&& !entity_id.is_empty()
&& matches!(entity_kind, "track" | "album" | "artist")
}
fn get_entity_user_ratings(
store: &LibraryStore,
refs: &[EntityUserRatingRefDto],
) -> Result<Vec<EntityUserRatingDto>, String> {
store.with_read_conn(|conn| {
let mut statement = conn.prepare(
"SELECT server_id, entity_kind, entity_id, rating, fetched_at
FROM entity_user_rating
WHERE server_id = ?1 AND entity_kind = ?2 AND entity_id = ?3",
)?;
let mut ratings = Vec::new();
for reference in refs {
let server_id = reference.server_id.trim();
let entity_kind = reference.entity_kind.trim();
let entity_id = reference.entity_id.trim();
if !valid_entity_user_rating_key(server_id, entity_kind, entity_id) {
continue;
}
if let Some(rating) = statement
.query_row(params![server_id, entity_kind, entity_id], |row| {
Ok(EntityUserRatingDto {
server_id: row.get(0)?,
entity_kind: row.get(1)?,
entity_id: row.get(2)?,
rating: row.get(3)?,
fetched_at: row.get(4)?,
})
})
.optional()?
{
ratings.push(rating);
}
}
Ok(ratings)
})
}
fn put_entity_user_ratings(
store: &LibraryStore,
ratings: &[EntityUserRatingDto],
now: i64,
) -> Result<(), String> {
store.with_conn_mut("entity_user_rating.upsert_batch", |conn| {
let transaction = conn.transaction()?;
let mut statement = transaction.prepare(
"INSERT INTO entity_user_rating (server_id, entity_kind, entity_id, rating, fetched_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(server_id, entity_kind, entity_id) DO UPDATE SET
rating = excluded.rating,
fetched_at = excluded.fetched_at",
)?;
for rating in ratings {
let server_id = rating.server_id.trim();
let entity_kind = rating.entity_kind.trim();
let entity_id = rating.entity_id.trim();
if !valid_entity_user_rating_key(server_id, entity_kind, entity_id) {
continue;
}
statement.execute(params![
server_id,
entity_kind,
entity_id,
rating.rating,
rating.fetched_at.max(now),
])?;
}
drop(statement);
transaction.commit()
})
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncIdleAck {
@@ -1905,6 +2021,95 @@ mod tests {
assert_eq!(TRACKS_BATCH_LIMIT, 100);
}
#[test]
fn entity_user_rating_cache_is_owner_scoped_and_ignores_malformed_keys() {
let store = LibraryStore::open_in_memory();
let ratings = vec![
EntityUserRatingDto {
server_id: "s1".into(),
entity_kind: "track".into(),
entity_id: "same-id".into(),
rating: 4,
fetched_at: 10,
},
EntityUserRatingDto {
server_id: "s2".into(),
entity_kind: "track".into(),
entity_id: "same-id".into(),
rating: 2,
fetched_at: 11,
},
EntityUserRatingDto {
server_id: "s1".into(),
entity_kind: "invalid".into(),
entity_id: "ignored".into(),
rating: 5,
fetched_at: 12,
},
];
put_entity_user_ratings(&store, &ratings, 100).unwrap();
let found = get_entity_user_ratings(
&store,
&[
EntityUserRatingRefDto {
server_id: "s2".into(),
entity_kind: "track".into(),
entity_id: "same-id".into(),
},
EntityUserRatingRefDto {
server_id: "s1".into(),
entity_kind: "track".into(),
entity_id: "same-id".into(),
},
EntityUserRatingRefDto {
server_id: "".into(),
entity_kind: "track".into(),
entity_id: "same-id".into(),
},
],
)
.unwrap();
assert_eq!(found.len(), 2);
assert_eq!(found[0].rating, 2);
assert_eq!(found[1].rating, 4);
assert!(found.iter().all(|rating| rating.fetched_at >= 100));
}
#[test]
fn entity_user_rating_cache_upsert_replaces_existing_owner_key() {
let store = LibraryStore::open_in_memory();
let rating = EntityUserRatingDto {
server_id: "s1".into(),
entity_kind: "album".into(),
entity_id: "a1".into(),
rating: 3,
fetched_at: 101,
};
put_entity_user_ratings(&store, std::slice::from_ref(&rating), 100).unwrap();
let mut updated = rating;
updated.rating = 5;
updated.fetched_at = 200;
put_entity_user_ratings(&store, &[updated], 100).unwrap();
let found = get_entity_user_ratings(
&store,
&[EntityUserRatingRefDto {
server_id: "s1".into(),
entity_kind: "album".into(),
entity_id: "a1".into(),
}],
)
.unwrap();
assert_eq!(found[0].rating, 5);
assert_eq!(found[0].fetched_at, 200);
}
#[test]
fn entity_user_rating_batch_limit_matches_spec_cap() {
assert_eq!(ENTITY_USER_RATINGS_BATCH_LIMIT, 300);
}
#[test]
fn normalize_base_url_adds_scheme_and_strips_trailing_slash() {
assert_eq!(normalize_base_url("nas.example.com"), "http://nas.example.com");
@@ -235,6 +235,26 @@ pub struct TrackRefDto {
pub content_hash: Option<String>,
}
/// Owner-scoped cache key for a user rating on a track, album, or artist.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct EntityUserRatingRefDto {
pub server_id: String,
pub entity_kind: String,
pub entity_id: String,
}
/// Cached user rating together with the time it was fetched from its owner.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct EntityUserRatingDto {
pub server_id: String,
pub entity_kind: String,
pub entity_id: String,
pub rating: i64,
pub fetched_at: i64,
}
/// Input to `library_put_artifact`. Same shape as `TrackArtifactDto`
/// minus the server-supplied `server_id` / `track_id` (provided as
/// command args) and `fetched_at` (stamped server-side from `now`).
@@ -39,7 +39,6 @@ pub fn list_lossless_albums(
store: &LibraryStore,
req: &LibraryLosslessAlbumsRequest,
) -> Result<LibraryLosslessAlbumsResponse, String> {
let total_started_at = Instant::now();
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(empty_response());
}
+41 -1
View File
@@ -12,7 +12,7 @@ use tauri::Manager;
///
/// Migration checklist (wiring, data backfill, open/swap path):
/// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 19;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 20;
/// One-time data repair after migration 014 (`artist.name_sort`).
pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1";
@@ -63,6 +63,9 @@ pub(crate) const MIGRATION_018_ARTIST_SYNCED_INDEX: &str =
/// New Releases reads over one `(server_id, library_id)` scope.
pub(crate) const MIGRATION_019_MAINSTAGE_FEED_INDEXES: &str =
include_str!("../migrations/019_mainstage_feed_indexes.sql");
/// Version 20: owner-scoped cache for track, album, and artist user ratings.
pub(crate) const MIGRATION_020_ENTITY_USER_RATING: &str =
include_str!("../migrations/020_entity_user_rating.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
@@ -76,6 +79,7 @@ const MIGRATIONS: &[(i64, &str)] = &[
(17, MIGRATION_017_LIBRARY_TAG_STATE),
(18, MIGRATION_018_ARTIST_SYNCED_INDEX),
(19, MIGRATION_019_MAINSTAGE_FEED_INDEXES),
(20, MIGRATION_020_ENTITY_USER_RATING),
];
/// Idempotent repair — also runs after the migration runner on every open so
@@ -91,6 +95,12 @@ pub(crate) fn ensure_mainstage_feed_indexes(conn: &Connection) -> rusqlite::Resu
conn.execute_batch(MIGRATION_019_MAINSTAGE_FEED_INDEXES)
}
/// Repairs a rare partial-v20 state where its migration marker was written but
/// the additive cache table did not survive.
pub(crate) fn ensure_entity_user_rating_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(MIGRATION_020_ENTITY_USER_RATING)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
/// Every missing migration was applied (or the DB was already at head).
@@ -671,6 +681,7 @@ fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()>
maybe_reconcile_orphan_browse_rows(conn)?;
ensure_genre_tags_schema(conn)?;
ensure_mainstage_feed_indexes(conn)?;
ensure_entity_user_rating_schema(conn)?;
checkpoint_wal_conn(conn, "open")?;
Ok(())
}
@@ -1463,6 +1474,35 @@ mod tests {
assert!(sql.contains("server_created_at IS NOT NULL"));
}
#[test]
fn migration_020_creates_entity_user_rating_table_idempotently() {
let store = LibraryStore::open_in_memory();
let version: i64 = store
.with_conn("test.entity_user_rating_version", |conn| {
conn.query_row(
"SELECT version FROM schema_migrations WHERE version = 20",
[],
|row| row.get(0),
)
})
.unwrap();
assert_eq!(version, 20);
store
.with_conn("test.entity_user_rating_ensure", ensure_entity_user_rating_schema)
.expect("repeated schema repair succeeds");
let table_count: i64 = store
.with_conn("test.entity_user_rating_table", |conn| {
conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entity_user_rating'",
[],
|row| row.get(0),
)
})
.unwrap();
assert_eq!(table_count, 1);
}
#[test]
fn library_id_backfill_reconcile_populates_from_raw_json() {
let store = LibraryStore::open_in_memory();
+4
View File
@@ -118,6 +118,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
psysonic_library::commands::library_count_live_tracks,
psysonic_library::commands::library_get_status,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_entity_user_ratings,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
psysonic_library::commands::library_genre_tags_inspect,
@@ -131,6 +132,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
psysonic_library::commands::library_sync_verify_integrity,
psysonic_library::commands::library_sync_cancel,
psysonic_library::commands::library_put_artifact,
psysonic_library::commands::library_put_entity_user_ratings,
psysonic_library::commands::library_put_fact,
psysonic_library::commands::library_record_play_session,
psysonic_library::commands::library_get_player_stats_year_summary,
@@ -1056,6 +1058,7 @@ pub fn run() {
psysonic_library::commands::library_get_tracks_by_album,
psysonic_library::commands::library_upsert_songs_from_api,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_entity_user_ratings,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
psysonic_library::commands::library_analysis_progress,
@@ -1073,6 +1076,7 @@ pub fn run() {
psysonic_library::browse_support::library_get_catalog_year_bounds,
psysonic_library::browse_support::library_get_genre_album_counts,
psysonic_library::commands::library_put_artifact,
psysonic_library::commands::library_put_entity_user_ratings,
psysonic_library::commands::library_put_fact,
psysonic_library::commands::library_record_play_session,
psysonic_library::commands::library_get_player_stats_year_summary,
+1 -1
View File
@@ -236,7 +236,7 @@ const handleShuffleAll = () => {
}
try {
await setRating(albumId, rating);
await setRating(albumId, rating, { serverId, kind: 'album' });
setAlbum(cur =>
cur && cur.album.id === albumId
? { ...cur, album: { ...cur.album, userRating: rating } }
@@ -30,7 +30,7 @@ export async function runArtistEntityRating(deps: RunArtistEntityRatingDeps): Pr
if (artistEntityRatingSupport !== 'full') return;
try {
await setRating(artistId, rating);
await setRating(artistId, rating, { serverId: artist.serverId ?? activeServerId, kind: 'artist' });
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
} catch (err) {
setArtistEntityRating(ratingAtStart);
@@ -39,7 +39,7 @@ export function useContextMenuRating({
const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => {
setUserRatingOverride(album.id, rating);
if (entityRatingSupport !== 'full') return;
setRating(album.id, rating).catch(err => {
setRating(album.id, rating, { serverId: album.serverId ?? activeServerId ?? undefined, kind: 'album' }).catch(err => {
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
@@ -52,7 +52,7 @@ export function useContextMenuRating({
const applyArtistRating = useCallback((artist: SubsonicArtist, rating: number) => {
setUserRatingOverride(artist.id, rating);
if (entityRatingSupport !== 'full') return;
setRating(artist.id, rating).catch(err => {
setRating(artist.id, rating, { serverId: artist.serverId ?? activeServerId ?? undefined, kind: 'artist' }).catch(err => {
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
@@ -4,20 +4,14 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resetPlayerStore } from '@/test/helpers/storeReset';
vi.mock('@/lib/api/subsonicRatings', () => ({
prefetchArtistUserRatings: vi.fn(),
prefetchAlbumUserRatings: vi.fn(),
prefetchArtistUserRatingsForServer: vi.fn(),
prefetchAlbumUserRatingsForServer: vi.fn(),
parseSubsonicEntityStarRating: vi.fn((entity: { userRating?: unknown; rating?: unknown }) =>
entity.userRating ?? entity.rating),
entityUserRatingKey: ({ serverId, entityKind, entityId }: { serverId: string; entityKind: string; entityId: string }) => `${serverId}\u0001${entityKind}\u0001${entityId}`,
rememberEntityUserRating: vi.fn(),
resolveEntityUserRatings: vi.fn(),
parseSubsonicEntityStarRating: vi.fn((entity: { userRating?: unknown; rating?: unknown }) => entity.userRating ?? entity.rating),
}));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => ({ activeServerId: 'server-a' }) } }));
import {
prefetchAlbumUserRatings,
prefetchAlbumUserRatingsForServer,
prefetchArtistUserRatings,
prefetchArtistUserRatingsForServer,
} from '@/lib/api/subsonicRatings';
import { resolveEntityUserRatings } from '@/lib/api/subsonicRatings';
import {
enrichSongsForMixRatingFilter,
filterAlbumsByMixRatingsAcrossServers,
@@ -26,48 +20,25 @@ import {
} from '@/features/playback/utils/mixRatingFilter';
const enabledArtist2: { enabled: true; minSong: 0; minAlbum: 0; minArtist: 2 } = {
enabled: true,
minSong: 0,
minAlbum: 0,
minArtist: 2,
enabled: true, minSong: 0, minAlbum: 0, minArtist: 2,
};
function song(partial: Partial<SubsonicSong> & Pick<SubsonicSong, 'id'>): SubsonicSong {
return {
title: 't',
artist: 'A',
album: 'Al',
albumId: 'alb-1',
artistId: 'art-1',
duration: 180,
...partial,
};
return { title: 't', artist: 'A', album: 'Al', albumId: 'alb-1', artistId: 'art-1', duration: 180, ...partial };
}
function album(
partial: Pick<SubsonicAlbum, 'id' | 'name' | 'artistId'> & { serverId: string },
): SubsonicAlbum & { serverId: string } {
return {
artist: 'Artist',
songCount: 1,
duration: 180,
...partial,
};
function album(partial: Pick<SubsonicAlbum, 'id' | 'name' | 'artistId'> & { serverId: string }): SubsonicAlbum & { serverId: string } {
return { artist: 'Artist', songCount: 1, duration: 180, ...partial };
}
beforeEach(() => {
resetPlayerStore();
vi.mocked(prefetchArtistUserRatings).mockReset();
vi.mocked(prefetchAlbumUserRatings).mockReset();
vi.mocked(prefetchArtistUserRatingsForServer).mockReset();
vi.mocked(prefetchAlbumUserRatingsForServer).mockReset();
vi.mocked(prefetchAlbumUserRatings).mockResolvedValue(new Map());
vi.mocked(prefetchArtistUserRatingsForServer).mockResolvedValue(new Map());
vi.mocked(prefetchAlbumUserRatingsForServer).mockResolvedValue(new Map());
vi.mocked(resolveEntityUserRatings).mockReset();
vi.mocked(resolveEntityUserRatings).mockResolvedValue(new Map());
});
describe('filterAlbumsByMixRatingsAcrossServers', () => {
it('enriches per server and preserves the original merged order', async () => {
it('uses owner-scoped local ratings and preserves merged order', async () => {
const config = { enabled: true, minSong: 0, minAlbum: 2, minArtist: 2 };
const albums: Array<SubsonicAlbum & { serverId: string }> = [
album({ id: 'shared', name: 'A shared', artistId: 'artist-a', serverId: 'server-a' }),
@@ -75,99 +46,62 @@ describe('filterAlbumsByMixRatingsAcrossServers', () => {
album({ id: 'keep-a', name: 'A keep', artistId: 'artist-a2', serverId: 'server-a' }),
album({ id: 'shared', name: 'B shared', artistId: 'artist-b2', serverId: 'server-b' }),
];
vi.mocked(prefetchAlbumUserRatingsForServer).mockImplementation(async serverId => (
serverId === 'server-a'
? new Map([['shared', 1], ['keep-a', 4]])
: new Map([['keep-b', 4], ['shared', 5]])
));
vi.mocked(prefetchArtistUserRatingsForServer).mockImplementation(async serverId => (
serverId === 'server-a'
? new Map([['artist-a', 5], ['artist-a2', 5]])
: new Map([['artist-b', 5], ['artist-b2', 5]])
));
vi.mocked(resolveEntityUserRatings).mockResolvedValue(new Map([
['server-a\u0001album\u0001shared', 1], ['server-a\u0001album\u0001keep-a', 4],
['server-b\u0001album\u0001keep-b', 4], ['server-b\u0001album\u0001shared', 5],
['server-a\u0001artist\u0001artist-a', 5], ['server-a\u0001artist\u0001artist-a2', 5],
['server-b\u0001artist\u0001artist-b', 5], ['server-b\u0001artist\u0001artist-b2', 5],
]));
const result = await filterAlbumsByMixRatingsAcrossServers(albums, config);
expect(result.map(album => `${album.serverId}:${album.id}`)).toEqual([
'server-b:keep-b',
'server-a:keep-a',
'server-b:shared',
expect(result.map(item => `${item.serverId}:${item.id}`)).toEqual([
'server-b:keep-b', 'server-a:keep-a', 'server-b:shared',
]);
expect(prefetchAlbumUserRatingsForServer).toHaveBeenCalledWith(
'server-a',
['shared', 'keep-a'],
);
expect(prefetchAlbumUserRatingsForServer).toHaveBeenCalledWith(
'server-b',
['keep-b', 'shared'],
);
expect(resolveEntityUserRatings).toHaveBeenCalledTimes(1);
});
});
describe('passesMixMinRatings artist axis', () => {
it('excludes when artistUserRating is at or below threshold', () => {
describe('passesMixMinRatings - artist axis', () => {
it('excludes ratings at or below the threshold and keeps unrated artists', () => {
expect(passesMixMinRatings(song({ id: '1', artistUserRating: 1 }), enabledArtist2)).toBe(false);
expect(passesMixMinRatings(song({ id: '2', artistUserRating: 2 }), enabledArtist2)).toBe(false);
expect(passesMixMinRatings(song({ id: '3', artistUserRating: 3 }), enabledArtist2)).toBe(true);
expect(passesMixMinRatings(song({ id: '4' }), enabledArtist2)).toBe(true);
});
it('keeps unrated artists (missing or zero)', () => {
expect(passesMixMinRatings(song({ id: '1' }), enabledArtist2)).toBe(true);
expect(passesMixMinRatings(song({ id: '2', artistUserRating: 0 }), enabledArtist2)).toBe(true);
});
it('uses playerStore userRatingOverrides before API fields', () => {
it('uses optimistic overrides before API fields', () => {
usePlayerStore.getState().setUserRatingOverride('art-1', 1);
expect(
passesMixMinRatings(song({ id: '1', artistUserRating: 5 }), enabledArtist2),
).toBe(false);
});
it('uses OpenSubsonic artists[] ref when artistUserRating is absent', () => {
const low = song({
id: '1',
artists: [{ id: 'art-1', userRating: 1 }],
});
expect(passesMixMinRatings(low, enabledArtist2)).toBe(false);
expect(passesMixMinRatings(song({ id: '1', artistUserRating: 5 }), enabledArtist2)).toBe(false);
});
});
describe('enrichSongsForMixRatingFilter', () => {
it('prefetches entity artist rating even when song carries a misleading artists[] ref', async () => {
vi.mocked(prefetchArtistUserRatings).mockResolvedValue(new Map([['art-1', 1]]));
it('uses a local resolved rating when the song payload omits one', async () => {
vi.mocked(resolveEntityUserRatings).mockResolvedValue(new Map([['server-a\u0001artist\u0001art-1', 1]]));
const out = await enrichSongsForMixRatingFilter([song({ id: '1' })], enabledArtist2);
const input = [
song({
id: '1',
artists: [{ id: 'art-1', userRating: 5 }],
}),
];
const out = await enrichSongsForMixRatingFilter(input, enabledArtist2);
expect(prefetchArtistUserRatings).toHaveBeenCalledWith(['art-1']);
expect(out[0].artistUserRating).toBe(1);
expect(passesMixMinRatings(out[0], enabledArtist2)).toBe(false);
});
it('keeps a payload rating ahead of a local cache value', async () => {
vi.mocked(resolveEntityUserRatings).mockResolvedValue(new Map([['server-a\u0001artist\u0001art-1', 1]]));
const out = await enrichSongsForMixRatingFilter([song({ id: '1', artistUserRating: 5 })], enabledArtist2);
expect(out[0].artistUserRating).toBe(5);
expect(passesMixMinRatings(out[0], enabledArtist2)).toBe(true);
});
});
describe('filterTopArtistsForMixRatings', () => {
it('drops artists rated at or below the threshold', async () => {
vi.mocked(prefetchArtistUserRatings).mockResolvedValue(
new Map([
['a1', 1],
['a2', 3],
]),
);
const out = await filterTopArtistsForMixRatings(
[
{ id: 'a1', name: 'Low' },
{ id: 'a2', name: 'Ok' },
{ id: 'a3', name: 'Unrated' },
],
enabledArtist2,
);
expect(out.map(a => a.id)).toEqual(['a2', 'a3']);
it('drops local ratings at or below the threshold', async () => {
vi.mocked(resolveEntityUserRatings).mockResolvedValue(new Map([
['server-a\u0001artist\u0001a1', 1], ['server-a\u0001artist\u0001a2', 3],
]));
const out = await filterTopArtistsForMixRatings([
{ id: 'a1', name: 'Low' }, { id: 'a2', name: 'Ok' }, { id: 'a3', name: 'Unrated' },
], enabledArtist2);
expect(out.map(item => item.id)).toEqual(['a2', 'a3']);
});
});
+60 -50
View File
@@ -1,9 +1,9 @@
import {
entityUserRatingKey,
rememberEntityUserRating,
parseSubsonicEntityStarRating,
prefetchAlbumUserRatings,
prefetchAlbumUserRatingsForServer,
prefetchArtistUserRatings,
prefetchArtistUserRatingsForServer,
resolveEntityUserRatings,
type EntityUserRatingRef,
} from '@/lib/api/subsonicRatings';
import { getRandomSongs } from '@/lib/api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
@@ -198,9 +198,7 @@ export function passesMixMinRatingsForAlbum(
return true;
}
/**
* Fetches missing entity ratings (bounded concurrency) then filters. Used for random album grids / hero.
*/
/** Resolves cached entity ratings and schedules missing entries for background hydration. */
export async function filterAlbumsByMixRatings(
albums: SubsonicAlbum[],
c: MixMinRatingsConfig,
@@ -208,35 +206,36 @@ export async function filterAlbumsByMixRatings(
return filterAlbumsByMixRatingsWithPrefetch(
albums,
c,
prefetchArtistUserRatings,
prefetchAlbumUserRatings,
useAuthStore.getState().activeServerId,
);
}
async function filterAlbumsByMixRatingsWithPrefetch<T extends SubsonicAlbum>(
albums: T[],
c: MixMinRatingsConfig,
prefetchArtists: (ids: string[]) => Promise<Map<string, number>>,
prefetchAlbums: (ids: string[]) => Promise<Map<string, number>>,
serverId: string | null | undefined,
): Promise<T[]> {
if (!c.enabled) return albums;
if (c.minAlbum <= 0 && c.minArtist <= 0) return albums;
if (!c.enabled || (c.minAlbum <= 0 && c.minArtist <= 0) || !serverId) return albums;
const needArtist = c.minArtist > 0;
const needAlbum = c.minAlbum > 0;
let byArtist = new Map<string, number>();
let byAlbum = new Map<string, number>();
if (needArtist) {
const ids = [...new Set(albums.map(a => a.artistId).filter(Boolean))] as string[];
byArtist = await prefetchArtists(ids);
}
if (needAlbum) {
const ids = [...new Set(albums.filter(a => a.userRating === undefined).map(a => a.id))];
if (ids.length) byAlbum = await prefetchAlbums(ids);
const refs: EntityUserRatingRef[] = [
...(needArtist ? albums.map(a => ({ serverId, entityKind: 'artist' as const, entityId: a.artistId })) : []),
...(needAlbum ? albums.map(a => ({ serverId, entityKind: 'album' as const, entityId: a.id })) : []),
];
const payloadRatingRefs: EntityUserRatingRef[] = [];
for (const album of albums) {
if (needAlbum) {
const ref = { serverId, entityKind: 'album' as const, entityId: album.id };
const rating = parseSubsonicEntityStarRating(album);
rememberEntityUserRating(ref, rating);
if (rating !== undefined) payloadRatingRefs.push(ref);
}
}
const ratings = await resolveEntityUserRatings(refs, payloadRatingRefs);
return albums.filter(a =>
passesMixMinRatingsForAlbum(a, c, {
artistUserRating: a.artistId ? byArtist.get(a.artistId) : undefined,
albumUserRating: byAlbum.get(a.id),
artistUserRating: ratings.get(entityUserRatingKey({ serverId, entityKind: 'artist', entityId: a.artistId })),
albumUserRating: ratings.get(entityUserRatingKey({ serverId, entityKind: 'album', entityId: a.id })),
}),
);
}
@@ -247,23 +246,25 @@ export async function filterAlbumsByMixRatingsAcrossServers<T extends SubsonicAl
c: MixMinRatingsConfig,
): Promise<T[]> {
if (!c.enabled || (c.minAlbum <= 0 && c.minArtist <= 0)) return albums;
const byServer = new Map<string, T[]>();
const refs: EntityUserRatingRef[] = [];
for (const album of albums) {
const group = byServer.get(album.serverId);
if (group) group.push(album);
else byServer.set(album.serverId, [album]);
if (c.minArtist > 0) refs.push({ serverId: album.serverId, entityKind: 'artist', entityId: album.artistId });
if (c.minAlbum > 0) refs.push({ serverId: album.serverId, entityKind: 'album', entityId: album.id });
}
const accepted = new Set<T>();
await Promise.all([...byServer].map(async ([serverId, group]) => {
const filtered = await filterAlbumsByMixRatingsWithPrefetch(
group,
c,
ids => prefetchArtistUserRatingsForServer(serverId, ids),
ids => prefetchAlbumUserRatingsForServer(serverId, ids),
);
for (const album of filtered) accepted.add(album);
const payloadRatingRefs: EntityUserRatingRef[] = [];
for (const album of albums) {
if (c.minAlbum > 0) {
const ref = { serverId: album.serverId, entityKind: 'album' as const, entityId: album.id };
const rating = parseSubsonicEntityStarRating(album);
rememberEntityUserRating(ref, rating);
if (rating !== undefined) payloadRatingRefs.push(ref);
}
}
const ratings = await resolveEntityUserRatings(refs, payloadRatingRefs);
return albums.filter(album => passesMixMinRatingsForAlbum(album, c, {
artistUserRating: ratings.get(entityUserRatingKey({ serverId: album.serverId, entityKind: 'artist', entityId: album.artistId })),
albumUserRating: ratings.get(entityUserRatingKey({ serverId: album.serverId, entityKind: 'album', entityId: album.id })),
}));
return albums.filter(album => accepted.has(album));
}
/** Enrich when needed, then drop songs excluded by Settings → Ratings → filter-by-rating. */
@@ -276,19 +277,18 @@ export async function filterSongsForLuckyMixRatings(
return enriched.filter(s => passesMixMinRatings(s, c));
}
/**
* Merge `getArtist` / `getAlbum` ratings into songs when list payloads omit them,
* so `passesMixMinRatings` / Lucky Mix filtering see album and artist stars.
*/
/** Merge local ratings only where song payloads and optimistic overrides provide no rating. */
/** Drop low-rated seed artists before Lucky Mix picks from listening history. */
export async function filterTopArtistsForMixRatings<T extends { id: string }>(
artists: T[],
c: MixMinRatingsConfig,
): Promise<T[]> {
if (!c.enabled || c.minArtist <= 0 || !artists.length) return artists;
const byArtist = await prefetchArtistUserRatings(artists.map(a => a.id));
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) return artists;
const ratings = await resolveEntityUserRatings(artists.map(a => ({ serverId, entityKind: 'artist' as const, entityId: a.id })));
return artists.filter(a => {
const r = mixRatingOverrideForEntity(a.id) ?? byArtist.get(a.id);
const r = mixRatingOverrideForEntity(a.id) ?? ratings.get(entityUserRatingKey({ serverId, entityKind: 'artist', entityId: a.id }));
if (r === undefined || r <= 0) return true;
return r > c.minArtist;
});
@@ -299,6 +299,8 @@ export async function enrichSongsForMixRatingFilter(
c: MixMinRatingsConfig,
): Promise<SubsonicSong[]> {
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) return songs;
const artistIds =
c.minArtist > 0
? [...new Set(songs.map(s => artistEntityIdForMixRating(s)).filter((id): id is string => !!id))]
@@ -307,17 +309,25 @@ export async function enrichSongsForMixRatingFilter(
c.minAlbum > 0
? [...new Set(songs.filter(s => s.albumId).map(s => s.albumId!))]
: [];
const [byArtist, byAlbum] = await Promise.all([
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
const ratings = await resolveEntityUserRatings([
...artistIds.map(entityId => ({ serverId, entityKind: 'artist' as const, entityId })),
...albumIds.map(entityId => ({ serverId, entityKind: 'album' as const, entityId })),
]);
if (!byArtist.size && !byAlbum.size) return songs;
if (!ratings.size) return songs;
return songs.map(s => {
const aid = artistEntityIdForMixRating(s);
const payloadArtistRating = effectiveArtistRatingForFilter(s);
const payloadAlbumRating = effectiveAlbumRatingOnSong(s);
const artistRating = aid
? ratings.get(entityUserRatingKey({ serverId, entityKind: 'artist', entityId: aid }))
: undefined;
const albumRating = s.albumId
? ratings.get(entityUserRatingKey({ serverId, entityKind: 'album', entityId: s.albumId }))
: undefined;
const artistPatch =
aid && byArtist.has(aid) ? { artistUserRating: byArtist.get(aid)! } : {};
payloadArtistRating === undefined && artistRating !== undefined ? { artistUserRating: artistRating } : {};
const albumPatch =
s.albumId && byAlbum.has(s.albumId) ? { albumUserRating: byAlbum.get(s.albumId)! } : {};
payloadAlbumRating === undefined && albumRating !== undefined ? { albumUserRating: albumRating } : {};
return { ...s, ...artistPatch, ...albumPatch };
});
}
+20
View File
@@ -39,6 +39,8 @@ export const commands = {
fetchedAt: number,
expiresAt: number | null,
} | null, string>(__TAURI_INVOKE("library_get_artifact", { serverId, trackId, artifactKind, sourceKind, sourceId, format })),
/** Read cached owner-scoped ratings. Invalid keys and cache misses are omitted. */
libraryGetEntityUserRatings: (refs: EntityUserRatingRefDto[]) => typedError<EntityUserRatingDto[], string>(__TAURI_INVOKE("library_get_entity_user_ratings", { refs })),
libraryGetFacts: (serverId: string, trackId: string, factKinds: string[] | null) => typedError<TrackFactDto[], string>(__TAURI_INVOKE("library_get_facts", { serverId, trackId, factKinds })),
libraryGetOfflinePath: (serverId: string, trackId: string) => typedError<OfflinePathDto, string>(__TAURI_INVOKE("library_get_offline_path", { serverId, trackId })),
libraryGenreTagsInspect: () => typedError<GenreTagsInspectDto, string>(__TAURI_INVOKE("library_genre_tags_inspect")),
@@ -61,6 +63,8 @@ export const commands = {
librarySyncVerifyIntegrity: (serverId: string, libraryScope: string | null) => typedError<SyncJobDto, string>(__TAURI_INVOKE("library_sync_verify_integrity", { serverId, libraryScope })),
librarySyncCancel: (jobId: string | null) => typedError<null, string>(__TAURI_INVOKE("library_sync_cancel", { jobId })),
libraryPutArtifact: (serverId: string, trackId: string, artifact: ArtifactInputDto) => typedError<null, string>(__TAURI_INVOKE("library_put_artifact", { serverId, trackId, artifact })),
/** Upsert cached owner-scoped ratings. Invalid keys are ignored. */
libraryPutEntityUserRatings: (ratings: EntityUserRatingDto[]) => typedError<null, string>(__TAURI_INVOKE("library_put_entity_user_ratings", { ratings })),
libraryPutFact: (serverId: string, trackId: string, fact: FactInputDto) => typedError<null, string>(__TAURI_INVOKE("library_put_fact", { serverId, trackId, fact })),
libraryRecordPlaySession: (input: PlaySessionInputDto) => typedError<null, string>(__TAURI_INVOKE("library_record_play_session", { input })),
libraryGetPlayerStatsYearSummary: (year: number) => typedError<PlaySessionYearSummaryDto, string>(__TAURI_INVOKE("library_get_player_stats_year_summary", { year })),
@@ -944,6 +948,22 @@ export type CustomHeadersApplyTo = "local" | "public" | "both";
export type EndpointKind = "local" | "public";
/** Cached user rating together with the time it was fetched from its owner. */
export type EntityUserRatingDto = {
serverId: string,
entityKind: string,
entityId: string,
rating: number,
fetchedAt: number,
};
/** Owner-scoped cache key for a user rating on a track, album, or artist. */
export type EntityUserRatingRefDto = {
serverId: string,
entityKind: string,
entityId: string,
};
/**
* Input to `library_put_fact`. Shape matches `TrackFactDto` minus the
* indices.
+42 -82
View File
@@ -1,99 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonicArtists', () => ({ getArtist: vi.fn(), getArtistForServer: vi.fn() }));
vi.mock('@/lib/api/subsonicLibrary', () => ({ getAlbum: vi.fn(), getAlbumForServer: vi.fn() }));
vi.mock('@/lib/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
shouldAttemptSubsonicForServer: vi.fn(() => true),
const mocks = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke }));
vi.mock('@/lib/api/subsonicArtists', () => ({ getArtistForServer: vi.fn() }));
vi.mock('@/lib/api/subsonicLibrary', () => ({ getAlbumForServer: vi.fn() }));
vi.mock('@/lib/network/subsonicNetworkGuard', () => ({ shouldAttemptSubsonicForServer: vi.fn(() => true) }));
vi.mock('@/lib/api/library/internal', () => ({
serverIndexKeyForId: (serverId: string) => `index:${serverId}`,
mapServerIdFromIndexKey: (serverId: string) => serverId.replace('index:', ''),
}));
import { getArtist, getArtistForServer } from '@/lib/api/subsonicArtists';
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import {
invalidateEntityUserRatingCaches,
prefetchAlbumUserRatingsForServer,
prefetchArtistUserRatings,
prefetchArtistUserRatingsForServer,
} from '@/lib/api/subsonicRatings';
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => ({ activeServerId: 'server-a' }) } }));
import { getArtistForServer } from '@/lib/api/subsonicArtists';
import { entityUserRatingKey, resolveEntityUserRatings } from '@/lib/api/subsonicRatings';
beforeEach(() => {
vi.mocked(getArtist).mockReset();
mocks.invoke.mockReset();
vi.mocked(getArtistForServer).mockReset();
vi.mocked(getAlbumForServer).mockReset();
invalidateEntityUserRatingCaches('art-1');
invalidateEntityUserRatingCaches('shared-id');
});
describe('explicit-server rating prefetch', () => {
it('isolates same artist ids by server', async () => {
vi.mocked(getArtistForServer).mockImplementation(async serverId => ({
artist: { id: 'shared-id', name: 'Artist', userRating: serverId === 'server-a' ? 1 : 5 },
albums: [],
}));
describe('resolveEntityUserRatings', () => {
it('keeps identical ids isolated by server and entity kind', async () => {
mocks.invoke.mockResolvedValue([
{ serverId: 'index:server-a', entityKind: 'artist', entityId: 'shared', rating: 1, fetchedAt: 1 },
{ serverId: 'index:server-b', entityKind: 'artist', entityId: 'shared', rating: 5, fetchedAt: 1 },
{ serverId: 'index:server-a', entityKind: 'album', entityId: 'shared', rating: 3, fetchedAt: 1 },
]);
const fromA = await prefetchArtistUserRatingsForServer('server-a', ['shared-id']);
const fromB = await prefetchArtistUserRatingsForServer('server-b', ['shared-id']);
const cachedA = await prefetchArtistUserRatingsForServer('server-a', ['shared-id']);
const ratings = await resolveEntityUserRatings([
{ serverId: 'server-a', entityKind: 'artist', entityId: 'shared' },
{ serverId: 'server-b', entityKind: 'artist', entityId: 'shared' },
{ serverId: 'server-a', entityKind: 'album', entityId: 'shared' },
]);
expect(fromA.get('shared-id')).toBe(1);
expect(fromB.get('shared-id')).toBe(5);
expect(cachedA.get('shared-id')).toBe(1);
expect(getArtistForServer).toHaveBeenCalledTimes(2);
expect(ratings.get(entityUserRatingKey({ serverId: 'server-a', entityKind: 'artist', entityId: 'shared' }))).toBe(1);
expect(ratings.get(entityUserRatingKey({ serverId: 'server-b', entityKind: 'artist', entityId: 'shared' }))).toBe(5);
expect(ratings.get(entityUserRatingKey({ serverId: 'server-a', entityKind: 'album', entityId: 'shared' }))).toBe(3);
expect(getArtistForServer).not.toHaveBeenCalled();
});
it('uses the explicit server for album fetches', async () => {
vi.mocked(getAlbumForServer).mockResolvedValue({
album: {
id: 'shared-id',
name: 'Album',
artist: 'Artist',
artistId: 'artist-id',
songCount: 1,
duration: 180,
userRating: 3,
},
songs: [],
it('returns after the local read while missing ratings hydrate in the background', async () => {
let releaseNetwork!: () => void;
const network = new Promise<{ artist: { id: string; name: string; userRating: number }; albums: [] }>(resolve => {
releaseNetwork = () => resolve({ artist: { id: 'artist-1', name: 'Artist', userRating: 1 }, albums: [] });
});
mocks.invoke.mockResolvedValue([]);
vi.mocked(getArtistForServer).mockReturnValue(network);
const ratings = await prefetchAlbumUserRatingsForServer('server-b', ['shared-id']);
const ratings = await resolveEntityUserRatings([
{ serverId: 'server-a', entityKind: 'artist', entityId: 'artist-1' },
]);
expect(ratings.get('shared-id')).toBe(3);
expect(getAlbumForServer).toHaveBeenCalledWith('server-b', 'shared-id');
});
});
describe('prefetchArtistUserRatings', () => {
it('does not negative-cache unrated artists', async () => {
vi.mocked(getArtist).mockResolvedValue({ artist: { id: 'art-1', name: 'Artist' }, albums: [] });
const first = await prefetchArtistUserRatings(['art-1']);
expect(first.size).toBe(0);
expect(getArtist).toHaveBeenCalledTimes(1);
vi.mocked(getArtist).mockResolvedValue({
artist: { id: 'art-1', name: 'Artist', userRating: 1 },
albums: [],
});
const second = await prefetchArtistUserRatings(['art-1']);
expect(second.get('art-1')).toBe(1);
expect(getArtist).toHaveBeenCalledTimes(2);
});
it('does not reuse ratings across active-server calls', async () => {
vi.mocked(getArtist).mockResolvedValue({
artist: { id: 'art-1', name: 'Artist', userRating: 2 },
albums: [],
});
const first = await prefetchArtistUserRatings(['art-1']);
expect(first.get('art-1')).toBe(2);
expect(getArtist).toHaveBeenCalledTimes(1);
vi.mocked(getArtist).mockResolvedValue({
artist: { id: 'art-1', name: 'Artist', userRating: 4 },
albums: [],
});
const fresh = await prefetchArtistUserRatings(['art-1']);
expect(fresh.get('art-1')).toBe(4);
expect(getArtist).toHaveBeenCalledTimes(2);
expect(ratings.size).toBe(0);
expect(getArtistForServer).toHaveBeenCalledWith('server-a', 'artist-1');
expect(mocks.invoke).toHaveBeenCalledTimes(1);
releaseNetwork();
});
});
+139 -136
View File
@@ -1,37 +1,42 @@
import { getArtist, getArtistForServer } from '@/lib/api/subsonicArtists';
import { getAlbum, getAlbumForServer } from '@/lib/api/subsonicLibrary';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '@/lib/network/subsonicNetworkGuard';
import { invoke } from '@tauri-apps/api/core';
import { getArtistForServer } from '@/lib/api/subsonicArtists';
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { mapServerIdFromIndexKey, serverIndexKeyForId } from '@/lib/api/library/internal';
import { shouldAttemptSubsonicForServer } from '@/lib/network/subsonicNetworkGuard';
import { useAuthStore } from '@/store/authStore';
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
const ratingCache = new Map<string, { value: number; expiresAt: number }>();
const ENTITY_RATING_BATCH_LIMIT = 300;
function getCachedRating(key: string): number | null {
const entry = ratingCache.get(key);
if (!entry) return null; // cache miss
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
return entry.value;
export type EntityRatingKind = 'track' | 'album' | 'artist';
export interface EntityUserRatingRef {
serverId: string;
entityKind: EntityRatingKind;
entityId: string;
}
function setCachedRating(key: string, value: number): void {
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
interface EntityUserRatingDto extends EntityUserRatingRef {
rating: number;
fetchedAt: number;
}
/** Drop cached entity ratings after `setRating` so mixes see fresh stars. */
export function invalidateEntityUserRatingCaches(id: string): void {
for (const key of ratingCache.keys()) {
if (key.endsWith(`:${id}`)) ratingCache.delete(key);
export function entityUserRatingKey({ serverId, entityKind, entityId }: EntityUserRatingRef): string {
return `${serverId}\u0001${entityKind}\u0001${entityId}`;
}
function validRefs(refs: EntityUserRatingRef[]): EntityUserRatingRef[] {
const unique = new Map<string, EntityUserRatingRef>();
for (const ref of refs) {
if (ref.serverId && ref.entityId) unique.set(entityUserRatingKey(ref), ref);
}
return [...unique.values()];
}
function parseEntityUserRating(v: unknown): number | undefined {
if (v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return undefined;
return n;
function chunks<T>(values: T[], size: number): T[][] {
const out: T[][] = [];
for (let index = 0; index < values.length; index += size) out.push(values.slice(index, index + size));
return out;
}
/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */
@@ -39,138 +44,136 @@ export function parseSubsonicEntityStarRating(entity: {
userRating?: unknown;
rating?: unknown;
}): number | undefined {
return parseEntityUserRating(entity.userRating ?? entity.rating);
const value = entity.userRating ?? entity.rating;
if (value === null || value === undefined) return undefined;
const rating = typeof value === 'number' ? value : Number(value);
return Number.isFinite(rating) ? rating : undefined;
}
/** Bump when rating parse keys change so stale cache entries are not reused. */
const ENTITY_RATING_CACHE_KEY_VER = 'v2';
type RatingEntity = 'artist' | 'album';
function ratingCacheKey(entity: RatingEntity, serverId: string, id: string): string {
return `${entity}:${ENTITY_RATING_CACHE_KEY_VER}:${serverId}:${id}`;
}
async function prefetchUserRatings(
entity: RatingEntity,
serverId: string,
ids: string[],
fetchEntity: (id: string) => Promise<{ userRating?: unknown; rating?: unknown }>,
concurrency: number,
networkAllowed: boolean,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(ratingCacheKey(entity, serverId, id));
if (cached !== null) out.set(id, cached);
else uncached.push(id);
/** Read cached owner-scoped ratings. The index uses server keys; callers use saved-server ids. */
export async function getLocalEntityUserRatings(refs: EntityUserRatingRef[]): Promise<Map<string, number>> {
const unique = validRefs(refs);
if (!unique.length) return new Map();
const requestedByIndexKey = new Map<string, EntityUserRatingRef>();
for (const ref of unique) {
requestedByIndexKey.set(entityUserRatingKey({ ...ref, serverId: serverIndexKeyForId(ref.serverId) }), ref);
}
if (!uncached.length) return out;
if (!networkAllowed) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= uncached.length) return;
const id = uncached[i];
try {
const r = parseSubsonicEntityStarRating(await fetchEntity(id));
if (r !== undefined && r > 0) {
setCachedRating(ratingCacheKey(entity, serverId, id), r);
out.set(id, r);
}
} catch {
/* ignore */
}
try {
const responses = await Promise.all(chunks(unique, ENTITY_RATING_BATCH_LIMIT).map(batch =>
invoke<EntityUserRatingDto[]>('library_get_entity_user_ratings', {
refs: batch.map(ref => ({ ...ref, serverId: serverIndexKeyForId(ref.serverId) })),
}),
));
const out = new Map<string, number>();
for (const response of responses.flat()) {
const requested = requestedByIndexKey.get(entityUserRatingKey(response));
const serverId = requested?.serverId ?? mapServerIdFromIndexKey(response.serverId);
out.set(entityUserRatingKey({ ...response, serverId }), response.rating);
}
return out;
} catch {
return new Map();
}
const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
async function prefetchActiveServerUserRatings(
ids: string[],
fetchEntity: (id: string) => Promise<{ userRating?: unknown; rating?: unknown }>,
concurrency: number,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length || !shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= unique.length) return;
const id = unique[i];
/** Write a known owner-scoped rating without waiting for a full library sync. */
export function putLocalEntityUserRatings(ratings: Array<EntityUserRatingRef & { rating: number }>): void {
const valid = ratings.filter(rating =>
rating.serverId && rating.entityId && Number.isFinite(rating.rating),
);
for (const batch of chunks(valid, ENTITY_RATING_BATCH_LIMIT)) {
void invoke<void>('library_put_entity_user_ratings', {
ratings: batch.map(rating => ({ ...rating, serverId: serverIndexKeyForId(rating.serverId), fetchedAt: 0 })),
}).catch(() => {});
}
}
const hydrationQueued = new Set<string>();
const hydrationQueue: EntityUserRatingRef[] = [];
let activeHydrations = 0;
function scheduleEntityRatingHydration(refs: EntityUserRatingRef[]): void {
for (const ref of validRefs(refs)) {
const key = entityUserRatingKey(ref);
if (ref.entityKind === 'track' || hydrationQueued.has(key) || !shouldAttemptSubsonicForServer(ref.serverId)) continue;
hydrationQueued.add(key);
hydrationQueue.push(ref);
}
while (activeHydrations < MIX_RATING_PREFETCH_CONCURRENCY && hydrationQueue.length) {
const ref = hydrationQueue.shift()!;
activeHydrations++;
void (async () => {
try {
const r = parseSubsonicEntityStarRating(await fetchEntity(id));
if (r !== undefined && r > 0) out.set(id, r);
const entity = ref.entityKind === 'artist'
? (await getArtistForServer(ref.serverId, ref.entityId)).artist
: (await getAlbumForServer(ref.serverId, ref.entityId)).album;
const rating = parseSubsonicEntityStarRating(entity);
if (rating !== undefined) putLocalEntityUserRatings([{ ...ref, rating }]);
} catch {
/* ignore */
// A later list pass may retry transient server failures.
} finally {
hydrationQueued.delete(entityUserRatingKey(ref));
activeHydrations--;
scheduleEntityRatingHydration([]);
}
}
})();
}
const nWorkers = Math.min(concurrency, unique.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
export async function prefetchArtistUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
/** Resolve local ratings, then schedule non-blocking hydration for missing albums and artists. */
export async function resolveEntityUserRatings(
refs: EntityUserRatingRef[],
knownRefs: EntityUserRatingRef[] = [],
): Promise<Map<string, number>> {
return prefetchActiveServerUserRatings(
ids,
async id => (await getArtist(id)).artist,
concurrency,
);
const local = await getLocalEntityUserRatings(refs);
const knownKeys = new Set(validRefs(knownRefs).map(entityUserRatingKey));
scheduleEntityRatingHydration(validRefs(refs).filter(ref => (
!local.has(entityUserRatingKey(ref)) && !knownKeys.has(entityUserRatingKey(ref))
)));
return local;
}
/** Explicit-server variant for merged multi-server browse results. */
export async function prefetchArtistUserRatingsForServer(
serverId: string,
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
return prefetchUserRatings(
'artist',
serverId,
ids,
async id => (await getArtistForServer(serverId, id)).artist,
concurrency,
shouldAttemptSubsonicForServer(serverId),
);
/** Persist ratings already supplied by a list/detail payload for the next local-first pass. */
export function rememberEntityUserRating(
ref: EntityUserRatingRef,
payloadRating: unknown,
): void {
const rating = parseSubsonicEntityStarRating({ userRating: payloadRating });
if (rating !== undefined) putLocalEntityUserRatings([{ ...ref, rating }]);
}
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
export async function prefetchAlbumUserRatings(
/** Legacy prefetch APIs now return local hits and schedule, rather than await, hydration. */
async function prefetchForServer(
entityKind: 'artist' | 'album',
serverId: string | null | undefined,
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
return prefetchActiveServerUserRatings(
ids,
async id => (await getAlbum(id)).album,
concurrency,
);
if (!serverId) return new Map();
const refs = ids.map(entityId => ({ serverId, entityKind, entityId }));
const ratings = await resolveEntityUserRatings(refs);
const byId = new Map<string, number>();
for (const ref of refs) {
const rating = ratings.get(entityUserRatingKey(ref));
if (rating !== undefined) byId.set(ref.entityId, rating);
}
return byId;
}
/** Explicit-server variant for merged multi-server browse results. */
export async function prefetchAlbumUserRatingsForServer(
serverId: string,
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
return prefetchUserRatings(
'album',
serverId,
ids,
async id => (await getAlbumForServer(serverId, id)).album,
concurrency,
shouldAttemptSubsonicForServer(serverId),
);
export function prefetchArtistUserRatings(ids: string[], _concurrency = MIX_RATING_PREFETCH_CONCURRENCY): Promise<Map<string, number>> {
return prefetchForServer('artist', useAuthStore.getState().activeServerId, ids);
}
export function prefetchArtistUserRatingsForServer(serverId: string, ids: string[], _concurrency = MIX_RATING_PREFETCH_CONCURRENCY): Promise<Map<string, number>> {
return prefetchForServer('artist', serverId, ids);
}
export function prefetchAlbumUserRatings(ids: string[], _concurrency = MIX_RATING_PREFETCH_CONCURRENCY): Promise<Map<string, number>> {
return prefetchForServer('album', useAuthStore.getState().activeServerId, ids);
}
export function prefetchAlbumUserRatingsForServer(serverId: string, ids: string[], _concurrency = MIX_RATING_PREFETCH_CONCURRENCY): Promise<Map<string, number>> {
return prefetchForServer('album', serverId, ids);
}
/** Kept for compatibility with prior callers that invalidated the frontend TTL cache. */
export function invalidateEntityUserRatingCaches(_id: string): void {}
+18 -11
View File
@@ -1,5 +1,5 @@
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from '@/lib/api/subsonicClient';
import { invalidateEntityUserRatingCaches } from '@/lib/api/subsonicRatings';
import { putLocalEntityUserRatings, type EntityRatingKind } from '@/lib/api/subsonicRatings';
import { useAuthStore } from '@/store/authStore';
import { patchLibraryAlbumOnUse, patchLibraryTrackOnUse, type StarPatchMeta } from '@/lib/library/patchOnUse';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
@@ -115,17 +115,24 @@ export async function unstar(
.catch(() => {});
}
export async function setRating(id: string, rating: number): Promise<void> {
await api('setRating.view', { id, rating });
const serverId = useAuthStore.getState().activeServerId;
patchLibraryTrackOnUse(serverId, id, { userRating: rating });
// Cached song lists keyed by rating (e.g. Tracks → Highly Rated rail) become
// stale immediately. `invalidateEntityUserRatingCaches` is static-imported:
// mix paths already pull `subsonicRatings` (e.g. mixRatingFilter), so a
// dynamic import would not split chunks and only triggered INEFFECTIVE_DYNAMIC_IMPORT.
// Navidrome browse stays lazy to keep this module free of that dependency when unused.
export async function setRating(
id: string,
rating: number,
options?: { serverId?: string; kind?: EntityRatingKind },
): Promise<void> {
const serverId = options?.serverId ?? useAuthStore.getState().activeServerId;
if (serverId && serverId !== useAuthStore.getState().activeServerId) {
await apiForServer(serverId, 'setRating.view', { id, rating });
} else {
await api('setRating.view', { id, rating });
}
if (options?.kind === 'album' || options?.kind === 'artist') {
if (serverId) putLocalEntityUserRatings([{ serverId, entityKind: options.kind, entityId: id, rating }]);
} else {
patchLibraryTrackOnUse(serverId, id, { userRating: rating });
}
// Cached song lists keyed by rating (e.g. Tracks → Highly Rated rail) become stale immediately.
void import('@/lib/api/navidromeBrowse').then(m => m.ndInvalidateSongsCache()).catch(() => {});
invalidateEntityUserRatingCaches(id);
}
/**