export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing'; /** * Metadata-enrichment state, distinct from file `availability`. `pending` = the * worker hasn't finished (or hasn't started); `enriched` = identity found; * `failed` = no match / a worker error (see `metadataError`); `manual` = user- * edited and never auto-overwritten. */ export type MetadataStatus = 'pending' | 'enriched' | 'failed' | 'manual'; export interface Track { id: string; title: string; artistId: string; artistName: string; albumId: string; albumTitle: string; albumArtUrl?: string; hasCover: boolean; durationMs: number; trackNumber?: number; discNumber?: number; year?: number; genre?: string; availability: TrackAvailability; metadataStatus: MetadataStatus; /** Human-readable reason the last enrichment run set `failed`; else undefined. */ metadataError?: string; fileSize?: number; format?: string; bitrate?: number; liked: boolean; /** Where the track entered the library (e.g. `upload`, `local_folder`). */ source?: string; /** ISO timestamp the track was added to the library. */ createdAt?: string; /** ISO timestamp the last successful enrichment ran; undefined if never. */ enrichedAt?: string; } export interface Album { id: string; title: string; artistId: string; artistName: string; artUrl?: string; /** Whether the album has cover art served by `GET /albums/{id}/cover`. */ hasCover: boolean; year?: number; trackCount: number; totalDurationMs: number; genre?: string; } export interface Artist { id: string; name: string; artUrl?: string; albumCount: number; trackCount: number; } export interface Playlist { id: string; name: string; description?: string; ownerId: string; trackCount: number; totalDurationMs: number; artUrl?: string; isPublic: boolean; createdAt: string; updatedAt: string; } export interface PlaylistTrack extends Track { position: number; addedAt: string; } /** Lifecycle of a download job, mirroring the backend's `DownloadStatus`. * `enriching` = file fetched, metadata pipeline running; `done` = imported. */ export type DownloadStatus = | 'queued' | 'downloading' | 'enriching' | 'done' | 'failed'; export interface DownloadJob { id: string; /** Source backend the job pulls from (e.g. `youtube`). */ source: string; /** Stable per-source id of the item (e.g. a YouTube videoId). */ sourceId?: string; /** The free-text query the job was created from, for display. */ query?: string; status: DownloadStatus; /** Fraction complete, 0..1. */ progress: number; errorMessage?: string; /** Set once the download finishes and the library track exists. */ trackId?: string; retryCount: number; createdAt: string; updatedAt: string; } /** Result of POST /downloads. Either the item is already in the library * (`alreadyInLibrary`, `trackId` set), or a job covers it (`job`). */ export interface DownloadRequestResult { alreadyInLibrary: boolean; trackId?: string; job?: DownloadJob; } /** One hit from an external (fetch) source — the §A4 discover screen. */ export interface ExternalSearchResult { source: string; sourceId: string; title: string; artist?: string; album?: string; durationMs?: number; thumbnailUrl?: string; } /** A registered source backend, from GET /sources. `kind`: `indexable` (a * mounted folder) or `fetch` (searchable + downloadable, e.g. YouTube). */ export interface SourceInfo { name: string; label: string; kind: 'indexable' | 'fetch'; available: boolean; } export interface UploadResponse { track_id: string; title: string; already_exists: boolean; } /** One backing-dependency probe result (GET /admin/services). `skipped` = the * dependency is optional and not configured (e.g. ML with no ML_SERVICE_URL). */ export type ServiceStatus = 'ok' | 'down' | 'skipped'; export interface ServicesStatus { database: ServiceStatus; redis: ServiceStatus; ml: ServiceStatus; } export interface StorageFormatBreakdown { fileFormat: string; trackCount: number; totalSize: number; } export interface StorageGenreCount { genre: string; trackCount: number; } /** Capacity of the volume backing the media store. Absent for object-store * backends (S3), which have no fixed disk to report. */ export interface StorageDiskUsage { total: number; used: number; free: number; } export interface StorageStats { totalTracks: number; totalArtists: number; totalAlbums: number; /** Sum of every track's recorded file size (the library's footprint). */ totalSize: number; totalDurationSeconds: number; largestTrackSize: number; earliestAdded?: string; latestAdded?: string; byFormat: StorageFormatBreakdown[]; byMetadataStatus: Record; bySource: Record; topGenres: StorageGenreCount[]; disk?: StorageDiskUsage; } /** A set of tracks sharing one acoustic fingerprint — de-dup candidates (§A6). */ export interface DuplicateGroup { fingerprint: string; tracks: Track[]; } /** One radio/similarity pick with its reason code (§6.5). `reason`: `ml` | * `similar` | `from_likes` | `discover` — localized client-side. */ export interface RadioPick { track: Track; reason: string; } /** Radio / similar response: where the picks came from + the picks. `source`: * `ml` (recommender) or `metadata` (fallback heuristics). */ export interface RadioResult { source: string; picks: RadioPick[]; } /** Cached lyrics for a track (§6.7). `not_found` is a normal state, not an * error. `synced` is raw LRC (client parses timestamps); `plain` is fallback. */ export interface Lyrics { trackId: string; status: 'found' | 'not_found' | 'pending'; source?: string; synced?: string; plain?: string; syncedAvailable: boolean; } export interface User { id: string; username: string; email?: string; role: 'admin' | 'user'; isActive: boolean; createdAt: string; lastActiveAt?: string; } export interface AuthTokens { accessToken: string; refreshToken: string; // Optional: the backend's TokenResponse carries no TTL — expiry is driven by // 401→refresh, not a client-side clock. Present only if a backend supplies it. expiresIn?: number; } export interface LoginRequest { username: string; password: string; } export interface LoginResponse { user: User; tokens: AuthTokens; } export interface RegisterRequest { username: string; password: string; } export interface PaginatedResponse { items: T[]; total: number; page: number; pageSize: number; hasMore: boolean; } export interface LibraryFilters { search?: string; genre?: string; artistId?: string; albumId?: string; /** Filter by ingest origin, e.g. `upload`, `youtube`, `local`. */ source?: string; liked?: boolean; page?: number; pageSize?: number; sortBy?: 'title' | 'artist' | 'album' | 'year' | 'dateAdded'; sortOrder?: 'asc' | 'desc'; } export interface ApiError { status: number; message: string; code?: string; } /** One AcoustID candidate from `GET /tracks/{id}/metadata/matches` (§A7). */ export interface MetadataMatch { acoustid: string; /** Confidence 0..1. */ score: number; recordingMbid?: string; releaseGroupMbid?: string; title?: string; artist?: string; album?: string; year?: number; } /** Manual edits / an accepted match, sent to `PUT /tracks/{id}/metadata`. */ export interface MetadataEdit { title?: string; artistName?: string; albumTitle?: string; year?: number; genre?: string; trackNumber?: number; } // -- user settings (`/settings`) --------------------------------------------- export type ThemePref = 'system' | 'light' | 'dark'; export type StreamQuality = 'original' | 'high' | 'medium' | 'low'; /** General per-user preferences from `GET /settings`. */ export interface AppSettings { theme: ThemePref; streamQuality: StreamQuality; } export type ScrobbleProvider = 'lastfm' | 'listenbrainz'; /** Scrobbling config from `GET /settings/scrobbling`. The session key is * write-only server-side, so it never appears here — only `configured`. */ export interface ScrobblingConfig { enabled: boolean; provider: ScrobbleProvider | null; username: string | null; configured: boolean; }