feat(discover): wire A4 search + A5 downloads to backend
Adds DownloadJob/ExternalSearchResult/SourceInfo contract types + mappers, the downloads + search RTKQ endpoints, and the SearchDownloadPage (search external sources, per-result download states) and DownloadsManagerPage (active/history, progress, retry/cancel, poll-while-active). en/ru i18n. Snapshot also bundles in-progress queue/metadata-editor/storage UI work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,88 @@
|
||||
import { api } from '../index';
|
||||
import type { DownloadJob } from '../types';
|
||||
import {
|
||||
toDownloadJob,
|
||||
toPage,
|
||||
type RawDownloadJob,
|
||||
type RawPaged,
|
||||
} from '../mappers';
|
||||
import type {
|
||||
DownloadJob,
|
||||
DownloadRequestResult,
|
||||
PaginatedResponse,
|
||||
} from '../types';
|
||||
|
||||
// NOTE: the backend `/downloads` routes are still unimplemented stubs (they
|
||||
// return no body / no schema). The request shapes below are provisional and the
|
||||
// responses will need the same snake→camel mapper treatment as library/playlists
|
||||
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not
|
||||
// wire these into the UI until then.
|
||||
interface ListParams {
|
||||
status?: DownloadJob['status'];
|
||||
/** Only the current user's jobs (backend `mine=true`). */
|
||||
mine?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
interface RawCreateResponse {
|
||||
already_in_library: boolean;
|
||||
track_id: string | null;
|
||||
job: RawDownloadJob | null;
|
||||
}
|
||||
|
||||
export const downloadsApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getDownloads: build.query<
|
||||
DownloadJob[],
|
||||
{ status?: DownloadJob['status'] } | void
|
||||
PaginatedResponse<DownloadJob>,
|
||||
ListParams | void
|
||||
>({
|
||||
query: (params) => ({ url: '/downloads', params: params ?? {} }),
|
||||
providesTags: ['Download'],
|
||||
query: (params) => {
|
||||
const p = params ?? {};
|
||||
const size = p.pageSize ?? 50;
|
||||
return {
|
||||
url: '/downloads',
|
||||
params: {
|
||||
status: p.status,
|
||||
mine: p.mine ? true : undefined,
|
||||
limit: size,
|
||||
offset: ((p.page ?? 1) - 1) * size,
|
||||
},
|
||||
};
|
||||
},
|
||||
transformResponse: (raw: RawPaged<RawDownloadJob>) =>
|
||||
toPage(raw, toDownloadJob),
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? [
|
||||
...result.items.map(({ id }) => ({
|
||||
type: 'Download' as const,
|
||||
id,
|
||||
})),
|
||||
'Download',
|
||||
]
|
||||
: ['Download'],
|
||||
}),
|
||||
addDownload: build.mutation<
|
||||
DownloadJob,
|
||||
{
|
||||
url: string;
|
||||
metadata?: { title?: string; artist?: string; album?: string };
|
||||
}
|
||||
getDownload: build.query<DownloadJob, string>({
|
||||
query: (id) => `/downloads/${id}`,
|
||||
transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
|
||||
providesTags: (_r, _e, id) => [{ type: 'Download', id }],
|
||||
}),
|
||||
/** Request a download of a discovered item (§A4 "Download to library"). */
|
||||
createDownload: build.mutation<
|
||||
DownloadRequestResult,
|
||||
{ source: string; sourceId: string; query?: string }
|
||||
>({
|
||||
query: (body) => ({ url: '/downloads', method: 'POST', body }),
|
||||
invalidatesTags: ['Download'],
|
||||
query: (body) => ({
|
||||
url: '/downloads',
|
||||
method: 'POST',
|
||||
body: {
|
||||
source: body.source,
|
||||
source_id: body.sourceId,
|
||||
query: body.query,
|
||||
},
|
||||
}),
|
||||
transformResponse: (raw: RawCreateResponse): DownloadRequestResult => ({
|
||||
alreadyInLibrary: raw.already_in_library,
|
||||
trackId: raw.track_id ?? undefined,
|
||||
job: raw.job ? toDownloadJob(raw.job) : undefined,
|
||||
}),
|
||||
// A completed dedup can surface an existing library track; refresh both.
|
||||
invalidatesTags: ['Download', 'Track'],
|
||||
}),
|
||||
cancelDownload: build.mutation<void, string>({
|
||||
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
|
||||
@@ -32,7 +90,8 @@ export const downloadsApi = api.injectEndpoints({
|
||||
}),
|
||||
retryDownload: build.mutation<DownloadJob, string>({
|
||||
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
|
||||
invalidatesTags: ['Download'],
|
||||
transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
|
||||
invalidatesTags: (_r, _e, id) => [{ type: 'Download', id }, 'Download'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
@@ -40,7 +99,8 @@ export const downloadsApi = api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetDownloadsQuery,
|
||||
useAddDownloadMutation,
|
||||
useGetDownloadQuery,
|
||||
useCreateDownloadMutation,
|
||||
useCancelDownloadMutation,
|
||||
useRetryDownloadMutation,
|
||||
} = downloadsApi;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { api } from '../index';
|
||||
import {
|
||||
toSearchResult,
|
||||
toSourceInfo,
|
||||
type RawSearchResult,
|
||||
type RawSourceInfo,
|
||||
} from '../mappers';
|
||||
import type { ExternalSearchResult, SourceInfo } from '../types';
|
||||
|
||||
interface RawSearchResponse {
|
||||
results: RawSearchResult[];
|
||||
searched_sources: string[];
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: ExternalSearchResult[];
|
||||
/** Names of the sources actually queried (available ones). */
|
||||
searchedSources: string[];
|
||||
}
|
||||
|
||||
const mapSearch = (raw: RawSearchResponse): SearchResponse => ({
|
||||
results: raw.results.map(toSearchResult),
|
||||
searchedSources: raw.searched_sources,
|
||||
});
|
||||
|
||||
export const searchApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
/** Registered source backends (for the §A4 source picker). */
|
||||
getSources: build.query<SourceInfo[], void>({
|
||||
query: () => '/sources',
|
||||
transformResponse: (raw: RawSourceInfo[]) => raw.map(toSourceInfo),
|
||||
}),
|
||||
/** Search across every available fetch source (no `source` → aggregate). */
|
||||
searchExternal: build.query<
|
||||
SearchResponse,
|
||||
{ q: string; source?: string; limit?: number }
|
||||
>({
|
||||
query: ({ q, source, limit }) =>
|
||||
source
|
||||
? { url: `/sources/${source}/search`, params: { q, limit } }
|
||||
: { url: '/search', params: { q, limit } },
|
||||
transformResponse: mapSearch,
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useGetSourcesQuery, useLazySearchExternalQuery } = searchApi;
|
||||
@@ -14,10 +14,14 @@
|
||||
import type {
|
||||
Album,
|
||||
Artist,
|
||||
DownloadJob,
|
||||
DownloadStatus,
|
||||
ExternalSearchResult,
|
||||
MetadataMatch,
|
||||
MetadataStatus,
|
||||
PaginatedResponse,
|
||||
Playlist,
|
||||
SourceInfo,
|
||||
StorageStats,
|
||||
Track,
|
||||
User,
|
||||
@@ -246,6 +250,86 @@ export const toStorageStats = (r: RawStorageStats): StorageStats => ({
|
||||
disk: r.disk ?? undefined,
|
||||
});
|
||||
|
||||
// ---- downloads + external search ----
|
||||
|
||||
const DOWNLOAD_STATUSES: readonly DownloadStatus[] = [
|
||||
'queued',
|
||||
'downloading',
|
||||
'enriching',
|
||||
'done',
|
||||
'failed',
|
||||
];
|
||||
|
||||
const toDownloadStatus = (raw: string): DownloadStatus =>
|
||||
(DOWNLOAD_STATUSES as readonly string[]).includes(raw)
|
||||
? (raw as DownloadStatus)
|
||||
: 'queued';
|
||||
|
||||
export interface RawDownloadJob {
|
||||
id: string;
|
||||
source: string;
|
||||
source_id: string | null;
|
||||
query: string | null;
|
||||
status: string;
|
||||
progress: number;
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
track_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const toDownloadJob = (r: RawDownloadJob): DownloadJob => ({
|
||||
id: r.id,
|
||||
source: r.source,
|
||||
sourceId: r.source_id ?? undefined,
|
||||
query: r.query ?? undefined,
|
||||
status: toDownloadStatus(r.status),
|
||||
progress: r.progress,
|
||||
errorMessage: r.error_message ?? undefined,
|
||||
trackId: r.track_id ?? undefined,
|
||||
retryCount: r.retry_count,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
});
|
||||
|
||||
export interface RawSearchResult {
|
||||
source: string;
|
||||
source_id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
duration_seconds: number | null;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export const toSearchResult = (r: RawSearchResult): ExternalSearchResult => ({
|
||||
source: r.source,
|
||||
sourceId: r.source_id,
|
||||
title: r.title,
|
||||
artist: r.artist ?? undefined,
|
||||
album: r.album ?? undefined,
|
||||
durationMs:
|
||||
r.duration_seconds != null ? r.duration_seconds * 1000 : undefined,
|
||||
thumbnailUrl: r.thumbnail_url ?? undefined,
|
||||
});
|
||||
|
||||
export interface RawSourceInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export const toSourceInfo = (r: RawSourceInfo): SourceInfo => ({
|
||||
name: r.name,
|
||||
label: r.label,
|
||||
// Backend kinds are `indexable` | `fetch`; default unknowns to indexable
|
||||
// (the conservative, non-searchable bucket).
|
||||
kind: r.kind === 'fetch' ? 'fetch' : 'indexable',
|
||||
available: r.available,
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
||||
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
||||
|
||||
+47
-5
@@ -78,20 +78,62 @@ export interface PlaylistTrack extends Track {
|
||||
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;
|
||||
url: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
status: 'queued' | 'downloading' | 'processing' | 'done' | 'error';
|
||||
/** 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;
|
||||
|
||||
Reference in New Issue
Block a user