Compare commits

..

3 Commits

Author SHA1 Message Date
Senko-san 89cf66f28a fix(api): refetch from server when online (stale-while-revalidate)
Docker Build & Publish / build (push) Successful in 35s
Docker Build & Publish / push (push) Failing after 2s
Docker Build & Publish / Prune old image versions (push) Has been skipped
The Tier-2 rehydrated cache seeded fulfilled entries at startup, so RTKQ served stale data and never hit the network (manual metadata edits, etc. only appeared after clearing the cache). Enable refetchOnMountOrArgChange + refetchOnReconnect + refetchOnFocus and wire setupListeners, so the cached snapshot still shows instantly but the server silently revalidates it whenever reachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:13:59 +03:00
Senko-san f5a6b919aa fix(library): poll track list while enrichment is pending
The library tracks query wasn't refetching, so a track stayed on "Identifying metadata…" until an unrelated Track-tag invalidation. Poll every 4s while any listed track is metadata_status=pending, then stop (and never while offline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:09:47 +03:00
Senko-san 231887c3b7 feat(discover): wire A4 search + A5 downloads to backend
Adds DownloadJob/ExternalSearchResult/SourceInfo contract types + mappers, the downloads + search RTKQ endpoints, and the SearchDownloadPage (search external sources, per-result download states) and DownloadsManagerPage (active/history, progress, retry/cancel, poll-while-active). en/ru i18n. Snapshot also bundles in-progress queue/metadata-editor/storage UI work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:04:43 +03:00
25 changed files with 1197 additions and 92 deletions
+3 -3
View File
@@ -7,15 +7,15 @@ 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:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
host: ${{ steps.meta.outputs.host }} host: ${{ steps.meta.outputs.host }}
image: ${{ steps.meta.outputs.image }} image: ${{ steps.meta.outputs.image }}
sha: ${{ steps.meta.outputs.sha }} sha: ${{ steps.meta.outputs.sha }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+5 -2
View File
@@ -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, {
+80 -20
View File
@@ -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;
+48
View File
@@ -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;
+9
View File
@@ -5,6 +5,15 @@ import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate';
export const api = createApi({ export const api = createApi({
reducerPath: 'api', reducerPath: 'api',
baseQuery: baseQueryWithReauth, baseQuery: baseQueryWithReauth,
// Stale-while-revalidate. The Tier-2 rehydrated cache (below) seeds fulfilled
// entries at startup, which would otherwise make RTKQ serve stale data and
// never hit the network. These flags keep showing the cached snapshot
// instantly but silently refetch from the server whenever it's reachable —
// on mount/arg change, on reconnect, and on window refocus. The result: the
// server is the source of truth when online; the cache is only a fallback.
refetchOnMountOrArgChange: true,
refetchOnReconnect: true,
refetchOnFocus: true,
tagTypes: [ tagTypes: [
'Track', 'Track',
'Album', 'Album',
+84
View File
@@ -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
View File
@@ -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;
+3 -1
View File
@@ -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>
+18 -3
View File
@@ -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' },
]; ];
+4 -1
View File
@@ -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>
); );
} }
+30 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -54,8 +54,14 @@ export function LibraryPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300); const [debouncedSearch] = useDebounce(search, 300);
// Poll while any listed track is still being enriched, then stop. Enrichment
// runs asynchronously in a worker after import/upload; without this the row
// stays stuck on "Identifying metadata…" until something else invalidates the
// Track tag. Cleared to 0 once nothing is pending (and while offline).
const [tracksPollMs, setTracksPollMs] = useState(0);
const tracksQuery = useGetTracksQuery( const tracksQuery = useGetTracksQuery(
debouncedSearch ? { search } : undefined, debouncedSearch ? { search } : undefined,
{ pollingInterval: tracksPollMs },
); );
const albumsQuery = useGetAlbumsQuery( const albumsQuery = useGetAlbumsQuery(
debouncedSearch ? { search } : undefined, debouncedSearch ? { search } : undefined,
@@ -73,16 +79,36 @@ export function LibraryPage() {
const localArtists = useAppSelector(selectLocalArtists); const localArtists = useAppSelector(selectLocalArtists);
const q = debouncedSearch.trim().toLowerCase(); const q = debouncedSearch.trim().toLowerCase();
const anyPending =
!offline &&
(tracksQuery.data?.items.some((tr) => tr.metadataStatus === 'pending') ??
false);
useEffect(() => {
setTracksPollMs(anyPending ? 4000 : 0);
}, [anyPending]);
// 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>
); );
} }
+7 -1
View File
@@ -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
+6 -2
View File
@@ -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={{
+19 -5
View File
@@ -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
View File
@@ -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…',
+43
View File
@@ -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: 'Обработка…',
+1
View File
@@ -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';
+7 -2
View File
@@ -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 />} />
+5
View File
@@ -1,4 +1,5 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { api } from '../api'; import { api } from '../api';
import authReducer from './slices/auth'; import authReducer from './slices/auth';
import connectionReducer from './slices/connection'; import connectionReducer from './slices/connection';
@@ -27,6 +28,10 @@ export const store = configureStore({
getDefaultMiddleware().concat(api.middleware), getDefaultMiddleware().concat(api.middleware),
}); });
// Enable refetchOnReconnect / refetchOnFocus by dispatching the browser's
// online + focus events into RTKQ (no-op without the api flags set in api/index).
setupListeners(store.dispatch);
// Flush queue/player changes back to localStorage (throttled). // Flush queue/player changes back to localStorage (throttled).
startPersistence(store); startPersistence(store);
+5 -1
View File
@@ -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 {
+7 -1
View File
@@ -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;
} }
+3 -3
View File
@@ -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();
}); });