ac6b76bf98
"Start radio" in the track menu seeds the queue from a track; RadioController
(headless, in AppShell) tops it up via /radio/next as it drains. QueuePanel's
radio scaffolding is now live: the exploration slider drives familiar<->new and
each entry shows its reason ("why this is playing"). Adds the radio endpoints
(start/next/similar), radio context + actions on the queue slice, and the pick->
entry mapper. Degrades to metadata picks while ML is offline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
324 lines
8.2 KiB
TypeScript
324 lines
8.2 KiB
TypeScript
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<string, number>;
|
|
bySource: Record<string, number>;
|
|
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<T> {
|
|
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;
|
|
}
|