mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
5cd01c90ac
* 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)
916 lines
28 KiB
TypeScript
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');
|
|
}
|