From 231887c3b75961282279cda183acde42cb8c0e18 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sun, 14 Jun 2026 14:04:43 +0300 Subject: [PATCH] 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 --- .gitea/workflows/docker-publish.yml | 6 +- public/sw.js | 7 +- src/api/endpoints/downloads.ts | 100 ++++- src/api/endpoints/search.ts | 48 +++ src/api/mappers.ts | 84 +++++ src/api/types.ts | 52 ++- src/components/common/Marquee.tsx | 4 +- src/components/layout/Sidebar.tsx | 21 +- src/components/player/QueuePanel.tsx | 5 +- .../DownloadsManagerPage.tsx | 272 +++++++++++++- src/features/library/LibraryPage.tsx | 18 +- .../metadata-editor/MetadataEditorPage.tsx | 126 +++++-- .../search-download/SearchDownloadPage.tsx | 355 +++++++++++++++++- src/features/settings/panels.tsx | 8 +- src/features/storage/StoragePage.tsx | 8 +- src/features/upload/UploadPage.tsx | 24 +- src/i18n/locales/en.ts | 48 ++- src/i18n/locales/ru.ts | 43 +++ src/index.tsx | 1 + src/routes/index.tsx | 9 +- src/store/rtkqPersist.ts | 6 +- tests/rtkqPersist.test.ts | 8 +- tests/sw-core.test.ts | 6 +- 23 files changed, 1168 insertions(+), 91 deletions(-) create mode 100644 src/api/endpoints/search.ts diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index 1a997cd..92fd19b 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -7,15 +7,15 @@ on: env: # Number of tagged (non-latest) versions to keep per image name. - KEEP_VERSIONS: "5" + KEEP_VERSIONS: '5' jobs: build: runs-on: ubuntu-latest outputs: - host: ${{ steps.meta.outputs.host }} + host: ${{ steps.meta.outputs.host }} image: ${{ steps.meta.outputs.image }} - sha: ${{ steps.meta.outputs.sha }} + sha: ${{ steps.meta.outputs.sha }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/public/sw.js b/public/sw.js index 4083c66..3a884d1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -60,7 +60,9 @@ async function handleAudio(event) { // 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a // complete copy, then satisfy the original request (range-sliced if asked). try { - const fullReq = new Request(req.url, { headers: withoutRange(req.headers) }); + const fullReq = new Request(req.url, { + headers: withoutRange(req.headers), + }); const resp = await fetch(fullReq); if (isCacheable(resp)) { event.waitUntil(storeInCache(key, resp.clone())); @@ -135,7 +137,8 @@ async function buildRangeResponse(response, rangeHeader) { const buf = await response.clone().arrayBuffer(); const size = buf.byteLength; const r = parseRangeHeader(rangeHeader, size); - const type = response.headers.get('content-type') || 'application/octet-stream'; + const type = + response.headers.get('content-type') || 'application/octet-stream'; if (!r) { return new Response(buf, { diff --git a/src/api/endpoints/downloads.ts b/src/api/endpoints/downloads.ts index 52ac13e..6dca7a8 100644 --- a/src/api/endpoints/downloads.ts +++ b/src/api/endpoints/downloads.ts @@ -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, + 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) => + 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({ + 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({ query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }), @@ -32,7 +90,8 @@ export const downloadsApi = api.injectEndpoints({ }), retryDownload: build.mutation({ 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; diff --git a/src/api/endpoints/search.ts b/src/api/endpoints/search.ts new file mode 100644 index 0000000..d9b650b --- /dev/null +++ b/src/api/endpoints/search.ts @@ -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({ + 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; diff --git a/src/api/mappers.ts b/src/api/mappers.ts index f4845bd..fb2b1a5 100644 --- a/src/api/mappers.ts +++ b/src/api/mappers.ts @@ -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. diff --git a/src/api/types.ts b/src/api/types.ts index 0e3194d..b7bf5b7 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; diff --git a/src/components/common/Marquee.tsx b/src/components/common/Marquee.tsx index 9a78026..5539387 100644 --- a/src/components/common/Marquee.tsx +++ b/src/components/common/Marquee.tsx @@ -31,7 +31,9 @@ export function Marquee({ {text} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index ec15c54..61c2bc2 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -19,9 +19,24 @@ interface NavDef { const MAIN_NAV: NavDef[] = [ { to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' }, - { to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' }, - { to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' }, - { to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' }, + { + to: '/discover', + labelKey: 'nav.search', + icon: 'magnifying-glass', + perm: 'download', + }, + { + to: '/downloads', + labelKey: 'nav.downloads', + icon: 'arrow-circle-down', + perm: 'download', + }, + { + to: '/upload', + labelKey: 'nav.upload', + icon: 'upload-simple', + perm: 'upload', + }, { to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' }, ]; diff --git a/src/components/player/QueuePanel.tsx b/src/components/player/QueuePanel.tsx index 55cc555..13271a7 100644 --- a/src/components/player/QueuePanel.tsx +++ b/src/components/player/QueuePanel.tsx @@ -273,7 +273,10 @@ function QueueRow({
- + diff --git a/src/features/downloads-manager/DownloadsManagerPage.tsx b/src/features/downloads-manager/DownloadsManagerPage.tsx index 35100bb..0cba1ab 100644 --- a/src/features/downloads-manager/DownloadsManagerPage.tsx +++ b/src/features/downloads-manager/DownloadsManagerPage.tsx @@ -1,13 +1,275 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { Window } from '@olly/modern-sk'; +import { + Badge, + Button, + Callout, + Progress, + ScrollArea, + Spinner, +} from '@olly/modern-sk'; +import { + useCancelDownloadMutation, + useGetDownloadsQuery, + useRetryDownloadMutation, +} from '../../api/endpoints/downloads'; +import { Icon } from '../../components/common/Icon'; +import { EmptyState } from '../../components/common/EmptyState'; +import { ErrorState } from '../../components/common/ErrorState'; +import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; +import { formatDateTime } from '../../lib/format'; +import type { DownloadJob, DownloadStatus } from '../../api/types'; +const ACTIVE: readonly DownloadStatus[] = [ + 'queued', + 'downloading', + 'enriching', +]; +const isActive = (s: DownloadStatus) => ACTIVE.includes(s); + +const STATUS_BADGE: Record< + DownloadStatus, + { variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string } +> = { + queued: { variant: 'neutral', key: 'downloads.status.queued' }, + downloading: { variant: 'neutral', key: 'downloads.status.downloading' }, + enriching: { variant: 'outline', key: 'downloads.status.enriching' }, + done: { variant: 'lime', key: 'downloads.status.done' }, + failed: { variant: 'ember', key: 'downloads.status.failed' }, +}; + +/** `/downloads` — A5 download manager: active queue, progress, errors, retries. */ export function DownloadsManagerPage() { const { t } = useTranslation(); + + // Poll while anything is in flight; idle pages stop polling (tag invalidation + // from a new download still refetches and re-arms the interval). + const [pollMs, setPollMs] = useState(2000); + const { data, isLoading, isError, refetch } = useGetDownloadsQuery( + undefined, + { + pollingInterval: pollMs, + }, + ); + + useEffect(() => { + const anyActive = data?.items.some((j) => isActive(j.status)) ?? false; + setPollMs(anyActive ? 1500 : 0); + }, [data]); + + const jobs = data?.items ?? []; + const active = jobs.filter((j) => isActive(j.status)); + const finished = jobs.filter((j) => !isActive(j.status)); + const failedCount = jobs.filter((j) => j.status === 'failed').length; + return ( -
- -

{t('common.comingSoon')}

-
+
+
+
+

+ {t('downloads.title')} +

+

+ {t('downloads.subtitle')} +

+
+ {active.length > 0 && ( + + {' '} + {t('downloads.activeCount', { count: active.length })} + + )} +
+ + +
+ {isLoading && } + {isError && ( + refetch()} + /> + )} + + {data && jobs.length === 0 && ( + } + title={t('downloads.emptyTitle')} + description={t('downloads.emptyDesc')} + /> + )} + + {failedCount > 0 && ( + + {t('downloads.failedBanner', { count: failedCount })} + + )} + + {active.length > 0 && ( +
+ {active.map((job) => ( + + ))} +
+ )} + + {finished.length > 0 && ( +
+ {finished.map((job) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+ + {title} + + {children} +
+ ); +} + +function JobRow({ job }: { job: DownloadJob }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [cancel, { isLoading: cancelling }] = useCancelDownloadMutation(); + const [retry, { isLoading: retrying }] = useRetryDownloadMutation(); + + const badge = STATUS_BADGE[job.status]; + const label = job.query || job.sourceId || job.source; + const added = formatDateTime(job.createdAt); + + return ( +
+
+
+
+ {label} +
+
+ {job.source} + {job.retryCount > 0 && + ` · ${t('downloads.attempt', { count: job.retryCount + 1 })}`} + {added && ` · ${added}`} +
+
+ + + {t(badge.key)} + + + {job.status === 'done' && job.trackId && ( + + )} + {job.status === 'failed' && ( + + )} + {isActive(job.status) && ( + + )} +
+ + {job.status === 'downloading' && ( + + )} + {job.status === 'failed' && job.errorMessage && ( +
+ {job.errorMessage} +
+ )}
); } diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx index 992fc7b..79e6ec4 100644 --- a/src/features/library/LibraryPage.tsx +++ b/src/features/library/LibraryPage.tsx @@ -76,13 +76,25 @@ export function LibraryPage() { // Live server data wins; offline we fall back to the locally-composed list. const tracksToShow = tracksQuery.data?.items ?? - (offline ? (q ? localTracks.filter((tr) => matchTrack(tr, q)) : localTracks) : undefined); + (offline + ? q + ? localTracks.filter((tr) => matchTrack(tr, q)) + : localTracks + : undefined); const albumsToShow = albumsQuery.data?.items ?? - (offline ? (q ? localAlbums.filter((a) => matchAlbum(a, q)) : localAlbums) : undefined); + (offline + ? q + ? localAlbums.filter((a) => matchAlbum(a, q)) + : localAlbums + : undefined); const artistsToShow = artistsQuery.data?.items ?? - (offline ? (q ? localArtists.filter((a) => matchArtist(a, q)) : localArtists) : undefined); + (offline + ? q + ? localArtists.filter((a) => matchArtist(a, q)) + : localArtists + : undefined); const handlePlayAll = (tracks: Track[]) => { dispatch( diff --git a/src/features/metadata-editor/MetadataEditorPage.tsx b/src/features/metadata-editor/MetadataEditorPage.tsx index 33c1582..f3cb37c 100644 --- a/src/features/metadata-editor/MetadataEditorPage.tsx +++ b/src/features/metadata-editor/MetadataEditorPage.tsx @@ -1,7 +1,16 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { Badge, Button, Callout, Card, IconButton, ScrollArea, Spinner, TextField } from '@olly/modern-sk'; +import { + Badge, + Button, + Callout, + Card, + IconButton, + ScrollArea, + Spinner, + TextField, +} from '@olly/modern-sk'; import { useApplyMetadataMutation, useEnrichTrackMutation, @@ -75,7 +84,9 @@ function SingleTrackEditor() { const [form, setForm] = useState(EMPTY_FORM); const [initialized, setInitialized] = useState(false); - const [selectedMatch, setSelectedMatch] = useState(null); + const [selectedMatch, setSelectedMatch] = useState( + null, + ); // Seed the form from the loaded track exactly once — afterwards it's the // user's edit buffer and shouldn't be clobbered by refetches. @@ -139,7 +150,9 @@ function SingleTrackEditor() { albumTitle: form.albumTitle.trim() || undefined, year: form.year.trim() ? Number(form.year) : undefined, genre: form.genre.trim() || undefined, - trackNumber: form.trackNumber.trim() ? Number(form.trackNumber) : undefined, + trackNumber: form.trackNumber.trim() + ? Number(form.trackNumber) + : undefined, }, }).unwrap(); }; @@ -210,7 +223,9 @@ function SingleTrackEditor() { }} >
- +
- +
- +
- +
- +
- +
-
+
@@ -295,7 +330,12 @@ function SingleTrackEditor() {
{t('metadataEditor.autoEnrich.title')}
-
+
{t('metadataEditor.autoEnrich.hint')}
@@ -328,18 +368,31 @@ function SingleTrackEditor() {
{enrichResult.isSuccess && ( - {t('metadataEditor.autoEnrich.enqueued')} + + {t('metadataEditor.autoEnrich.enqueued')} + )} {matchesResult.isError && ( - {t('metadataEditor.autoEnrich.error')} + + {t('metadataEditor.autoEnrich.error')} + )} - {matchesResult.isSuccess && matchesResult.data && ( - matchesResult.data.length === 0 ? ( - {t('metadataEditor.autoEnrich.noMatches')} + {matchesResult.isSuccess && + matchesResult.data && + (matchesResult.data.length === 0 ? ( + + {t('metadataEditor.autoEnrich.noMatches')} + ) : ( -
+
{matchesResult.data.map((match) => ( ))}
- ) - )} + ))} {selectedMatch && ( void }) { +function MatchRow({ + match, + onUse, +}: { + match: MetadataMatch; + onUse: () => void; +}) { const { t } = useTranslation(); const pct = Math.round(match.score * 100); return ( @@ -489,11 +547,20 @@ function DiffView({ {t('metadataEditor.diff.noChanges')}
) : ( -
+
{changed.map((row) => (
- {row.label}: - + + {row.label}:{' '} + + {row.current || '—'} {' → '} @@ -504,11 +571,18 @@ function DiffView({ ))}
)} -
+
-
diff --git a/src/features/search-download/SearchDownloadPage.tsx b/src/features/search-download/SearchDownloadPage.tsx index e965665..7a44ad8 100644 --- a/src/features/search-download/SearchDownloadPage.tsx +++ b/src/features/search-download/SearchDownloadPage.tsx @@ -1,13 +1,358 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { Window } from '@olly/modern-sk'; +import { + Badge, + Button, + Callout, + ScrollArea, + SearchField, + SegmentedControl, + Spinner, +} from '@olly/modern-sk'; +import { + useGetSourcesQuery, + useLazySearchExternalQuery, +} from '../../api/endpoints/search'; +import { useCreateDownloadMutation } from '../../api/endpoints/downloads'; +import { Icon } from '../../components/common/Icon'; +import { EmptyState } from '../../components/common/EmptyState'; +import { ErrorState } from '../../components/common/ErrorState'; +import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; +import { formatDuration } from '../../lib/format'; +import type { ExternalSearchResult } from '../../api/types'; +const ALL = '__all__'; + +/** Per-result download outcome, keyed by `${source}:${sourceId}`. */ +type RowState = 'idle' | 'pending' | 'queued' | 'inLibrary' | 'error'; + +const rowKey = (r: ExternalSearchResult) => `${r.source}:${r.sourceId}`; + +/** `/discover` — A4: search external sources and download into the library. */ export function SearchDownloadPage() { const { t } = useTranslation(); + const navigate = useNavigate(); + + const sourcesQuery = useGetSourcesQuery(); + const fetchSources = (sourcesQuery.data ?? []).filter( + (s) => s.kind === 'fetch', + ); + const hasFetchSource = fetchSources.length > 0; + + const [term, setTerm] = useState(''); + const [source, setSource] = useState(ALL); + const [search, result] = useLazySearchExternalQuery(); + const [createDownload] = useCreateDownloadMutation(); + + const [rowStates, setRowStates] = useState>({}); + const [queuedAny, setQueuedAny] = useState(false); + + const runSearch = (q: string) => { + if (!q) return; + setRowStates({}); + void search({ q, source: source === ALL ? undefined : source, limit: 25 }); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + runSearch(term.trim()); + }; + + const download = async (r: ExternalSearchResult) => { + const key = rowKey(r); + setRowStates((s) => ({ ...s, [key]: 'pending' })); + try { + const res = await createDownload({ + source: r.source, + sourceId: r.sourceId, + query: term.trim() || undefined, + }).unwrap(); + setRowStates((s) => ({ + ...s, + [key]: res.alreadyInLibrary ? 'inLibrary' : 'queued', + })); + if (!res.alreadyInLibrary) setQueuedAny(true); + } catch { + setRowStates((s) => ({ ...s, [key]: 'error' })); + } + }; + + const pickerItems = [ + { value: ALL, label: t('discover.allSources') }, + ...fetchSources.map((s) => ({ value: s.name, label: s.label })), + ]; + return ( -
- -

{t('common.comingSoon')}

-
+
+
+
+
+

+ {t('discover.title')} +

+

+ {t('discover.subtitle')} +

+
+ {queuedAny && ( + + )} +
+ +
+
+ } + placeholder={t('discover.searchPlaceholder')} + value={term} + onChange={(e) => setTerm(e.target.value)} + disabled={!hasFetchSource} + /> +
+ +
+ + {fetchSources.length > 1 && ( + + )} +
+ + +
+ {!sourcesQuery.isLoading && !hasFetchSource && ( + {t('discover.noSources')} + )} + + {result.isFetching && } + + {result.isError && !result.isFetching && ( + runSearch(term.trim())} + /> + )} + + {!result.isFetching && + !result.isError && + result.data && + result.data.results.length === 0 && ( + } + title={t('discover.emptyTitle')} + description={t('discover.emptyDesc')} + /> + )} + + {result.isUninitialized && hasFetchSource && ( + } + title={t('discover.startTitle')} + description={t('discover.startDesc')} + /> + )} + + {!result.isFetching && + result.data && + result.data.results.length > 0 && ( +
+ {result.data.results.map((r) => ( + void download(r)} + /> + ))} +
+ )} +
+
+
+ ); +} + +function ResultRow({ + result, + state, + onDownload, +}: { + result: ExternalSearchResult; + state: RowState; + onDownload: () => void; +}) { + const { t } = useTranslation(); + return ( +
+ + +
+
+ {result.title} +
+
+ {[result.artist, result.album].filter(Boolean).join(' · ') || + result.source} +
+
+ + {result.durationMs != null && ( + + {formatDuration(result.durationMs)} + + )} + + +
+ ); +} + +function DownloadControl({ + state, + onDownload, + t, +}: { + state: RowState; + onDownload: () => void; + t: (k: string) => string; +}) { + if (state === 'inLibrary') { + return ( + + {t('discover.inLibrary')} + + ); + } + if (state === 'queued') { + return ( + + {t('discover.queued')} + + ); + } + if (state === 'pending') { + return ( + + ); + } + return ( + + ); +} + +function Thumb({ url }: { url?: string }) { + return ( +
+ {url ? ( + + ) : ( + + )}
); } diff --git a/src/features/settings/panels.tsx b/src/features/settings/panels.tsx index ba78df6..a015af4 100644 --- a/src/features/settings/panels.tsx +++ b/src/features/settings/panels.tsx @@ -4,7 +4,13 @@ import { Window, SegmentedControl, useTheme } from '@olly/modern-sk'; import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n'; /** Labelled settings row: caption on the left, control on the right. */ -function SettingRow({ label, children }: { label: string; children: ReactNode }) { +function SettingRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { return (
-
+
{/* ── On this device (local + cached) ───────────────────────── */}
@@ -347,7 +349,9 @@ function DiskGauge({ total: formatFileSize(disk.total), })} - {t('storage.diskFree', { free: formatFileSize(disk.free) })} + + {t('storage.diskFree', { free: formatFileSize(disk.free) })} +

([]); const patchItem = (id: string, patch: Partial) => - setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it))); + setItems((prev) => + prev.map((it) => (it.id === id ? { ...it, ...patch } : it)), + ); // Ref-based concurrency pump: refs (not state) so it is safe to call from // async callbacks without stale closures over the queue. const pump = () => { - while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) { + while ( + activeCount.current < MAX_CONCURRENCY && + pending.current.length > 0 + ) { const item = pending.current.shift()!; activeCount.current += 1; patchItem(item.id, { status: 'uploading', error: undefined }); @@ -182,7 +190,9 @@ export function UploadPage() { >

{t('upload.dropzone.title')}
-
+
{t('upload.dropzone.hint')}