Files
psysonic/src/api/library.ts
T
cucadmuh 5cd01c90ac fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill

Restore atomic genre browse, filters, and counts via track_genre:
OpenSubsonic genres[] first with Navidrome-default split fallback, sync
write path, read-path query switches, blocking startup backfill with
progress, and v12 repair migration for DBs that recorded legacy 002–011.
TS fallback adds genreTagsFor and migration gate i18n across locales.

* fix(library): address multi-genre review — robust TS genres and scope join

genreTagsFor routes raw genres through parseItemGenres (single-object
Subsonic quirk and bare strings). Library-scoped genre browse/counts join
track for raw_json library_id fallback. Statistics keeps empty-genre bucket.

* docs: CHANGELOG and credits for multi-genre local index (PR #1059)

* docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
2026-06-11 01:02:35 +03:00

916 lines
28 KiB
TypeScript

/**
* Typed wrappers around the `library_*` Tauri commands (spec §7.1) plus
* subscribers for `library:sync-progress` / `library:sync-idle` events
* (§7.2). One thin file per cucadmuh's PR-5 kickoff Q1 — Settings UI
* (LibraryTab) imports from here; nothing else in the app talks to the
* backend library surface directly.
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useAuthStore } from '../store/authStore';
import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
// ── DTO mirrors (camelCase, matching the Rust `#[serde(rename_all = "camelCase")]`) ─
export interface TrackRefDto {
serverId: string;
trackId: string;
contentHash?: string | null;
}
/** E3 readiness summary — present only on single-track `libraryGetTrack` reads. */
export interface TrackEnrichmentDto {
waveformReady: boolean;
loudnessReady: boolean;
lyricsCached: boolean;
}
export interface LibraryTrackDto {
serverId: string;
id: string;
contentHash?: string | null;
title: string;
titleSort?: string | null;
artist?: string | null;
artistId?: string | null;
album: string;
albumId?: string | null;
albumArtist?: string | null;
durationSec: number;
trackNumber?: number | null;
discNumber?: number | null;
year?: number | null;
genre?: string | null;
suffix?: string | null;
bitRate?: number | null;
sizeBytes?: number | null;
coverArtId?: string | null;
starredAt?: number | null;
userRating?: number | null;
playCount?: number | null;
playedAt?: number | null;
serverPath?: string | null;
libraryId?: string | null;
isrc?: string | null;
mbidRecording?: string | null;
bpm?: number | null;
/** `'analysis'` | `'tag'` — Advanced Search BPM dual-storage projection only. */
bpmSource?: string | null;
replayGainTrackDb?: number | null;
replayGainAlbumDb?: number | null;
serverUpdatedAt?: number | null;
serverCreatedAt?: number | null;
syncedAt: number;
/** E3: populated only by `libraryGetTrack` (omitted on list/batch reads). */
enrichment?: TrackEnrichmentDto | null;
rawJson: unknown;
}
export interface SyncStateDto {
serverId: string;
libraryScope: string;
syncPhase: string;
capabilityFlags: number;
libraryTier: string;
lastFullSyncAt?: number | null;
lastDeltaSyncAt?: number | null;
nextPollAt?: number | null;
serverLastScanIso?: string | null;
indexesLastModifiedMs?: number | null;
artistsLastModifiedMs?: number | null;
localTrackCount?: number | null;
serverTrackCount?: number | null;
lastError?: string | null;
localTracksMaxUpdatedMs?: number | null;
/** True when at least one non-deleted track exists locally (cheap EXISTS). */
hasLocalTracks?: boolean;
ingestStrategy?: string | null;
ingestPhase?: string | null;
/** Tracks ingested per persisted initial-sync cursor (IS-3 progress). */
cursorIngestedCount?: number | null;
n1BulkUnreliable?: boolean | null;
}
export interface LibraryTracksEnvelope {
tracks: LibraryTrackDto[];
total: number;
}
export interface TrackArtifactDto {
serverId: string;
trackId: string;
artifactKind: string;
format: string;
sourceKind: string;
sourceId: string;
language?: string | null;
contentText?: string | null;
contentBytes: number;
notFound: boolean;
contentHash?: string | null;
fetchedAt: number;
expiresAt?: number | null;
}
export interface ArtifactInputDto {
artifactKind: string;
format: string;
sourceKind: string;
sourceId: string;
language?: string | null;
contentText?: string | null;
contentBlob?: number[] | null;
contentBytes?: number;
notFound?: boolean;
contentHash?: string | null;
expiresAt?: number | null;
}
export interface TrackFactDto {
serverId: string;
trackId: string;
factKind: string;
valueReal?: number | null;
valueInt?: number | null;
valueText?: string | null;
unit?: string | null;
sourceKind: string;
sourceId: string;
confidence: number;
contentHash?: string | null;
fetchedAt: number;
expiresAt?: number | null;
}
export interface FactInputDto {
factKind: string;
valueReal?: number | null;
valueInt?: number | null;
valueText?: string | null;
unit?: string | null;
sourceKind: string;
sourceId: string;
confidence?: number;
contentHash?: string | null;
expiresAt?: number | null;
}
export interface OfflinePathDto {
serverId: string;
trackId: string;
localPath?: string | null;
missing: boolean;
}
export interface PurgeReportDto {
tracksDeleted: number;
albumsDeleted: number;
artistsDeleted: number;
offlineRowsDeleted: number;
bytesFreed: number;
}
export interface SyncJobDto {
jobId: string;
serverId: string;
kind: string; // 'initial_sync' | 'delta_sync'
}
// ── Advanced Search (PR-5d, §5.13 / §5.5B) ────────────────────────────
export type LibraryEntityType = 'artist' | 'album' | 'track';
/** v1 operator set the Rust `FilterFieldRegistry` accepts (§5.13.2). */
export type FilterOperator = 'eq' | 'gte' | 'lte' | 'between' | 'fts' | 'is_true' | 'in';
export type SortDir = 'asc' | 'desc';
export interface LibraryFilterClause {
field: string; // registry id, e.g. 'genre' | 'year' | 'bpm'
op: FilterOperator;
value?: string | number | boolean | null;
valueTo?: number | null; // between: inclusive upper bound
}
export interface LibrarySortClause {
field: string;
dir: SortDir;
}
export interface LibraryAdvancedSearchRequest {
serverId: string;
libraryScope?: string | null;
query?: string | null; // shorthand → fts clause on text fields
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
starredOnly?: boolean | null;
/** Server favorites ids ∩ local filters (lossless, genre, year). */
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit: number;
offset?: number;
/** Skip expensive COUNT queries (Live Search). */
skipTotals?: boolean;
/** Album text query matches title/name only (All Albums scoped browse). */
queryAlbumTitleOnly?: boolean | null;
}
export interface LibraryAlbumDto {
serverId: string;
id: string;
name: string;
artist?: string | null;
artistId?: string | null;
songCount?: number | null;
durationSec?: number | null;
year?: number | null;
genre?: string | null;
coverArtId?: string | null;
starredAt?: number | null;
syncedAt: number;
rawJson: unknown;
}
export interface LibraryArtistDto {
serverId: string;
id: string;
name: string;
albumCount?: number | null;
syncedAt: number;
rawJson: unknown;
}
export interface LibrarySearchTotals {
artists: number;
albums: number;
tracks: number;
}
export interface LibraryAdvancedSearchResponse {
artists: LibraryArtistDto[];
albums: LibraryAlbumDto[];
tracks: LibraryTrackDto[];
totals: LibrarySearchTotals;
/** Registry field ids actually applied — UI chips / debug. */
appliedFilters: string[];
source: 'local' | 'network' | 'mixed';
}
export interface LibraryCrossServerSearchResponse {
hits: LibraryTrackDto[];
/** Fuzzy `title LIKE` matches the exact FTS pass missed (§5.9 / H3). */
fuzzy: LibraryTrackDto[];
serversSearched: string[];
}
function serverIndexKeyForId(serverId: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
if (!server) return serverId;
return serverIndexKeyFromUrl(server.url) || serverId;
}
function mapServerIdFromIndexKey(serverId: string, fallback?: string): string {
if (fallback) return fallback;
return resolveServerIdForIndexKey(serverId);
}
function mapTracksServerId(
tracks: LibraryTrackDto[],
fallbackServerId?: string,
): LibraryTrackDto[] {
if (tracks.length === 0) return tracks;
return tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId, fallbackServerId),
}));
}
// ── Read commands (PR-5a) ─────────────────────────────────────────────
export function libraryGetStatus(
serverId: string,
libraryScope?: string,
): Promise<SyncStateDto> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<SyncStateDto>('library_get_status', { serverId: indexKey, libraryScope })
.then(status => ({ ...status, serverId }));
}
export function librarySearch(
serverId: string,
query: string,
options?: { limit?: number; offset?: number; libraryScope?: string },
): Promise<LibraryTracksEnvelope> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryTracksEnvelope>('library_search', {
serverId: indexKey,
query,
limit: options?.limit,
offset: options?.offset,
libraryScope: options?.libraryScope,
}).then(envelope => ({
...envelope,
tracks: mapTracksServerId(envelope.tracks, serverId),
}));
}
/**
* Advanced Search against the local index (§5.13). The frontend fallback
* (PR-7 F2) decides local vs network and maps the same `LibraryFilterClause`
* shape onto the network path; this wrapper only talks to the local builder.
*/
export function libraryAdvancedSearch(
request: LibraryAdvancedSearchRequest,
): Promise<LibraryAdvancedSearchResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryAdvancedSearchResponse>('library_advanced_search', {
request: { ...request, serverId: indexKey },
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
tracks: mapTracksServerId(response.tracks, request.serverId),
}));
}
export interface LibraryLiveSearchResponse {
artists: LibraryArtistDto[];
albums: LibraryAlbumDto[];
tracks: LibraryTrackDto[];
source: 'local' | 'network' | 'mixed';
}
/** Live Search dropdown — one lean FTS query (§5.9), not Advanced Search. */
export interface LibraryLiveSearchRequest {
serverId: string;
query: string;
/** Subsonic `musicFolderId` / Navidrome library id — omit for all libraries. */
libraryScope?: string | null;
artistLimit?: number;
albumLimit?: number;
songLimit?: number;
/** UI generation — stale Rust FTS passes are dropped server-side. */
requestEpoch?: number;
}
export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<LibraryLiveSearchResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryLiveSearchResponse>('library_live_search', {
request: { ...request, serverId: indexKey },
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
tracks: mapTracksServerId(response.tracks, request.serverId),
}));
}
export interface LibraryLosslessAlbumsRequest {
serverId: string;
libraryScope?: string | null;
limit?: number;
offset?: number;
}
export interface LibraryLosslessAlbumsResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
source: 'local';
}
/** Paginated lossless albums from the local track index. */
export function libraryListLosslessAlbums(
request: LibraryLosslessAlbumsRequest,
): Promise<LibraryLosslessAlbumsResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryLosslessAlbumsResponse>('library_list_lossless_albums', {
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
limit: request.limit,
offset: request.offset,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
}));
}
export interface LibraryArtistLosslessBrowseRequest {
serverId: string;
artistId: string;
libraryScope?: string | null;
}
export interface LibraryArtistLosslessBrowseResponse {
albums: LibraryAlbumDto[];
tracks: LibraryTrackDto[];
source: 'local';
}
/** Lossless albums + tracks for one artist from the local index. */
export function libraryGetArtistLosslessBrowse(
request: LibraryArtistLosslessBrowseRequest,
): Promise<LibraryArtistLosslessBrowseResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryArtistLosslessBrowseResponse>('library_get_artist_lossless_browse', {
request: {
serverId: indexKey,
artistId: request.artistId,
libraryScope: request.libraryScope ?? undefined,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
tracks: mapTracksServerId(response.tracks, request.serverId),
}));
}
/** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */
export function librarySearchCrossServer(args: {
query: string;
limit?: number;
servers?: string[];
}): Promise<LibraryCrossServerSearchResponse> {
const indexServers = args.servers?.map(serverIndexKeyForId);
return invoke<LibraryCrossServerSearchResponse>('library_search_cross_server', {
...args,
servers: indexServers,
}).then(response => ({
...response,
hits: mapTracksServerId(response.hits),
fuzzy: mapTracksServerId(response.fuzzy),
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
}));
}
export function libraryGetTrack(
serverId: string,
trackId: string,
): Promise<LibraryTrackDto | null> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryTrackDto | null>('library_get_track', { serverId: indexKey, trackId })
.then(track => (track ? { ...track, serverId } : track));
}
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
export function libraryUpsertSongsFromApi(
serverId: string,
songs: unknown[],
): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
}
/** `library_get_tracks_batch` cap (spec §7.1). */
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
const indexKeyMap = new Map<string, string>();
const remapped = refs.map(ref => {
const indexKey = serverIndexKeyForId(ref.serverId);
if (!indexKeyMap.has(indexKey)) indexKeyMap.set(indexKey, ref.serverId);
return { ...ref, serverId: indexKey };
});
return invoke<LibraryTrackDto[]>('library_get_tracks_batch', { refs: remapped })
.then(tracks => tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId, indexKeyMap.get(track.serverId)),
})));
}
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
if (refs.length === 0) return [];
const out: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
out.push(...batch);
}
return out;
}
export function libraryGetTracksByAlbum(
serverId: string,
albumId: string,
): Promise<LibraryTrackDto[]> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryTrackDto[]>('library_get_tracks_by_album', { serverId: indexKey, albumId })
.then(tracks => mapTracksServerId(tracks, serverId));
}
export function libraryGetArtifact(
serverId: string,
trackId: string,
artifactKind: string,
options?: { sourceKind?: string; sourceId?: string; format?: string },
): Promise<TrackArtifactDto | null> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<TrackArtifactDto | null>('library_get_artifact', {
serverId: indexKey,
trackId,
artifactKind,
sourceKind: options?.sourceKind,
sourceId: options?.sourceId,
format: options?.format,
}).then(artifact => (artifact ? { ...artifact, serverId } : artifact));
}
export function libraryGetFacts(
serverId: string,
trackId: string,
factKinds?: string[],
): Promise<TrackFactDto[]> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<TrackFactDto[]>('library_get_facts', { serverId: indexKey, trackId, factKinds })
.then(facts => facts.map(fact => ({ ...fact, serverId })));
}
export function libraryGetOfflinePath(
serverId: string,
trackId: string,
): Promise<OfflinePathDto> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<OfflinePathDto>('library_get_offline_path', { serverId: indexKey, trackId })
.then(path => ({ ...path, serverId }));
}
// ── Session + lifecycle (PR-5b) ───────────────────────────────────────
export function librarySyncBindSession(args: {
serverId: string;
baseUrl: string;
username: string;
password: string;
libraryScope?: string;
}): Promise<void> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_sync_bind_session', { ...args, serverId: indexKey });
}
export function librarySyncClearSession(serverId: string): Promise<void> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<void>('library_sync_clear_session', { serverId: indexKey });
}
export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active';
export function libraryGetPlaybackHint(): Promise<PlaybackHint> {
return invoke<PlaybackHint>('library_get_playback_hint');
}
export function librarySetPlaybackHint(hint: PlaybackHint): Promise<void> {
return invoke<void>('library_set_playback_hint', { hint });
}
export type SyncMode = 'full' | 'delta';
export function librarySyncStart(args: {
serverId: string;
mode: SyncMode;
libraryScope?: string;
}): Promise<SyncJobDto> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<SyncJobDto>('library_sync_start', { ...args, serverId: indexKey })
.then(job => ({ ...job, serverId: args.serverId }));
}
/** Forced full-budget tombstone delta — Settings → «Verify integrity». */
export function librarySyncVerifyIntegrity(args: {
serverId: string;
libraryScope?: string;
}): Promise<SyncJobDto> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<SyncJobDto>('library_sync_verify_integrity', { ...args, serverId: indexKey })
.then(job => ({ ...job, serverId: args.serverId }));
}
export function librarySyncCancel(jobId?: string): Promise<void> {
return invoke<void>('library_sync_cancel', { jobId });
}
export function libraryPatchTrack(args: {
serverId: string;
trackId: string;
patch: {
starredAt?: number | null;
userRating?: number | null;
playCount?: number | null;
playedAt?: number | null;
/** E2: playback-derived `md5_16kb` content fingerprint. Normally written
* by the Rust analysis bridge; exposed here for contract completeness. */
contentHash?: string | null;
};
}): Promise<void> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_patch_track', { ...args, serverId: indexKey });
}
/** Server favorites → `album.starred_at` (UPDATE only, no stub rows). */
export function libraryReconcileAlbumStars(args: {
serverId: string;
starredAlbums: Array<{ id: string; starredAt: number }>;
}): Promise<void> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_reconcile_album_stars', {
serverId: indexKey,
starredAlbums: args.starredAlbums.map(a => ({ id: a.id, starredAt: a.starredAt })),
});
}
export function libraryPutArtifact(args: {
serverId: string;
trackId: string;
artifact: ArtifactInputDto;
}): Promise<void> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_put_artifact', { ...args, serverId: indexKey });
}
export function libraryPutFact(args: {
serverId: string;
trackId: string;
fact: FactInputDto;
}): Promise<void> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_put_fact', { ...args, serverId: indexKey });
}
export function libraryPurgeServer(args: {
serverId: string;
includeAnalysis?: boolean;
includeOffline?: boolean;
}): Promise<PurgeReportDto> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<PurgeReportDto>('library_purge_server', { ...args, serverId: indexKey });
}
export function libraryDeleteServerData(serverId: string): Promise<void> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<void>('library_delete_server_data', { serverId: indexKey });
}
// ── Player stats (local listening history) ────────────────────────────
export type PlaySessionEndReason = 'ended' | 'skip' | 'stop' | 'switch' | 'close';
export type PlaySessionInput = {
serverId: string;
trackId: string;
startedAtMs: number;
listenedSec: number;
positionMaxSec: number;
endReason: PlaySessionEndReason;
/** Player-known track duration when the library index has none. */
durationSecHint?: number;
};
export type PlaySessionYearSummary = {
totalListenedSec: number;
sessionCount: number;
trackPlayCount: number;
uniqueTrackCount: number;
listeningDayCount: number;
fullCount: number;
partialCount: number;
};
export type PlaySessionHeatmapDay = {
date: string;
trackPlayCount: number;
};
export type PlaySessionDayTrack = {
serverId: string;
trackId: string;
title: string;
artist: string | null;
listenedSec: number;
completion: 'partial' | 'full' | string;
startedAtMs: number;
};
export type PlaySessionDayDetail = {
totals: {
totalListenedSec: number;
sessionCount: number;
trackPlayCount: number;
fullCount: number;
partialCount: number;
};
tracks: PlaySessionDayTrack[];
};
export type PlaySessionYearBounds = {
minYear: number | null;
maxYear: number | null;
};
export type CatalogYearBounds = {
minYear: number | null;
maxYear: number | null;
};
export type GenreAlbumCountRow = {
value: string;
albumCount: number;
songCount: number;
};
export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise<CatalogYearBounds> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<CatalogYearBounds>('library_get_catalog_year_bounds', {
serverId: indexKey,
});
}
export function libraryGetGenreAlbumCounts(args: {
serverId: string;
libraryScope?: string;
}): Promise<GenreAlbumCountRow[]> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
serverId: indexKey,
libraryScope: args.libraryScope,
});
}
export type LibraryGenreAlbumsRequest = {
serverId: string;
genre: string;
libraryScope?: string | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
includeTotal?: boolean;
};
export type LibraryGenreAlbumsResponse = {
albums: LibraryAlbumDto[];
hasMore: boolean;
total?: number | null;
source: 'local';
};
/** Paginated albums for one genre from the local track index. */
export function libraryListAlbumsByGenre(
request: LibraryGenreAlbumsRequest,
): Promise<LibraryGenreAlbumsResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryGenreAlbumsResponse>('library_list_albums_by_genre', {
request: {
serverId: indexKey,
genre: request.genre,
libraryScope: request.libraryScope ?? undefined,
sort: request.sort ?? [],
limit: request.limit ?? 50,
offset: request.offset ?? 0,
includeTotal: request.includeTotal ?? false,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
}));
}
export type PlaySessionRecentDay = {
date: string;
totalListenedSec: number;
sessionCount: number;
trackPlayCount: number;
fullCount: number;
partialCount: number;
};
export function libraryRecordPlaySession(input: PlaySessionInput): Promise<void> {
const indexKey = serverIndexKeyForId(input.serverId);
return invoke<void>('library_record_play_session', { input: { ...input, serverId: indexKey } });
}
export function libraryGetPlayerStatsYearSummary(year: number): Promise<PlaySessionYearSummary> {
return invoke<PlaySessionYearSummary>('library_get_player_stats_year_summary', { year });
}
export function libraryGetPlayerStatsHeatmap(year: number): Promise<PlaySessionHeatmapDay[]> {
return invoke<PlaySessionHeatmapDay[]>('library_get_player_stats_heatmap', { year });
}
export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise<PlaySessionDayDetail> {
return invoke<PlaySessionDayDetail>('library_get_player_stats_day_detail', { dateIso })
.then(detail => ({
...detail,
tracks: detail.tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId),
})),
}));
}
export function libraryGetPlayerStatsYearBounds(): Promise<PlaySessionYearBounds> {
return invoke<PlaySessionYearBounds>('library_get_player_stats_year_bounds');
}
export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySessionRecentDay[]> {
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
}
// ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
serverId: string;
libraryScope: string;
/** 'phase_changed' | 'ingest_page' | 'remapped' | 'tombstoned' | 'completed' | 'error' */
kind: string;
phase?: string | null;
ingestedTotal?: number | null;
batchCount?: number | null;
remappedCount?: number | null;
tombstonesChecked?: number | null;
tombstonesDeleted?: number | null;
completedKind?: string | null;
message?: string | null;
/** S1 per-batch timings from the Rust ingest runner (when available). */
ingestMetrics?: IngestBatchMetrics | null;
}
export interface IngestBatchMetrics {
offset: number;
strategy: string;
fetchMs: number;
writeMs: number;
lockWaitMs: number;
sqlExecMs: number;
persistMs: number;
rowCount: number;
bulkIngestActive: boolean;
}
export interface LibrarySyncIdlePayload {
serverId: string;
libraryScope: string;
kind: string; // 'initial_sync' | 'delta_sync'
ok: boolean;
error?: string | null;
}
export function subscribeLibrarySyncProgress(
handler: (payload: LibrarySyncProgressPayload) => void,
): Promise<UnlistenFn> {
return listen<LibrarySyncProgressPayload>('library:sync-progress', ({ payload }) =>
handler(payload),
);
}
export function subscribeLibrarySyncIdle(
handler: (payload: LibrarySyncIdlePayload) => void,
): Promise<UnlistenFn> {
return listen<LibrarySyncIdlePayload>('library:sync-idle', ({ payload }) =>
handler(payload),
);
}
// ── Genre tags startup backfill (multi-genre local index) ───────────────
export interface GenreTagsInspectDto {
needed: boolean;
totalTracks: number;
doneTracks: number;
}
export interface GenreTagsProgressEvent {
done: number;
total: number;
}
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
}
export function libraryGenreTagsRun(): Promise<void> {
return invoke<void>('library_genre_tags_run');
}