Compare commits
1 Commits
cdcacc56d1
...
231887c3b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 231887c3b7 |
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Number of tagged (non-latest) versions to keep per image name.
|
# Number of tagged (non-latest) versions to keep per image name.
|
||||||
KEEP_VERSIONS: "5"
|
KEEP_VERSIONS: '5'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
+5
-2
@@ -60,7 +60,9 @@ async function handleAudio(event) {
|
|||||||
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
|
// 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).
|
// complete copy, then satisfy the original request (range-sliced if asked).
|
||||||
try {
|
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);
|
const resp = await fetch(fullReq);
|
||||||
if (isCacheable(resp)) {
|
if (isCacheable(resp)) {
|
||||||
event.waitUntil(storeInCache(key, resp.clone()));
|
event.waitUntil(storeInCache(key, resp.clone()));
|
||||||
@@ -135,7 +137,8 @@ async function buildRangeResponse(response, rangeHeader) {
|
|||||||
const buf = await response.clone().arrayBuffer();
|
const buf = await response.clone().arrayBuffer();
|
||||||
const size = buf.byteLength;
|
const size = buf.byteLength;
|
||||||
const r = parseRangeHeader(rangeHeader, size);
|
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) {
|
if (!r) {
|
||||||
return new Response(buf, {
|
return new Response(buf, {
|
||||||
|
|||||||
@@ -1,30 +1,88 @@
|
|||||||
import { api } from '../index';
|
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
|
interface ListParams {
|
||||||
// return no body / no schema). The request shapes below are provisional and the
|
status?: DownloadJob['status'];
|
||||||
// responses will need the same snake→camel mapper treatment as library/playlists
|
/** Only the current user's jobs (backend `mine=true`). */
|
||||||
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not
|
mine?: boolean;
|
||||||
// wire these into the UI until then.
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCreateResponse {
|
||||||
|
already_in_library: boolean;
|
||||||
|
track_id: string | null;
|
||||||
|
job: RawDownloadJob | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const downloadsApi = api.injectEndpoints({
|
export const downloadsApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getDownloads: build.query<
|
getDownloads: build.query<
|
||||||
DownloadJob[],
|
PaginatedResponse<DownloadJob>,
|
||||||
{ status?: DownloadJob['status'] } | void
|
ListParams | void
|
||||||
>({
|
>({
|
||||||
query: (params) => ({ url: '/downloads', params: params ?? {} }),
|
query: (params) => {
|
||||||
providesTags: ['Download'],
|
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<
|
getDownload: build.query<DownloadJob, string>({
|
||||||
DownloadJob,
|
query: (id) => `/downloads/${id}`,
|
||||||
{
|
transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
|
||||||
url: string;
|
providesTags: (_r, _e, id) => [{ type: 'Download', id }],
|
||||||
metadata?: { title?: string; artist?: string; album?: string };
|
}),
|
||||||
}
|
/** 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 }),
|
query: (body) => ({
|
||||||
invalidatesTags: ['Download'],
|
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>({
|
cancelDownload: build.mutation<void, string>({
|
||||||
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
|
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
|
||||||
@@ -32,7 +90,8 @@ export const downloadsApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
retryDownload: build.mutation<DownloadJob, string>({
|
retryDownload: build.mutation<DownloadJob, string>({
|
||||||
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
|
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,
|
overrideExisting: false,
|
||||||
@@ -40,7 +99,8 @@ export const downloadsApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetDownloadsQuery,
|
useGetDownloadsQuery,
|
||||||
useAddDownloadMutation,
|
useGetDownloadQuery,
|
||||||
|
useCreateDownloadMutation,
|
||||||
useCancelDownloadMutation,
|
useCancelDownloadMutation,
|
||||||
useRetryDownloadMutation,
|
useRetryDownloadMutation,
|
||||||
} = downloadsApi;
|
} = 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 {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
|
DownloadJob,
|
||||||
|
DownloadStatus,
|
||||||
|
ExternalSearchResult,
|
||||||
MetadataMatch,
|
MetadataMatch,
|
||||||
MetadataStatus,
|
MetadataStatus,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
SourceInfo,
|
||||||
StorageStats,
|
StorageStats,
|
||||||
Track,
|
Track,
|
||||||
User,
|
User,
|
||||||
@@ -246,6 +250,86 @@ export const toStorageStats = (r: RawStorageStats): StorageStats => ({
|
|||||||
disk: r.disk ?? undefined,
|
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
|
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
||||||
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
||||||
|
|||||||
+47
-5
@@ -78,20 +78,62 @@ export interface PlaylistTrack extends Track {
|
|||||||
addedAt: string;
|
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 {
|
export interface DownloadJob {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
/** Source backend the job pulls from (e.g. `youtube`). */
|
||||||
title?: string;
|
source: string;
|
||||||
artist?: string;
|
/** Stable per-source id of the item (e.g. a YouTube videoId). */
|
||||||
album?: string;
|
sourceId?: string;
|
||||||
status: 'queued' | 'downloading' | 'processing' | 'done' | 'error';
|
/** The free-text query the job was created from, for display. */
|
||||||
|
query?: string;
|
||||||
|
status: DownloadStatus;
|
||||||
|
/** Fraction complete, 0..1. */
|
||||||
progress: number;
|
progress: number;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
/** Set once the download finishes and the library track exists. */
|
||||||
trackId?: string;
|
trackId?: string;
|
||||||
|
retryCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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 {
|
export interface UploadResponse {
|
||||||
track_id: string;
|
track_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export function Marquee({
|
|||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`marquee${shift ? ' on' : ''}${className ? ` ${className}` : ''}`}
|
className={`marquee${shift ? ' on' : ''}${className ? ` ${className}` : ''}`}
|
||||||
style={shift ? ({ '--mq-shift': `-${shift}px` } as CSSProperties) : undefined}
|
style={
|
||||||
|
shift ? ({ '--mq-shift': `-${shift}px` } as CSSProperties) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="marquee-inner">{text}</span>
|
<span className="marquee-inner">{text}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -19,9 +19,24 @@ interface NavDef {
|
|||||||
|
|
||||||
const MAIN_NAV: NavDef[] = [
|
const MAIN_NAV: NavDef[] = [
|
||||||
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
{ 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: '/discover',
|
||||||
{ to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' },
|
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' },
|
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -273,7 +273,10 @@ function QueueRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="qt">
|
<div className="qt">
|
||||||
<Marquee className="t" text={resolved?.title ?? entry.title} />
|
<Marquee className="t" text={resolved?.title ?? entry.title} />
|
||||||
<Marquee className="r" text={resolved?.artistName ?? entry.artistName} />
|
<Marquee
|
||||||
|
className="r"
|
||||||
|
text={resolved?.artistName ?? entry.artistName}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
|
|||||||
@@ -1,13 +1,275 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
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() {
|
export function DownloadsManagerPage() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Window title={t('pages.downloads')}>
|
<header
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
style={{
|
||||||
</Window>
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('downloads.title')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('downloads.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{active.length > 0 && (
|
||||||
|
<Badge variant="neutral">
|
||||||
|
<Spinner size="sm" />{' '}
|
||||||
|
{t('downloads.activeCount', { count: active.length })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
maxWidth: 880,
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && <LoadingSkeleton rows={5} height={64} />}
|
||||||
|
{isError && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('downloads.loadError')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && jobs.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="arrow-circle-down" />}
|
||||||
|
title={t('downloads.emptyTitle')}
|
||||||
|
description={t('downloads.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<Callout variant="warning">
|
||||||
|
{t('downloads.failedBanner', { count: failedCount })}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active.length > 0 && (
|
||||||
|
<Section title={t('downloads.sectionActive')}>
|
||||||
|
{active.map((job) => (
|
||||||
|
<JobRow key={job.id} job={job} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finished.length > 0 && (
|
||||||
|
<Section title={t('downloads.sectionHistory')}>
|
||||||
|
{finished.map((job) => (
|
||||||
|
<JobRow key={job.id} job={job} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{job.source}
|
||||||
|
{job.retryCount > 0 &&
|
||||||
|
` · ${t('downloads.attempt', { count: job.retryCount + 1 })}`}
|
||||||
|
{added && ` · ${added}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant={badge.variant} dot>
|
||||||
|
{t(badge.key)}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{job.status === 'done' && job.trackId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate(`/tracks/${job.trackId}/metadata`)}
|
||||||
|
>
|
||||||
|
{t('downloads.open')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{job.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={retrying}
|
||||||
|
onClick={() => void retry(job.id)}
|
||||||
|
>
|
||||||
|
<Icon name="arrows-clockwise" /> {t('downloads.retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isActive(job.status) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={cancelling}
|
||||||
|
onClick={() => void cancel(job.id)}
|
||||||
|
title={t('downloads.cancel')}
|
||||||
|
>
|
||||||
|
<Icon name="x" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.status === 'downloading' && (
|
||||||
|
<Progress value={Math.round(job.progress * 100)} />
|
||||||
|
)}
|
||||||
|
{job.status === 'failed' && job.errorMessage && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{job.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,13 +76,25 @@ export function LibraryPage() {
|
|||||||
// Live server data wins; offline we fall back to the locally-composed list.
|
// Live server data wins; offline we fall back to the locally-composed list.
|
||||||
const tracksToShow =
|
const tracksToShow =
|
||||||
tracksQuery.data?.items ??
|
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 =
|
const albumsToShow =
|
||||||
albumsQuery.data?.items ??
|
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 =
|
const artistsToShow =
|
||||||
artistsQuery.data?.items ??
|
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[]) => {
|
const handlePlayAll = (tracks: Track[]) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 {
|
import {
|
||||||
useApplyMetadataMutation,
|
useApplyMetadataMutation,
|
||||||
useEnrichTrackMutation,
|
useEnrichTrackMutation,
|
||||||
@@ -75,7 +84,9 @@ function SingleTrackEditor() {
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(null);
|
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// Seed the form from the loaded track exactly once — afterwards it's the
|
// Seed the form from the loaded track exactly once — afterwards it's the
|
||||||
// user's edit buffer and shouldn't be clobbered by refetches.
|
// user's edit buffer and shouldn't be clobbered by refetches.
|
||||||
@@ -139,7 +150,9 @@ function SingleTrackEditor() {
|
|||||||
albumTitle: form.albumTitle.trim() || undefined,
|
albumTitle: form.albumTitle.trim() || undefined,
|
||||||
year: form.year.trim() ? Number(form.year) : undefined,
|
year: form.year.trim() ? Number(form.year) : undefined,
|
||||||
genre: form.genre.trim() || undefined,
|
genre: form.genre.trim() || undefined,
|
||||||
trackNumber: form.trackNumber.trim() ? Number(form.trackNumber) : undefined,
|
trackNumber: form.trackNumber.trim()
|
||||||
|
? Number(form.trackNumber)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
};
|
};
|
||||||
@@ -210,7 +223,9 @@ function SingleTrackEditor() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.title')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.title')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
value={form.title}
|
value={form.title}
|
||||||
@@ -218,7 +233,9 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.artist')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.artist')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
value={form.artistName}
|
value={form.artistName}
|
||||||
@@ -226,7 +243,9 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.album')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.album')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
value={form.albumTitle}
|
value={form.albumTitle}
|
||||||
@@ -235,7 +254,9 @@ function SingleTrackEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.year')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.year')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -244,7 +265,9 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.trackNumber')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.trackNumber')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -254,7 +277,9 @@ function SingleTrackEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('metadataEditor.fields.genre')}</label>
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.genre')}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
style={fieldStyle()}
|
style={fieldStyle()}
|
||||||
value={form.genre}
|
value={form.genre}
|
||||||
@@ -262,13 +287,23 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
disabled={applyResult.isLoading}
|
disabled={applyResult.isLoading}
|
||||||
>
|
>
|
||||||
{applyResult.isLoading ? <Spinner size="sm" /> : t('metadataEditor.save')}
|
{applyResult.isLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.save')
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,7 +330,12 @@ function SingleTrackEditor() {
|
|||||||
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
{t('metadataEditor.autoEnrich.title')}
|
{t('metadataEditor.autoEnrich.title')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('metadataEditor.autoEnrich.hint')}
|
{t('metadataEditor.autoEnrich.hint')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,18 +368,31 @@ function SingleTrackEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enrichResult.isSuccess && (
|
{enrichResult.isSuccess && (
|
||||||
<Callout variant="info">{t('metadataEditor.autoEnrich.enqueued')}</Callout>
|
<Callout variant="info">
|
||||||
|
{t('metadataEditor.autoEnrich.enqueued')}
|
||||||
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{matchesResult.isError && (
|
{matchesResult.isError && (
|
||||||
<Callout variant="danger">{t('metadataEditor.autoEnrich.error')}</Callout>
|
<Callout variant="danger">
|
||||||
|
{t('metadataEditor.autoEnrich.error')}
|
||||||
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{matchesResult.isSuccess && matchesResult.data && (
|
{matchesResult.isSuccess &&
|
||||||
matchesResult.data.length === 0 ? (
|
matchesResult.data &&
|
||||||
<Callout variant="warning">{t('metadataEditor.autoEnrich.noMatches')}</Callout>
|
(matchesResult.data.length === 0 ? (
|
||||||
|
<Callout variant="warning">
|
||||||
|
{t('metadataEditor.autoEnrich.noMatches')}
|
||||||
|
</Callout>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{matchesResult.data.map((match) => (
|
{matchesResult.data.map((match) => (
|
||||||
<MatchRow
|
<MatchRow
|
||||||
key={match.acoustid}
|
key={match.acoustid}
|
||||||
@@ -348,8 +401,7 @@ function SingleTrackEditor() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedMatch && (
|
{selectedMatch && (
|
||||||
<DiffView
|
<DiffView
|
||||||
@@ -367,7 +419,13 @@ function SingleTrackEditor() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchRow({ match, onUse }: { match: MetadataMatch; onUse: () => void }) {
|
function MatchRow({
|
||||||
|
match,
|
||||||
|
onUse,
|
||||||
|
}: {
|
||||||
|
match: MetadataMatch;
|
||||||
|
onUse: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pct = Math.round(match.score * 100);
|
const pct = Math.round(match.score * 100);
|
||||||
return (
|
return (
|
||||||
@@ -489,11 +547,20 @@ function DiffView({
|
|||||||
{t('metadataEditor.diff.noChanges')}
|
{t('metadataEditor.diff.noChanges')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}
|
||||||
|
>
|
||||||
{changed.map((row) => (
|
{changed.map((row) => (
|
||||||
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
|
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
|
||||||
<span style={{ color: 'var(--color-text-3)' }}>{row.label}: </span>
|
<span style={{ color: 'var(--color-text-3)' }}>
|
||||||
<span style={{ textDecoration: 'line-through', color: 'var(--color-text-3)' }}>
|
{row.label}:{' '}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{row.current || '—'}
|
{row.current || '—'}
|
||||||
</span>
|
</span>
|
||||||
{' → '}
|
{' → '}
|
||||||
@@ -504,11 +571,18 @@ function DiffView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
<div
|
||||||
|
style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||||
{t('metadataEditor.diff.cancel')}
|
{t('metadataEditor.diff.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" onClick={onApply} disabled={changed.length === 0}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={changed.length === 0}
|
||||||
|
>
|
||||||
{t('metadataEditor.diff.apply')}
|
{t('metadataEditor.diff.apply')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,358 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
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() {
|
export function SearchDownloadPage() {
|
||||||
const { t } = useTranslation();
|
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<Record<string, RowState>>({});
|
||||||
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Window title={t('pages.search')}>
|
<header
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
style={{
|
||||||
</Window>
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('discover.title')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('discover.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{queuedAny && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate('/downloads')}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-circle-down" /> {t('discover.viewDownloads')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
style={{ display: 'flex', gap: '0.625rem', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<SearchField
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
placeholder={t('discover.searchPlaceholder')}
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
disabled={!hasFetchSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasFetchSource || !term.trim()}
|
||||||
|
>
|
||||||
|
{t('discover.searchButton')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{fetchSources.length > 1 && (
|
||||||
|
<SegmentedControl
|
||||||
|
value={source}
|
||||||
|
onValueChange={setSource}
|
||||||
|
items={pickerItems}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div style={{ padding: '1.5rem', maxWidth: 880, margin: '0 auto' }}>
|
||||||
|
{!sourcesQuery.isLoading && !hasFetchSource && (
|
||||||
|
<Callout variant="warning">{t('discover.noSources')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.isFetching && <LoadingSkeleton rows={6} height={64} />}
|
||||||
|
|
||||||
|
{result.isError && !result.isFetching && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('discover.searchError')}
|
||||||
|
onRetry={() => runSearch(term.trim())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!result.isFetching &&
|
||||||
|
!result.isError &&
|
||||||
|
result.data &&
|
||||||
|
result.data.results.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
title={t('discover.emptyTitle')}
|
||||||
|
description={t('discover.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.isUninitialized && hasFetchSource && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
title={t('discover.startTitle')}
|
||||||
|
description={t('discover.startDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!result.isFetching &&
|
||||||
|
result.data &&
|
||||||
|
result.data.results.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.data.results.map((r) => (
|
||||||
|
<ResultRow
|
||||||
|
key={rowKey(r)}
|
||||||
|
result={r}
|
||||||
|
state={rowStates[rowKey(r)] ?? 'idle'}
|
||||||
|
onDownload={() => void download(r)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultRow({
|
||||||
|
result,
|
||||||
|
state,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
result: ExternalSearchResult;
|
||||||
|
state: RowState;
|
||||||
|
onDownload: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.875rem',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Thumb url={result.thumbnailUrl} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={result.title}
|
||||||
|
>
|
||||||
|
{result.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[result.artist, result.album].filter(Boolean).join(' · ') ||
|
||||||
|
result.source}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.durationMs != null && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(result.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DownloadControl state={state} onDownload={onDownload} t={t} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadControl({
|
||||||
|
state,
|
||||||
|
onDownload,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
state: RowState;
|
||||||
|
onDownload: () => void;
|
||||||
|
t: (k: string) => string;
|
||||||
|
}) {
|
||||||
|
if (state === 'inLibrary') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" dot>
|
||||||
|
{t('discover.inLibrary')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'queued') {
|
||||||
|
return (
|
||||||
|
<Badge variant="lime" dot>
|
||||||
|
{t('discover.queued')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'pending') {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" type="button" disabled>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button variant="primary" size="sm" type="button" onClick={onDownload}>
|
||||||
|
<Icon name="arrow-circle-down" />
|
||||||
|
{state === 'error' ? t('discover.retryDownload') : t('discover.download')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumb({ url }: { url?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-surface-2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
width={44}
|
||||||
|
height={44}
|
||||||
|
style={{ objectFit: 'cover', width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon name="vinyl-record" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
|
|||||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||||
|
|
||||||
/** Labelled settings row: caption on the left, control on the right. */
|
/** 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 (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ export function StoragePage() {
|
|||||||
{t('storage.subtitle')}
|
{t('storage.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}
|
||||||
|
>
|
||||||
{/* ── On this device (local + cached) ───────────────────────── */}
|
{/* ── On this device (local + cached) ───────────────────────── */}
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle icon="hard-drives">
|
<SectionTitle icon="hard-drives">
|
||||||
@@ -347,7 +349,9 @@ function DiskGauge({
|
|||||||
total: formatFileSize(disk.total),
|
total: formatFileSize(disk.total),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span>{t('storage.diskFree', { free: formatFileSize(disk.free) })}</span>
|
<span>
|
||||||
|
{t('storage.diskFree', { free: formatFileSize(disk.free) })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ const MAX_CONCURRENCY = 3;
|
|||||||
|
|
||||||
function extractError(err: unknown): string {
|
function extractError(err: unknown): string {
|
||||||
if (typeof err === 'object' && err !== null) {
|
if (typeof err === 'object' && err !== null) {
|
||||||
const e = err as { data?: { message?: string; detail?: string }; error?: string };
|
const e = err as {
|
||||||
|
data?: { message?: string; detail?: string };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
||||||
}
|
}
|
||||||
return 'Upload failed';
|
return 'Upload failed';
|
||||||
@@ -65,12 +68,17 @@ export function UploadPage() {
|
|||||||
const pending = useRef<QueueItem[]>([]);
|
const pending = useRef<QueueItem[]>([]);
|
||||||
|
|
||||||
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
||||||
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
|
// Ref-based concurrency pump: refs (not state) so it is safe to call from
|
||||||
// async callbacks without stale closures over the queue.
|
// async callbacks without stale closures over the queue.
|
||||||
const pump = () => {
|
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()!;
|
const item = pending.current.shift()!;
|
||||||
activeCount.current += 1;
|
activeCount.current += 1;
|
||||||
patchItem(item.id, { status: 'uploading', error: undefined });
|
patchItem(item.id, { status: 'uploading', error: undefined });
|
||||||
@@ -182,7 +190,9 @@ export function UploadPage() {
|
|||||||
>
|
>
|
||||||
<div style={{ fontSize: '2rem' }}>⬆</div>
|
<div style={{ fontSize: '2rem' }}>⬆</div>
|
||||||
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
<div
|
||||||
|
style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}
|
||||||
|
>
|
||||||
{t('upload.dropzone.hint')}
|
{t('upload.dropzone.hint')}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" size="sm" type="button">
|
<Button variant="primary" size="sm" type="button">
|
||||||
@@ -202,7 +212,11 @@ export function UploadPage() {
|
|||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
+46
-2
@@ -133,7 +133,8 @@ const en = {
|
|||||||
},
|
},
|
||||||
offline: {
|
offline: {
|
||||||
title: 'Artist not available offline',
|
title: 'Artist not available offline',
|
||||||
description: "You're offline and this artist isn't cached on this device.",
|
description:
|
||||||
|
"You're offline and this artist isn't cached on this device.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
@@ -237,7 +238,8 @@ const en = {
|
|||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
comingSoon: 'Coming soon',
|
comingSoon: 'Coming soon',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
offlineBanner: "You're offline — showing locally available data, read-only.",
|
offlineBanner:
|
||||||
|
"You're offline — showing locally available data, read-only.",
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Everything this instance has tucked away',
|
subtitle: 'Everything this instance has tucked away',
|
||||||
@@ -352,6 +354,48 @@ const en = {
|
|||||||
error: 'Failed',
|
error: 'Failed',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
discover: {
|
||||||
|
title: 'Search & download',
|
||||||
|
subtitle: 'Find music on connected sources and add it to your library.',
|
||||||
|
searchPlaceholder: 'Search for a song, artist, or album…',
|
||||||
|
searchButton: 'Search',
|
||||||
|
allSources: 'All sources',
|
||||||
|
noSources:
|
||||||
|
'No download sources are configured. Enable a source (e.g. YouTube Music) on the server to search and download.',
|
||||||
|
startTitle: 'Search to get started',
|
||||||
|
startDesc: 'Results from your connected sources will appear here.',
|
||||||
|
emptyTitle: 'No results',
|
||||||
|
emptyDesc: 'Try a different search term or another source.',
|
||||||
|
searchError: "Couldn't search right now. Try again.",
|
||||||
|
download: 'Download',
|
||||||
|
retryDownload: 'Retry',
|
||||||
|
queued: 'Queued',
|
||||||
|
inLibrary: 'In library',
|
||||||
|
viewDownloads: 'View downloads',
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
title: 'Downloads',
|
||||||
|
subtitle: 'Track downloads in progress, completed, and failed.',
|
||||||
|
activeCount: '{{count}} active',
|
||||||
|
sectionActive: 'In progress',
|
||||||
|
sectionHistory: 'History',
|
||||||
|
emptyTitle: 'No downloads yet',
|
||||||
|
emptyDesc: 'Find music in Search & download to queue it here.',
|
||||||
|
loadError: "Couldn't load downloads.",
|
||||||
|
failedBanner:
|
||||||
|
'{{count}} download(s) failed. Retry them, or check the source on the server.',
|
||||||
|
attempt: 'Attempt {{count}}',
|
||||||
|
open: 'Open',
|
||||||
|
retry: 'Retry',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
status: {
|
||||||
|
queued: 'Queued',
|
||||||
|
downloading: 'Downloading',
|
||||||
|
enriching: 'Enriching',
|
||||||
|
done: 'Done',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
status: {
|
status: {
|
||||||
pending: 'Enriching…',
|
pending: 'Enriching…',
|
||||||
|
|||||||
@@ -355,6 +355,49 @@ const ru: Translations = {
|
|||||||
error: 'Ошибка',
|
error: 'Ошибка',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
discover: {
|
||||||
|
title: 'Поиск и скачивание',
|
||||||
|
subtitle:
|
||||||
|
'Находите музыку в подключённых источниках и добавляйте в библиотеку.',
|
||||||
|
searchPlaceholder: 'Найти трек, исполнителя или альбом…',
|
||||||
|
searchButton: 'Найти',
|
||||||
|
allSources: 'Все источники',
|
||||||
|
noSources:
|
||||||
|
'Источники скачивания не настроены. Включите источник (например, YouTube Music) на сервере, чтобы искать и скачивать.',
|
||||||
|
startTitle: 'Начните с поиска',
|
||||||
|
startDesc: 'Здесь появятся результаты из подключённых источников.',
|
||||||
|
emptyTitle: 'Ничего не найдено',
|
||||||
|
emptyDesc: 'Попробуйте другой запрос или другой источник.',
|
||||||
|
searchError: 'Не удалось выполнить поиск. Попробуйте ещё раз.',
|
||||||
|
download: 'Скачать',
|
||||||
|
retryDownload: 'Повторить',
|
||||||
|
queued: 'В очереди',
|
||||||
|
inLibrary: 'В библиотеке',
|
||||||
|
viewDownloads: 'К загрузкам',
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
title: 'Загрузки',
|
||||||
|
subtitle: 'Активные, завершённые и неуспешные скачивания.',
|
||||||
|
activeCount: 'Активных: {{count}}',
|
||||||
|
sectionActive: 'В процессе',
|
||||||
|
sectionHistory: 'История',
|
||||||
|
emptyTitle: 'Пока нет загрузок',
|
||||||
|
emptyDesc: 'Найдите музыку в разделе «Поиск и скачивание».',
|
||||||
|
loadError: 'Не удалось загрузить список.',
|
||||||
|
failedBanner:
|
||||||
|
'Неуспешных скачиваний: {{count}}. Повторите их или проверьте источник на сервере.',
|
||||||
|
attempt: 'Попытка {{count}}',
|
||||||
|
open: 'Открыть',
|
||||||
|
retry: 'Повторить',
|
||||||
|
cancel: 'Отменить',
|
||||||
|
status: {
|
||||||
|
queued: 'В очереди',
|
||||||
|
downloading: 'Скачивание',
|
||||||
|
enriching: 'Обработка',
|
||||||
|
done: 'Готово',
|
||||||
|
failed: 'Ошибка',
|
||||||
|
},
|
||||||
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
status: {
|
status: {
|
||||||
pending: 'Обработка…',
|
pending: 'Обработка…',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import './api/endpoints/auth';
|
|||||||
import './api/endpoints/library';
|
import './api/endpoints/library';
|
||||||
import './api/endpoints/playlists';
|
import './api/endpoints/playlists';
|
||||||
import './api/endpoints/downloads';
|
import './api/endpoints/downloads';
|
||||||
|
import './api/endpoints/search';
|
||||||
import './api/endpoints/likes';
|
import './api/endpoints/likes';
|
||||||
import './api/endpoints/storage';
|
import './api/endpoints/storage';
|
||||||
import './api/endpoints/admin';
|
import './api/endpoints/admin';
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ const DownloadsManagerPage = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const UploadPage = lazy(() =>
|
const UploadPage = lazy(() =>
|
||||||
import('../features/upload/UploadPage').then((m) => ({ default: m.UploadPage })),
|
import('../features/upload/UploadPage').then((m) => ({
|
||||||
|
default: m.UploadPage,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
const MetadataEditorPage = lazy(() =>
|
const MetadataEditorPage = lazy(() =>
|
||||||
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
|
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
|
||||||
@@ -126,7 +128,10 @@ export function AppRoutes() {
|
|||||||
|
|
||||||
{/* Storage */}
|
{/* Storage */}
|
||||||
<Route path="/storage" element={<StoragePage />} />
|
<Route path="/storage" element={<StoragePage />} />
|
||||||
<Route path="/storage/maintenance" element={<StorageMaintenancePage />} />
|
<Route
|
||||||
|
path="/storage/maintenance"
|
||||||
|
element={<StorageMaintenancePage />}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Queue (narrow viewports) */}
|
{/* Queue (narrow viewports) */}
|
||||||
<Route path="/queue" element={<QueuePage />} />
|
<Route path="/queue" element={<QueuePage />} />
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ function snapshot(apiState: ApiState): RehydrateApiPayload {
|
|||||||
}
|
}
|
||||||
// Carry `provided` along so RTKQ can re-register invalidation tags for the
|
// Carry `provided` along so RTKQ can re-register invalidation tags for the
|
||||||
// restored entries; it is also required structurally (see RehydrateApiPayload).
|
// restored entries; it is also required structurally (see RehydrateApiPayload).
|
||||||
return { queries, mutations: {}, provided: apiState.provided ?? EMPTY_PROVIDED };
|
return {
|
||||||
|
queries,
|
||||||
|
mutations: {},
|
||||||
|
provided: apiState.provided ?? EMPTY_PROVIDED,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(): RehydrateApiPayload | null {
|
function load(): RehydrateApiPayload | null {
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ beforeEach(() => {
|
|||||||
|
|
||||||
function apiStateWith(queries: Record<string, unknown>) {
|
function apiStateWith(queries: Record<string, unknown>) {
|
||||||
return {
|
return {
|
||||||
api: { queries, mutations: {}, provided: {}, subscriptions: {}, config: {} },
|
api: {
|
||||||
|
queries,
|
||||||
|
mutations: {},
|
||||||
|
provided: {},
|
||||||
|
subscriptions: {},
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
} as unknown as RootState;
|
} as unknown as RootState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
} from '../public/sw-core.js';
|
} from '../public/sw-core.js';
|
||||||
|
|
||||||
test('trackIdFromUrl extracts the content id from a stream URL', () => {
|
test('trackIdFromUrl extracts the content id from a stream URL', () => {
|
||||||
expect(
|
expect(trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz')).toBe(
|
||||||
trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz'),
|
'abc123',
|
||||||
).toBe('abc123');
|
);
|
||||||
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
|
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user