mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
281e86fd3b
* fix(queue): persist timeline play history across queue replace (#1096) Add a session-scoped play-history buffer (with play_session cold bootstrap) and timeline UI that shows history + current + upcoming without mutating the canonical queue or Subsonic sync. * fix(queue): pin timeline current to top and replay history in-place Timeline scroll matches queue mode (current at top). History clicks insert after the playing track instead of replacing the queue, and replayed tracks stay visible in the history strip. * docs: add CHANGELOG and credits for timeline play history (PR #1204) * fix(queue): resolve cross-server cover art for timeline history Include album/cover ids in play_session bootstrap rows, prefetch history refs through the queue resolver per server, and resolve before replay so Now Playing artwork works for inactive-server tracks. * fix(now-playing): stop playbackReport on cross-server track switch Send stopped to the previous server's playbackReport session when the playback server changes (queue click, history replay, etc.) so Who is listening clears on the server that was showing the prior track. * fix(queue): close timeline history review gaps for PR #1204 Defer play_session bootstrap until the library index is ready with retry while timeline mode is active; resolve history and queue rows by serverId + trackId for mixed-server queues; add tests for bootstrap defer and ref lookup. * chore(queue): remove dead timeline scroll guard in QueueList Timeline scroll is handled in the virtual-rows effect; the legacy branch is queue/playlist only. * fix(queue): address PR review nits for timeline play history Use authoritative row.ref.serverId for history clicks before resolver fill; simplify empty bootstrap seed; tighten completion types; unify recent_plays SQL. * fix(queue): immutable session history append for useSyncExternalStore Replace in-place push with a fresh array so getSnapshot returns a new reference and React re-renders on live appends without a playerStore update.
946 lines
28 KiB
TypeScript
946 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;
|
|
ignoredArticles?: string | 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;
|
|
nameSort?: string | null;
|
|
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';
|
|
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 });
|
|
}
|
|
|
|
export type PlaySessionRecentTrack = {
|
|
serverId: string;
|
|
trackId: string;
|
|
title: string;
|
|
artist: string | null;
|
|
album: string | null;
|
|
albumId: string | null;
|
|
coverArtId: string | null;
|
|
startedAtMs: number;
|
|
listenedSec: number;
|
|
completion: 'partial' | 'full';
|
|
};
|
|
|
|
export function libraryGetRecentPlaySessions(args?: {
|
|
limit?: number;
|
|
sinceMs?: number;
|
|
}): Promise<PlaySessionRecentTrack[]> {
|
|
return invoke<PlaySessionRecentTrack[]>('library_get_recent_play_sessions', {
|
|
limit: args?.limit,
|
|
sinceMs: args?.sinceMs,
|
|
}).then(rows =>
|
|
rows.map(row => ({
|
|
...row,
|
|
serverId: mapServerIdFromIndexKey(row.serverId),
|
|
})),
|
|
);
|
|
}
|
|
|
|
// ── 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');
|
|
}
|