mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
06da15caf3
* feat(albums): persist browse sort and genre filter for the session Keep Albums sort and genre selection in an in-memory Zustand store so navigating into album detail and back no longer resets browse context. Fixes #875 (partial). * feat(albums): restore browse filters only when returning from album detail Keep sort in the session store for the app lifetime. Stash genre, year, compilation, starred, and lossless filters when leaving Albums for an album page and restore them on POP (back). Clear the stash when opening Albums from elsewhere via sidebar navigation. * feat(albums): filter quick-clear chips; fix lossless A–Z sort Add inline × on active toolbar filters (genre, year, favorites, lossless, compilations) without opening the popover. Route lossless album browse through advanced search with album sort clauses on Albums and Lossless Albums; client-sort on the network fallback path. * fix(albums): apply year filter when only from or to is set Resolve open-ended year bounds with gte/lte on the local index and partial fromYear/toYear on Subsonic. Update the year filter chip label for single-bound ranges. * refactor(albums): combine browse filters in one query (genre + year + lossless) Replace mutually exclusive load/loadFiltered branches with fetchAlbumBrowsePage that ANDs server-side filters on the local index (genre OR union). Network fallback applies year bounds after genre fetch. Always show sort while a year filter is active. * fix(albums): load favorites filter server-side instead of scanning all albums Starred on Albums was client-only: each page was filtered locally and pendingClientFilterMatch kept paginating the full catalog. Query starred albums via the local index or getAlbumList(starred); apply overrides only for in-session star/unstar. * feat(library): local album/artist favorites via patch-on-use Mirror album- and artist-level stars into the library index (library_patch_album, library_patch_artist, migration 010). Albums and Artists favorites browse use entity starred_at only; normal album catalog stays track-derived so patch stubs do not hide the library. Keep album year on favorite cards via track COALESCE, patch metadata, and safer raw_json merge. * fix(library): reconcile album/artist stars from server, drop stubs Favorites browse uses getAlbumList/getStarred2 as source of truth. library_reconcile_*_stars clears local stars removed elsewhere; patch-on-use updates existing rows only (no stub INSERT). Reconcile on favorites load and after star/unstar in-app. * feat(albums): favorites reconcile, filter combos, and back-navigation fix Album browse keeps filter state when returning from album detail (POP stash read on mount, request-generation guard against stale loads). Favorites use getStarred2 as source of truth: reconcile album.starred_at in the local index (UPDATE only, no stub rows), with a small session cache for instant paint. Combine favorites with lossless or genre via restrictAlbumIds in advanced search. Remove album/artist patch-on-use and migration 010; artist favorites stay network-only. Track patch-on-use unchanged. * feat(albums): catalog year bounds and genre list narrowed by filters Year filter spinners use min/max years from the local track index (not 1900); "from" starts at oldest, "to" at newest, values clamp to catalog. When year, lossless, favorites, or compilation filters are active, the genre picker lists only genres present on matching albums (other filters applied, genre excluded). Adds library_get_catalog_year_bounds for the year UI. * feat(albums): debounce year filter and show genre album counts Debounce year range changes by 350ms before reloading browse. Genre picker lists album counts per genre (from getGenres or from albums matching other active filters) and sorts genres by count descending. * fix(albums): compilation filter detection and scan cap Recognize OpenSubsonic compilation flags (compilation, releaseTypes) so client-side comp filters work on local index rows. Cap background pagination at 500 albums when no matches are visible and show empty state instead of spinning through the whole catalog. * feat(albums): filter compilations via local library index Add `compilation` to advanced search (album entity): reads OpenSubsonic flags from album raw_json. Album browse passes compFilter into library_advanced_search when the index is ready; network-only path keeps client-side filtering with the existing scan cap. * fix(albums): apply compilation filter on track-grouped index browse Album browse uses track aggregation, so compilation clauses were skipped. Filter track raw_json (same SQL as album), merge album flags at sync, and always run the client-side compilation pass as a fallback. * refactor(albums): split browse modules and extract browse_support commands Move album browse fetch/filter logic into focused modules and useAlbumBrowseData; register reconcile/year-bounds Tauri commands from browse_support. Trim dead helpers and barrel exports; fix typecheck in compilation tests. * chore: note PR #876 in CHANGELOG and settings credits * fix(albums): show catalog min/max in partial year filter chip label When only from or to year is set, the active chip now reads e.g. 1990–2020 instead of 1990– or –2025, using indexed catalog bounds when available.
811 lines
24 KiB
TypeScript
811 lines
24 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;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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)),
|
|
})));
|
|
}
|
|
|
|
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 function libraryGetCatalogYearBounds(args: { serverId: string }): Promise<CatalogYearBounds> {
|
|
const indexKey = serverIndexKeyForId(args.serverId);
|
|
return invoke<CatalogYearBounds>('library_get_catalog_year_bounds', {
|
|
serverId: indexKey,
|
|
});
|
|
}
|
|
|
|
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),
|
|
);
|
|
}
|