diff --git a/src/lib/api/library.ts b/src/lib/api/library.ts index 305e1aff..ec3223e6 100644 --- a/src/lib/api/library.ts +++ b/src/lib/api/library.ts @@ -1,945 +1,15 @@ /** * 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. + * subscribers for `library:sync-progress` / `library:sync-idle` events (§7.2). + * + * Formerly one thin file (cucadmuh's PR-5 kickoff Q1); split by concern under + * `library/` for the ~500 LOC ceiling. This barrel keeps `@/lib/api/library` as + * the single import point, so the Settings UI (LibraryTab) and every other + * consumer are unchanged. The private server-id↔index-key helpers live in + * `library/internal` and are intentionally not re-exported. */ - -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { useAuthStore } from '@/store/authStore'; -import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey'; -import { resolveServerIdForIndexKey } from '@/lib/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 { - 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'; - 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 }); -} - -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 { - return invoke('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 { - 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'); -} +export * from './library/dto'; +export * from './library/reads'; +export * from './library/sync'; +export * from './library/stats'; +export * from './library/events'; diff --git a/src/lib/api/library/dto.ts b/src/lib/api/library/dto.ts new file mode 100644 index 00000000..44c88788 --- /dev/null +++ b/src/lib/api/library/dto.ts @@ -0,0 +1,466 @@ +/** + * DTO mirrors for the `library_*` Tauri command surface (camelCase, matching the + * Rust `#[serde(rename_all = "camelCase")]`). Split out of the former single + * `lib/api/library.ts` god-module; the wrappers (reads/sync/stats/events) and the + * `@/lib/api/library` barrel re-export these, so consumers are unchanged. + */ + +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[]; +} + +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 interface LibraryLosslessAlbumsRequest { + serverId: string; + libraryScope?: string | null; + limit?: number; + offset?: number; +} + +export interface LibraryLosslessAlbumsResponse { + albums: LibraryAlbumDto[]; + hasMore: boolean; + source: 'local'; +} + +export interface LibraryArtistLosslessBrowseRequest { + serverId: string; + artistId: string; + libraryScope?: string | null; +} + +export interface LibraryArtistLosslessBrowseResponse { + albums: LibraryAlbumDto[]; + tracks: LibraryTrackDto[]; + source: 'local'; +} + +export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active'; + +export type SyncMode = 'full' | 'delta'; + +// ── 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 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'; +}; + +export type PlaySessionRecentDay = { + date: string; + totalListenedSec: number; + sessionCount: number; + trackPlayCount: number; + fullCount: number; + partialCount: number; +}; + +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'; +}; + +// ── 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; +} + +// ── Genre tags startup backfill (multi-genre local index) ─────────────── + +export interface GenreTagsInspectDto { + needed: boolean; + totalTracks: number; + doneTracks: number; +} + +export interface GenreTagsProgressEvent { + done: number; + total: number; +} diff --git a/src/lib/api/library/events.ts b/src/lib/api/library/events.ts new file mode 100644 index 00000000..4adc43e8 --- /dev/null +++ b/src/lib/api/library/events.ts @@ -0,0 +1,36 @@ +/** + * Event subscriptions (`library:sync-progress` / `library:sync-idle`, §7.2) plus + * the genre-tags startup backfill commands. Split out of the former single + * `lib/api/library.ts`; re-exported via the `@/lib/api/library` barrel. + */ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { + LibrarySyncProgressPayload, + LibrarySyncIdlePayload, + GenreTagsInspectDto, +} from './dto'; + +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), + ); +} + +export function libraryGenreTagsInspect(): Promise { + return invoke('library_genre_tags_inspect'); +} + +export function libraryGenreTagsRun(): Promise { + return invoke('library_genre_tags_run'); +} diff --git a/src/lib/api/library/internal.ts b/src/lib/api/library/internal.ts new file mode 100644 index 00000000..1f7ca19f --- /dev/null +++ b/src/lib/api/library/internal.ts @@ -0,0 +1,30 @@ +/** + * Internal server-id ↔ index-key mapping helpers shared by the library command + * wrappers (reads/sync/stats). Not part of the public `@/lib/api/library` barrel. + */ +import { useAuthStore } from '@/store/authStore'; +import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey'; +import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; +import type { LibraryTrackDto } from './dto'; + +export function serverIndexKeyForId(serverId: string): string { + const server = useAuthStore.getState().servers.find(s => s.id === serverId); + if (!server) return serverId; + return serverIndexKeyFromUrl(server.url) || serverId; +} + +export function mapServerIdFromIndexKey(serverId: string, fallback?: string): string { + if (fallback) return fallback; + return resolveServerIdForIndexKey(serverId); +} + +export function mapTracksServerId( + tracks: LibraryTrackDto[], + fallbackServerId?: string, +): LibraryTrackDto[] { + if (tracks.length === 0) return tracks; + return tracks.map(track => ({ + ...track, + serverId: mapServerIdFromIndexKey(track.serverId, fallbackServerId), + })); +} diff --git a/src/lib/api/library/reads.ts b/src/lib/api/library/reads.ts new file mode 100644 index 00000000..f5e8eeb0 --- /dev/null +++ b/src/lib/api/library/reads.ts @@ -0,0 +1,247 @@ +/** + * Read commands (PR-5a) — typed wrappers around the `library_*` read/query Tauri + * commands. Split out of the former single `lib/api/library.ts`; re-exported via + * the `@/lib/api/library` barrel. + */ +import { invoke } from '@tauri-apps/api/core'; +import { serverIndexKeyForId, mapServerIdFromIndexKey, mapTracksServerId } from './internal'; +import type { + SyncStateDto, + LibraryTracksEnvelope, + LibraryAdvancedSearchRequest, + LibraryAdvancedSearchResponse, + LibraryLiveSearchRequest, + LibraryLiveSearchResponse, + LibraryLosslessAlbumsRequest, + LibraryLosslessAlbumsResponse, + LibraryArtistLosslessBrowseRequest, + LibraryArtistLosslessBrowseResponse, + LibraryCrossServerSearchResponse, + LibraryTrackDto, + TrackRefDto, + TrackArtifactDto, + TrackFactDto, + OfflinePathDto, +} from './dto'; + +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 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), + })); +} + +/** 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), + })), + })); +} + +/** 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 })); +} diff --git a/src/lib/api/library/stats.ts b/src/lib/api/library/stats.ts new file mode 100644 index 00000000..c6d43355 --- /dev/null +++ b/src/lib/api/library/stats.ts @@ -0,0 +1,109 @@ +/** + * Player stats (local listening history) + genre/catalog aggregate reads. Split + * out of the former single `lib/api/library.ts`; re-exported via the + * `@/lib/api/library` barrel. + */ +import { invoke } from '@tauri-apps/api/core'; +import { serverIndexKeyForId, mapServerIdFromIndexKey } from './internal'; +import type { + CatalogYearBounds, + GenreAlbumCountRow, + LibraryGenreAlbumsRequest, + LibraryGenreAlbumsResponse, + PlaySessionInput, + PlaySessionYearSummary, + PlaySessionHeatmapDay, + PlaySessionDayDetail, + PlaySessionYearBounds, + PlaySessionRecentDay, + PlaySessionRecentTrack, +} from './dto'; + +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, + }); +} + +/** 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 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 }); +} + +export function libraryGetRecentPlaySessions(args?: { + limit?: number; + sinceMs?: number; +}): Promise { + return invoke('library_get_recent_play_sessions', { + limit: args?.limit, + sinceMs: args?.sinceMs, + }).then(rows => + rows.map(row => ({ + ...row, + serverId: mapServerIdFromIndexKey(row.serverId), + })), + ); +} diff --git a/src/lib/api/library/sync.ts b/src/lib/api/library/sync.ts new file mode 100644 index 00000000..a55387bc --- /dev/null +++ b/src/lib/api/library/sync.ts @@ -0,0 +1,124 @@ +/** + * Session + lifecycle commands (PR-5b) — bind/clear sync sessions, start/cancel + * syncs, mutate tracks, write artifacts/facts, purge. Split out of the former + * single `lib/api/library.ts`; re-exported via the `@/lib/api/library` barrel. + */ +import { invoke } from '@tauri-apps/api/core'; +import { serverIndexKeyForId } from './internal'; +import type { + PlaybackHint, + SyncMode, + SyncJobDto, + PurgeReportDto, + ArtifactInputDto, + FactInputDto, +} from './dto'; + +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 function libraryGetPlaybackHint(): Promise { + return invoke('library_get_playback_hint'); +} + +export function librarySetPlaybackHint(hint: PlaybackHint): Promise { + return invoke('library_set_playback_hint', { hint }); +} + +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 }); +}