mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
refactor(lib): split api/library god-module into concern modules behind a barrel
The 945-LOC lib/api/library.ts (cucadmuh's deliberate 'one thin file' library_*
command surface) exceeded the ~500 LOC ceiling. Split by its own PR-5 section
comments into library/{dto,reads,sync,stats,events}.ts + a private library/internal.ts
(the shared serverId↔indexKey helpers). lib/api/library.ts is now a barrel that
re-exports all of them, so @/lib/api/library stays the single import point — every
consumer (incl. the Settings LibraryTab) and all 11 vi.mock('@/lib/api/library')
test factories are unchanged. Pure reorganization, no logic change; tests pass
unmodified. Largest module is now dto.ts (466, pure types).
This commit is contained in:
+12
-942
@@ -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<SyncStateDto> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<SyncStateDto>('library_get_status', { serverId: indexKey, libraryScope })
|
||||
.then(status => ({ ...status, serverId }));
|
||||
}
|
||||
|
||||
export function librarySearch(
|
||||
serverId: string,
|
||||
query: string,
|
||||
options?: { limit?: number; offset?: number; libraryScope?: string },
|
||||
): Promise<LibraryTracksEnvelope> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<LibraryTracksEnvelope>('library_search', {
|
||||
serverId: indexKey,
|
||||
query,
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
libraryScope: options?.libraryScope,
|
||||
}).then(envelope => ({
|
||||
...envelope,
|
||||
tracks: mapTracksServerId(envelope.tracks, serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Search against the local index (§5.13). The frontend fallback
|
||||
* (PR-7 F2) decides local vs network and maps the same `LibraryFilterClause`
|
||||
* shape onto the network path; this wrapper only talks to the local builder.
|
||||
*/
|
||||
export function libraryAdvancedSearch(
|
||||
request: LibraryAdvancedSearchRequest,
|
||||
): Promise<LibraryAdvancedSearchResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryAdvancedSearchResponse>('library_advanced_search', {
|
||||
request: { ...request, serverId: indexKey },
|
||||
}).then(response => ({
|
||||
...response,
|
||||
artists: response.artists.map(artist => ({
|
||||
...artist,
|
||||
serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId),
|
||||
})),
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
tracks: mapTracksServerId(response.tracks, request.serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryLiveSearchResponse {
|
||||
artists: LibraryArtistDto[];
|
||||
albums: LibraryAlbumDto[];
|
||||
tracks: LibraryTrackDto[];
|
||||
source: 'local' | 'network' | 'mixed';
|
||||
}
|
||||
|
||||
/** Live Search dropdown — one lean FTS query (§5.9), not Advanced Search. */
|
||||
export interface LibraryLiveSearchRequest {
|
||||
serverId: string;
|
||||
query: string;
|
||||
/** Subsonic `musicFolderId` / Navidrome library id — omit for all libraries. */
|
||||
libraryScope?: string | null;
|
||||
artistLimit?: number;
|
||||
albumLimit?: number;
|
||||
songLimit?: number;
|
||||
/** UI generation — stale Rust FTS passes are dropped server-side. */
|
||||
requestEpoch?: number;
|
||||
}
|
||||
|
||||
export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<LibraryLiveSearchResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryLiveSearchResponse>('library_live_search', {
|
||||
request: { ...request, serverId: indexKey },
|
||||
}).then(response => ({
|
||||
...response,
|
||||
artists: response.artists.map(artist => ({
|
||||
...artist,
|
||||
serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId),
|
||||
})),
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
tracks: mapTracksServerId(response.tracks, request.serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryLosslessAlbumsRequest {
|
||||
serverId: string;
|
||||
libraryScope?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface LibraryLosslessAlbumsResponse {
|
||||
albums: LibraryAlbumDto[];
|
||||
hasMore: boolean;
|
||||
source: 'local';
|
||||
}
|
||||
|
||||
/** Paginated lossless albums from the local track index. */
|
||||
export function libraryListLosslessAlbums(
|
||||
request: LibraryLosslessAlbumsRequest,
|
||||
): Promise<LibraryLosslessAlbumsResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryLosslessAlbumsResponse>('library_list_lossless_albums', {
|
||||
request: {
|
||||
serverId: indexKey,
|
||||
libraryScope: request.libraryScope ?? undefined,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
},
|
||||
}).then(response => ({
|
||||
...response,
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryArtistLosslessBrowseRequest {
|
||||
serverId: string;
|
||||
artistId: string;
|
||||
libraryScope?: string | null;
|
||||
}
|
||||
|
||||
export interface LibraryArtistLosslessBrowseResponse {
|
||||
albums: LibraryAlbumDto[];
|
||||
tracks: LibraryTrackDto[];
|
||||
source: 'local';
|
||||
}
|
||||
|
||||
/** Lossless albums + tracks for one artist from the local index. */
|
||||
export function libraryGetArtistLosslessBrowse(
|
||||
request: LibraryArtistLosslessBrowseRequest,
|
||||
): Promise<LibraryArtistLosslessBrowseResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryArtistLosslessBrowseResponse>('library_get_artist_lossless_browse', {
|
||||
request: {
|
||||
serverId: indexKey,
|
||||
artistId: request.artistId,
|
||||
libraryScope: request.libraryScope ?? undefined,
|
||||
},
|
||||
}).then(response => ({
|
||||
...response,
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
tracks: mapTracksServerId(response.tracks, request.serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */
|
||||
export function librarySearchCrossServer(args: {
|
||||
query: string;
|
||||
limit?: number;
|
||||
servers?: string[];
|
||||
}): Promise<LibraryCrossServerSearchResponse> {
|
||||
const indexServers = args.servers?.map(serverIndexKeyForId);
|
||||
return invoke<LibraryCrossServerSearchResponse>('library_search_cross_server', {
|
||||
...args,
|
||||
servers: indexServers,
|
||||
}).then(response => ({
|
||||
...response,
|
||||
hits: mapTracksServerId(response.hits),
|
||||
fuzzy: mapTracksServerId(response.fuzzy),
|
||||
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function libraryGetTrack(
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
): Promise<LibraryTrackDto | null> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<LibraryTrackDto | null>('library_get_track', { serverId: indexKey, trackId })
|
||||
.then(track => (track ? { ...track, serverId } : track));
|
||||
}
|
||||
|
||||
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
|
||||
export function libraryUpsertSongsFromApi(
|
||||
serverId: string,
|
||||
songs: unknown[],
|
||||
): Promise<number> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
|
||||
}
|
||||
|
||||
/** `library_get_tracks_batch` cap (spec §7.1). */
|
||||
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
|
||||
|
||||
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
const indexKeyMap = new Map<string, string>();
|
||||
const remapped = refs.map(ref => {
|
||||
const indexKey = serverIndexKeyForId(ref.serverId);
|
||||
if (!indexKeyMap.has(indexKey)) indexKeyMap.set(indexKey, ref.serverId);
|
||||
return { ...ref, serverId: indexKey };
|
||||
});
|
||||
return invoke<LibraryTrackDto[]>('library_get_tracks_batch', { refs: remapped })
|
||||
.then(tracks => tracks.map(track => ({
|
||||
...track,
|
||||
serverId: mapServerIdFromIndexKey(track.serverId, indexKeyMap.get(track.serverId)),
|
||||
})));
|
||||
}
|
||||
|
||||
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
|
||||
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
if (refs.length === 0) return [];
|
||||
const out: LibraryTrackDto[] = [];
|
||||
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
|
||||
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
|
||||
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
|
||||
out.push(...batch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function libraryGetTracksByAlbum(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<LibraryTrackDto[]> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<LibraryTrackDto[]>('library_get_tracks_by_album', { serverId: indexKey, albumId })
|
||||
.then(tracks => mapTracksServerId(tracks, serverId));
|
||||
}
|
||||
|
||||
export function libraryGetArtifact(
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
artifactKind: string,
|
||||
options?: { sourceKind?: string; sourceId?: string; format?: string },
|
||||
): Promise<TrackArtifactDto | null> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<TrackArtifactDto | null>('library_get_artifact', {
|
||||
serverId: indexKey,
|
||||
trackId,
|
||||
artifactKind,
|
||||
sourceKind: options?.sourceKind,
|
||||
sourceId: options?.sourceId,
|
||||
format: options?.format,
|
||||
}).then(artifact => (artifact ? { ...artifact, serverId } : artifact));
|
||||
}
|
||||
|
||||
export function libraryGetFacts(
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
factKinds?: string[],
|
||||
): Promise<TrackFactDto[]> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<TrackFactDto[]>('library_get_facts', { serverId: indexKey, trackId, factKinds })
|
||||
.then(facts => facts.map(fact => ({ ...fact, serverId })));
|
||||
}
|
||||
|
||||
export function libraryGetOfflinePath(
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
): Promise<OfflinePathDto> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<OfflinePathDto>('library_get_offline_path', { serverId: indexKey, trackId })
|
||||
.then(path => ({ ...path, serverId }));
|
||||
}
|
||||
|
||||
// ── Session + lifecycle (PR-5b) ───────────────────────────────────────
|
||||
|
||||
export function librarySyncBindSession(args: {
|
||||
serverId: string;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
libraryScope?: string;
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_sync_bind_session', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
export function librarySyncClearSession(serverId: string): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<void>('library_sync_clear_session', { serverId: indexKey });
|
||||
}
|
||||
|
||||
export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active';
|
||||
|
||||
export function libraryGetPlaybackHint(): Promise<PlaybackHint> {
|
||||
return invoke<PlaybackHint>('library_get_playback_hint');
|
||||
}
|
||||
|
||||
export function librarySetPlaybackHint(hint: PlaybackHint): Promise<void> {
|
||||
return invoke<void>('library_set_playback_hint', { hint });
|
||||
}
|
||||
|
||||
export type SyncMode = 'full' | 'delta';
|
||||
|
||||
export function librarySyncStart(args: {
|
||||
serverId: string;
|
||||
mode: SyncMode;
|
||||
libraryScope?: string;
|
||||
}): Promise<SyncJobDto> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<SyncJobDto>('library_sync_start', { ...args, serverId: indexKey })
|
||||
.then(job => ({ ...job, serverId: args.serverId }));
|
||||
}
|
||||
|
||||
/** Forced full-budget tombstone delta — Settings → «Verify integrity». */
|
||||
export function librarySyncVerifyIntegrity(args: {
|
||||
serverId: string;
|
||||
libraryScope?: string;
|
||||
}): Promise<SyncJobDto> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<SyncJobDto>('library_sync_verify_integrity', { ...args, serverId: indexKey })
|
||||
.then(job => ({ ...job, serverId: args.serverId }));
|
||||
}
|
||||
|
||||
export function librarySyncCancel(jobId?: string): Promise<void> {
|
||||
return invoke<void>('library_sync_cancel', { jobId });
|
||||
}
|
||||
|
||||
export function libraryPatchTrack(args: {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
patch: {
|
||||
starredAt?: number | null;
|
||||
userRating?: number | null;
|
||||
playCount?: number | null;
|
||||
playedAt?: number | null;
|
||||
/** E2: playback-derived `md5_16kb` content fingerprint. Normally written
|
||||
* by the Rust analysis bridge; exposed here for contract completeness. */
|
||||
contentHash?: string | null;
|
||||
};
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_patch_track', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
/** Server favorites → `album.starred_at` (UPDATE only, no stub rows). */
|
||||
export function libraryReconcileAlbumStars(args: {
|
||||
serverId: string;
|
||||
starredAlbums: Array<{ id: string; starredAt: number }>;
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_reconcile_album_stars', {
|
||||
serverId: indexKey,
|
||||
starredAlbums: args.starredAlbums.map(a => ({ id: a.id, starredAt: a.starredAt })),
|
||||
});
|
||||
}
|
||||
|
||||
export function libraryPutArtifact(args: {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
artifact: ArtifactInputDto;
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_put_artifact', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
export function libraryPutFact(args: {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
fact: FactInputDto;
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_put_fact', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
export function libraryPurgeServer(args: {
|
||||
serverId: string;
|
||||
includeAnalysis?: boolean;
|
||||
includeOffline?: boolean;
|
||||
}): Promise<PurgeReportDto> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<PurgeReportDto>('library_purge_server', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
export function libraryDeleteServerData(serverId: string): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<void>('library_delete_server_data', { serverId: indexKey });
|
||||
}
|
||||
|
||||
// ── Player stats (local listening history) ────────────────────────────
|
||||
|
||||
export type PlaySessionEndReason = 'ended' | 'skip' | 'stop' | 'switch' | 'close';
|
||||
|
||||
export type PlaySessionInput = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
startedAtMs: number;
|
||||
listenedSec: number;
|
||||
positionMaxSec: number;
|
||||
endReason: PlaySessionEndReason;
|
||||
/** Player-known track duration when the library index has none. */
|
||||
durationSecHint?: number;
|
||||
};
|
||||
|
||||
export type PlaySessionYearSummary = {
|
||||
totalListenedSec: number;
|
||||
sessionCount: number;
|
||||
trackPlayCount: number;
|
||||
uniqueTrackCount: number;
|
||||
listeningDayCount: number;
|
||||
fullCount: number;
|
||||
partialCount: number;
|
||||
};
|
||||
|
||||
export type PlaySessionHeatmapDay = {
|
||||
date: string;
|
||||
trackPlayCount: number;
|
||||
};
|
||||
|
||||
export type PlaySessionDayTrack = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
listenedSec: number;
|
||||
completion: 'partial' | 'full';
|
||||
startedAtMs: number;
|
||||
};
|
||||
|
||||
export type PlaySessionDayDetail = {
|
||||
totals: {
|
||||
totalListenedSec: number;
|
||||
sessionCount: number;
|
||||
trackPlayCount: number;
|
||||
fullCount: number;
|
||||
partialCount: number;
|
||||
};
|
||||
tracks: PlaySessionDayTrack[];
|
||||
};
|
||||
|
||||
export type PlaySessionYearBounds = {
|
||||
minYear: number | null;
|
||||
maxYear: number | null;
|
||||
};
|
||||
|
||||
export type CatalogYearBounds = {
|
||||
minYear: number | null;
|
||||
maxYear: number | null;
|
||||
};
|
||||
|
||||
export type GenreAlbumCountRow = {
|
||||
value: string;
|
||||
albumCount: number;
|
||||
songCount: number;
|
||||
};
|
||||
|
||||
export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise<CatalogYearBounds> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<CatalogYearBounds>('library_get_catalog_year_bounds', {
|
||||
serverId: indexKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function libraryGetGenreAlbumCounts(args: {
|
||||
serverId: string;
|
||||
libraryScope?: string;
|
||||
}): Promise<GenreAlbumCountRow[]> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
|
||||
serverId: indexKey,
|
||||
libraryScope: args.libraryScope,
|
||||
});
|
||||
}
|
||||
|
||||
export type LibraryGenreAlbumsRequest = {
|
||||
serverId: string;
|
||||
genre: string;
|
||||
libraryScope?: string | null;
|
||||
sort?: LibrarySortClause[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeTotal?: boolean;
|
||||
};
|
||||
|
||||
export type LibraryGenreAlbumsResponse = {
|
||||
albums: LibraryAlbumDto[];
|
||||
hasMore: boolean;
|
||||
total?: number | null;
|
||||
source: 'local';
|
||||
};
|
||||
|
||||
/** Paginated albums for one genre from the local track index. */
|
||||
export function libraryListAlbumsByGenre(
|
||||
request: LibraryGenreAlbumsRequest,
|
||||
): Promise<LibraryGenreAlbumsResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryGenreAlbumsResponse>('library_list_albums_by_genre', {
|
||||
request: {
|
||||
serverId: indexKey,
|
||||
genre: request.genre,
|
||||
libraryScope: request.libraryScope ?? undefined,
|
||||
sort: request.sort ?? [],
|
||||
limit: request.limit ?? 50,
|
||||
offset: request.offset ?? 0,
|
||||
includeTotal: request.includeTotal ?? false,
|
||||
},
|
||||
}).then(response => ({
|
||||
...response,
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export type PlaySessionRecentDay = {
|
||||
date: string;
|
||||
totalListenedSec: number;
|
||||
sessionCount: number;
|
||||
trackPlayCount: number;
|
||||
fullCount: number;
|
||||
partialCount: number;
|
||||
};
|
||||
|
||||
export function libraryRecordPlaySession(input: PlaySessionInput): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(input.serverId);
|
||||
return invoke<void>('library_record_play_session', { input: { ...input, serverId: indexKey } });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsYearSummary(year: number): Promise<PlaySessionYearSummary> {
|
||||
return invoke<PlaySessionYearSummary>('library_get_player_stats_year_summary', { year });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsHeatmap(year: number): Promise<PlaySessionHeatmapDay[]> {
|
||||
return invoke<PlaySessionHeatmapDay[]>('library_get_player_stats_heatmap', { year });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise<PlaySessionDayDetail> {
|
||||
return invoke<PlaySessionDayDetail>('library_get_player_stats_day_detail', { dateIso })
|
||||
.then(detail => ({
|
||||
...detail,
|
||||
tracks: detail.tracks.map(track => ({
|
||||
...track,
|
||||
serverId: mapServerIdFromIndexKey(track.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsYearBounds(): Promise<PlaySessionYearBounds> {
|
||||
return invoke<PlaySessionYearBounds>('library_get_player_stats_year_bounds');
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySessionRecentDay[]> {
|
||||
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
|
||||
}
|
||||
|
||||
export type PlaySessionRecentTrack = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
albumId: string | null;
|
||||
coverArtId: string | null;
|
||||
startedAtMs: number;
|
||||
listenedSec: number;
|
||||
completion: 'partial' | 'full';
|
||||
};
|
||||
|
||||
export function libraryGetRecentPlaySessions(args?: {
|
||||
limit?: number;
|
||||
sinceMs?: number;
|
||||
}): Promise<PlaySessionRecentTrack[]> {
|
||||
return invoke<PlaySessionRecentTrack[]>('library_get_recent_play_sessions', {
|
||||
limit: args?.limit,
|
||||
sinceMs: args?.sinceMs,
|
||||
}).then(rows =>
|
||||
rows.map(row => ({
|
||||
...row,
|
||||
serverId: mapServerIdFromIndexKey(row.serverId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event subscriptions ───────────────────────────────────────────────
|
||||
|
||||
export interface LibrarySyncProgressPayload {
|
||||
serverId: string;
|
||||
libraryScope: string;
|
||||
/** 'phase_changed' | 'ingest_page' | 'remapped' | 'tombstoned' | 'completed' | 'error' */
|
||||
kind: string;
|
||||
phase?: string | null;
|
||||
ingestedTotal?: number | null;
|
||||
batchCount?: number | null;
|
||||
remappedCount?: number | null;
|
||||
tombstonesChecked?: number | null;
|
||||
tombstonesDeleted?: number | null;
|
||||
completedKind?: string | null;
|
||||
message?: string | null;
|
||||
/** S1 per-batch timings from the Rust ingest runner (when available). */
|
||||
ingestMetrics?: IngestBatchMetrics | null;
|
||||
}
|
||||
|
||||
export interface IngestBatchMetrics {
|
||||
offset: number;
|
||||
strategy: string;
|
||||
fetchMs: number;
|
||||
writeMs: number;
|
||||
lockWaitMs: number;
|
||||
sqlExecMs: number;
|
||||
persistMs: number;
|
||||
rowCount: number;
|
||||
bulkIngestActive: boolean;
|
||||
}
|
||||
|
||||
export interface LibrarySyncIdlePayload {
|
||||
serverId: string;
|
||||
libraryScope: string;
|
||||
kind: string; // 'initial_sync' | 'delta_sync'
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function subscribeLibrarySyncProgress(
|
||||
handler: (payload: LibrarySyncProgressPayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<LibrarySyncProgressPayload>('library:sync-progress', ({ payload }) =>
|
||||
handler(payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeLibrarySyncIdle(
|
||||
handler: (payload: LibrarySyncIdlePayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<LibrarySyncIdlePayload>('library:sync-idle', ({ payload }) =>
|
||||
handler(payload),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Genre tags startup backfill (multi-genre local index) ───────────────
|
||||
|
||||
export interface GenreTagsInspectDto {
|
||||
needed: boolean;
|
||||
totalTracks: number;
|
||||
doneTracks: number;
|
||||
}
|
||||
|
||||
export interface GenreTagsProgressEvent {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
|
||||
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
|
||||
}
|
||||
|
||||
export function libraryGenreTagsRun(): Promise<void> {
|
||||
return invoke<void>('library_genre_tags_run');
|
||||
}
|
||||
export * from './library/dto';
|
||||
export * from './library/reads';
|
||||
export * from './library/sync';
|
||||
export * from './library/stats';
|
||||
export * from './library/events';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<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),
|
||||
);
|
||||
}
|
||||
|
||||
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
|
||||
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
|
||||
}
|
||||
|
||||
export function libraryGenreTagsRun(): Promise<void> {
|
||||
return invoke<void>('library_genre_tags_run');
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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<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 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),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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 }));
|
||||
}
|
||||
@@ -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<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,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 function libraryRecordPlaySession(input: PlaySessionInput): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(input.serverId);
|
||||
return invoke<void>('library_record_play_session', { input: { ...input, serverId: indexKey } });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsYearSummary(year: number): Promise<PlaySessionYearSummary> {
|
||||
return invoke<PlaySessionYearSummary>('library_get_player_stats_year_summary', { year });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsHeatmap(year: number): Promise<PlaySessionHeatmapDay[]> {
|
||||
return invoke<PlaySessionHeatmapDay[]>('library_get_player_stats_heatmap', { year });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise<PlaySessionDayDetail> {
|
||||
return invoke<PlaySessionDayDetail>('library_get_player_stats_day_detail', { dateIso })
|
||||
.then(detail => ({
|
||||
...detail,
|
||||
tracks: detail.tracks.map(track => ({
|
||||
...track,
|
||||
serverId: mapServerIdFromIndexKey(track.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsYearBounds(): Promise<PlaySessionYearBounds> {
|
||||
return invoke<PlaySessionYearBounds>('library_get_player_stats_year_bounds');
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySessionRecentDay[]> {
|
||||
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
|
||||
}
|
||||
|
||||
export function libraryGetRecentPlaySessions(args?: {
|
||||
limit?: number;
|
||||
sinceMs?: number;
|
||||
}): Promise<PlaySessionRecentTrack[]> {
|
||||
return invoke<PlaySessionRecentTrack[]>('library_get_recent_play_sessions', {
|
||||
limit: args?.limit,
|
||||
sinceMs: args?.sinceMs,
|
||||
}).then(rows =>
|
||||
rows.map(row => ({
|
||||
...row,
|
||||
serverId: mapServerIdFromIndexKey(row.serverId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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<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 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 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user