/** * 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 { const indexKey = serverIndexKeyForId(serverId); return invoke('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 { const indexKey = serverIndexKeyForId(serverId); return invoke('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 { const indexKey = serverIndexKeyForId(request.serverId); return invoke('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 { const indexKey = serverIndexKeyForId(request.serverId); return invoke('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 { const indexKey = serverIndexKeyForId(request.serverId); return invoke('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 { const indexKey = serverIndexKeyForId(request.serverId); return invoke('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 { const indexServers = args.servers?.map(serverIndexKeyForId); return invoke('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 { const indexKey = serverIndexKeyForId(serverId); return invoke('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 { const indexKey = serverIndexKeyForId(serverId); return invoke('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 { const indexKeyMap = new Map(); 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('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 { 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 { const indexKey = serverIndexKeyForId(serverId); return invoke('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 { const indexKey = serverIndexKeyForId(serverId); return invoke('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 { const indexKey = serverIndexKeyForId(serverId); return invoke('library_get_facts', { serverId: indexKey, trackId, factKinds }) .then(facts => facts.map(fact => ({ ...fact, serverId }))); } export function libraryGetOfflinePath( serverId: string, trackId: string, ): Promise { const indexKey = serverIndexKeyForId(serverId); return invoke('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 { const indexKey = serverIndexKeyForId(args.serverId); return invoke('library_sync_bind_session', { ...args, serverId: indexKey }); } export function librarySyncClearSession(serverId: string): Promise { const indexKey = serverIndexKeyForId(serverId); return invoke('library_sync_clear_session', { serverId: indexKey }); } export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active'; export function libraryGetPlaybackHint(): Promise { return invoke('library_get_playback_hint'); } export function librarySetPlaybackHint(hint: PlaybackHint): Promise { return invoke('library_set_playback_hint', { hint }); } export type SyncMode = 'full' | 'delta'; export function librarySyncStart(args: { serverId: string; mode: SyncMode; libraryScope?: string; }): Promise { const indexKey = serverIndexKeyForId(args.serverId); return invoke('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 { const indexKey = serverIndexKeyForId(args.serverId); return invoke('library_sync_verify_integrity', { ...args, serverId: indexKey }) .then(job => ({ ...job, serverId: args.serverId })); } export function librarySyncCancel(jobId?: string): Promise { return invoke('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 { const indexKey = serverIndexKeyForId(args.serverId); return invoke('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 { const indexKey = serverIndexKeyForId(args.serverId); return invoke('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 { const indexKey = serverIndexKeyForId(args.serverId); return invoke('library_put_artifact', { ...args, serverId: indexKey }); } export function libraryPutFact(args: { serverId: string; trackId: string; fact: FactInputDto; }): Promise { const indexKey = serverIndexKeyForId(args.serverId); return invoke('library_put_fact', { ...args, serverId: indexKey }); } export function libraryPurgeServer(args: { serverId: string; includeAnalysis?: boolean; includeOffline?: boolean; }): Promise { const indexKey = serverIndexKeyForId(args.serverId); return invoke('library_purge_server', { ...args, serverId: indexKey }); } export function libraryDeleteServerData(serverId: string): Promise { const indexKey = serverIndexKeyForId(serverId); return invoke('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 { const indexKey = serverIndexKeyForId(args.serverId); return invoke('library_get_catalog_year_bounds', { serverId: indexKey, }); } export function libraryGetGenreAlbumCounts(args: { serverId: string; libraryScope?: string; }): Promise { const indexKey = serverIndexKeyForId(args.serverId); return invoke('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 { const indexKey = serverIndexKeyForId(request.serverId); return invoke('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 { const indexKey = serverIndexKeyForId(input.serverId); return invoke('library_record_play_session', { input: { ...input, serverId: indexKey } }); } export function libraryGetPlayerStatsYearSummary(year: number): Promise { return invoke('library_get_player_stats_year_summary', { year }); } export function libraryGetPlayerStatsHeatmap(year: number): Promise { return invoke('library_get_player_stats_heatmap', { year }); } export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise { return invoke('library_get_player_stats_day_detail', { dateIso }) .then(detail => ({ ...detail, tracks: detail.tracks.map(track => ({ ...track, serverId: mapServerIdFromIndexKey(track.serverId), })), })); } export function libraryGetPlayerStatsYearBounds(): Promise { return invoke('library_get_player_stats_year_bounds'); } export function libraryGetPlayerStatsRecentDays(limit = 30): Promise { return invoke('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 { return listen('library:sync-progress', ({ payload }) => handler(payload), ); } export function subscribeLibrarySyncIdle( handler: (payload: LibrarySyncIdlePayload) => void, ): Promise { return listen('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 { return invoke('library_genre_tags_inspect'); } export function libraryGenreTagsRun(): Promise { return invoke('library_genre_tags_run'); }