feat(storage): wire Storage Maintenance screen (A6)
Duplicates / failed / missing-metadata tabs (paginated, reusing TrackRow) plus admin orphan-file cleanup. Adds getDuplicates/getBrokenFiles/getMissingMetadata/ runCleanup endpoints and a DuplicateGroup type; drops the dead scanStorage/ deleteTrackFile stubs that had no backend route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,32 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import { toStorageStats, type RawStorageStats } from '../mappers';
|
import {
|
||||||
import type { StorageStats } from '../types';
|
toStorageStats,
|
||||||
|
toPage,
|
||||||
|
toTrack,
|
||||||
|
type RawStorageStats,
|
||||||
|
type RawPaged,
|
||||||
|
type RawTrack,
|
||||||
|
} from '../mappers';
|
||||||
|
import type {
|
||||||
|
StorageStats,
|
||||||
|
DuplicateGroup,
|
||||||
|
PaginatedResponse,
|
||||||
|
Track,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
// `GET /storage` returns library + disk statistics (§A6). The maintenance
|
// `GET /storage` returns library + disk statistics (§A6). The maintenance
|
||||||
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
// routes back the Storage Maintenance screen: `/duplicates`, `/broken`,
|
||||||
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
|
// `/missing-metadata` (read) and `POST /cleanup` (admin, enqueues the worker).
|
||||||
|
|
||||||
|
interface RawDuplicateGroup {
|
||||||
|
fingerprint: string;
|
||||||
|
tracks: RawTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Paging {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const storageApi = api.injectEndpoints({
|
export const storageApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
@@ -13,16 +35,32 @@ export const storageApi = api.injectEndpoints({
|
|||||||
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
||||||
providesTags: ['Storage'],
|
providesTags: ['Storage'],
|
||||||
}),
|
}),
|
||||||
scanStorage: build.mutation<{ jobId: string }, void>({
|
getDuplicates: build.query<DuplicateGroup[], void>({
|
||||||
query: () => ({ url: '/storage/scan', method: 'POST' }),
|
query: () => '/storage/duplicates',
|
||||||
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
|
transformResponse: (raw: RawDuplicateGroup[]) =>
|
||||||
|
raw.map((g) => ({
|
||||||
|
fingerprint: g.fingerprint,
|
||||||
|
tracks: g.tracks.map(toTrack),
|
||||||
|
})),
|
||||||
|
providesTags: ['Storage', 'Track'],
|
||||||
}),
|
}),
|
||||||
deleteTrackFile: build.mutation<void, string>({
|
getBrokenFiles: build.query<PaginatedResponse<Track>, Paging | void>({
|
||||||
query: (trackId) => ({
|
query: (p) => ({ url: '/storage/broken', params: p ?? {} }),
|
||||||
url: `/storage/tracks/${trackId}`,
|
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
|
||||||
method: 'DELETE',
|
providesTags: ['Storage', 'Track'],
|
||||||
}),
|
}),
|
||||||
invalidatesTags: ['Storage', { type: 'Track', id: undefined }],
|
getMissingMetadata: build.query<PaginatedResponse<Track>, Paging | void>({
|
||||||
|
query: (p) => ({ url: '/storage/missing-metadata', params: p ?? {} }),
|
||||||
|
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
|
||||||
|
providesTags: ['Storage', 'Track'],
|
||||||
|
}),
|
||||||
|
runCleanup: build.mutation<{ status: string; jobId: string }, void>({
|
||||||
|
query: () => ({ url: '/storage/cleanup', method: 'POST' }),
|
||||||
|
transformResponse: (raw: { status: string; job_id: string }) => ({
|
||||||
|
status: raw.status,
|
||||||
|
jobId: raw.job_id,
|
||||||
|
}),
|
||||||
|
invalidatesTags: ['Storage', 'Track'],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
@@ -30,6 +68,8 @@ export const storageApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetStorageStatsQuery,
|
useGetStorageStatsQuery,
|
||||||
useScanStorageMutation,
|
useGetDuplicatesQuery,
|
||||||
useDeleteTrackFileMutation,
|
useGetBrokenFilesQuery,
|
||||||
|
useGetMissingMetadataQuery,
|
||||||
|
useRunCleanupMutation,
|
||||||
} = storageApi;
|
} = storageApi;
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ export interface StorageStats {
|
|||||||
disk?: StorageDiskUsage;
|
disk?: StorageDiskUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A set of tracks sharing one acoustic fingerprint — de-dup candidates (§A6). */
|
||||||
|
export interface DuplicateGroup {
|
||||||
|
fingerprint: string;
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -1,8 +1,232 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Placeholder } from '../../components/common/Placeholder';
|
import { Window, Card, Button, SegmentedControl, Callout } from '@olly/modern-sk';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { usePermissions } from '../../hooks/usePermissions';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
useGetDuplicatesQuery,
|
||||||
|
useGetBrokenFilesQuery,
|
||||||
|
useGetMissingMetadataQuery,
|
||||||
|
useRunCleanupMutation,
|
||||||
|
} from '../../api/endpoints/storage';
|
||||||
|
|
||||||
/** `/storage/maintenance` — A6 maintenance (dupes, broken files, cleanup). Scaffold only. */
|
const PAGE_SIZE = 50;
|
||||||
|
type Tab = 'duplicates' | 'broken' | 'missing';
|
||||||
|
|
||||||
|
/** `/storage/maintenance` — A6 maintenance: duplicates, broken files, missing
|
||||||
|
* metadata, and orphan cleanup. */
|
||||||
export function StorageMaintenancePage() {
|
export function StorageMaintenancePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <Placeholder title={t('pages.storageMaintenance')} />;
|
const offline = useIsOffline();
|
||||||
|
const [tab, setTab] = useState<Tab>('duplicates');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
|
<Window title={t('pages.storageMaintenance')}>
|
||||||
|
<p style={{ color: 'var(--color-text-2)', marginTop: 0 }}>
|
||||||
|
{t('storage.maintenance.subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{offline && (
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CleanupSection />
|
||||||
|
|
||||||
|
<div style={{ margin: '1.25rem 0' }}>
|
||||||
|
<SegmentedControl
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(v as Tab)}
|
||||||
|
items={[
|
||||||
|
{ value: 'duplicates', label: t('storage.maintenance.tabs.duplicates') },
|
||||||
|
{ value: 'broken', label: t('storage.maintenance.tabs.broken') },
|
||||||
|
{ value: 'missing', label: t('storage.maintenance.tabs.missing') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Only the active tab is mounted, so only its query fires. */}
|
||||||
|
{tab === 'duplicates' && <DuplicatesTab />}
|
||||||
|
{tab === 'broken' && (
|
||||||
|
<TrackListTab useQuery={useGetBrokenFilesQuery} emptyKey="broken" />
|
||||||
|
)}
|
||||||
|
{tab === 'missing' && (
|
||||||
|
<TrackListTab useQuery={useGetMissingMetadataQuery} emptyKey="missing" />
|
||||||
|
)}
|
||||||
|
</Window>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orphan-file cleanup — admin only (enqueues a worker job). */
|
||||||
|
function CleanupSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
const [runCleanup, { isLoading }] = useRunCleanupMutation();
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
const handle = async () => {
|
||||||
|
setDone(false);
|
||||||
|
await runCleanup().unwrap();
|
||||||
|
setDone(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{t('storage.maintenance.cleanup.title')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('storage.maintenance.cleanup.description')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{done && (
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--color-accent)' }}>
|
||||||
|
{t('storage.maintenance.cleanup.enqueued')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ember" disabled={isLoading} onClick={() => void handle()}>
|
||||||
|
{t('storage.maintenance.cleanup.button')}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duplicate groups (tracks sharing an acoustic fingerprint). */
|
||||||
|
function DuplicatesTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetDuplicatesQuery();
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSkeleton rows={5} height={56} />;
|
||||||
|
if (isError) return <ErrorState onRetry={refetch} />;
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState icon="✓" title={t('storage.maintenance.duplicates.empty')} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{data.map((group) => (
|
||||||
|
<Card key={group.fingerprint} style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.maintenance.duplicates.groupTitle', {
|
||||||
|
count: group.tracks.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{group.tracks.map((track) => (
|
||||||
|
<TrackRow
|
||||||
|
key={track.id}
|
||||||
|
track={track}
|
||||||
|
showAlbum
|
||||||
|
onEditMetadata={(tr) => void navigate(`/tracks/${tr.id}/metadata`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A paginated track list backed by a maintenance query (broken / missing). */
|
||||||
|
function TrackListTab({
|
||||||
|
useQuery,
|
||||||
|
emptyKey,
|
||||||
|
}: {
|
||||||
|
useQuery:
|
||||||
|
| typeof useGetBrokenFilesQuery
|
||||||
|
| typeof useGetMissingMetadataQuery;
|
||||||
|
emptyKey: 'broken' | 'missing';
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSkeleton rows={6} height={56} />;
|
||||||
|
if (isError) return <ErrorState onRetry={refetch} />;
|
||||||
|
if (!data || data.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="✓"
|
||||||
|
title={t(`storage.maintenance.${emptyKey}.empty`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = offset + 1;
|
||||||
|
const to = offset + data.items.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
{data.items.map((track) => (
|
||||||
|
<TrackRow
|
||||||
|
key={track.id}
|
||||||
|
track={track}
|
||||||
|
showAlbum
|
||||||
|
onEditMetadata={(tr) => void navigate(`/tracks/${tr.id}/metadata`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(offset > 0 || data.hasMore) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.75rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('storage.maintenance.pagination', {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
total: data.total,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
{t('storage.maintenance.prev')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={!data.hasMore}
|
||||||
|
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
{t('storage.maintenance.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,35 @@ const en = {
|
|||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Everything this instance has tucked away',
|
subtitle: 'Everything this instance has tucked away',
|
||||||
|
maintenance: {
|
||||||
|
subtitle:
|
||||||
|
'Find duplicates, tracks that failed enrichment, and files still awaiting metadata — then reconcile orphaned rows.',
|
||||||
|
tabs: {
|
||||||
|
duplicates: 'Duplicates',
|
||||||
|
broken: 'Failed',
|
||||||
|
missing: 'No metadata',
|
||||||
|
},
|
||||||
|
cleanup: {
|
||||||
|
title: 'Clean up orphaned files',
|
||||||
|
description:
|
||||||
|
'Scan the catalogue and remove rows whose backing file has vanished. Runs in the background.',
|
||||||
|
button: 'Run cleanup',
|
||||||
|
enqueued: 'Cleanup queued',
|
||||||
|
},
|
||||||
|
duplicates: {
|
||||||
|
empty: 'No duplicates found.',
|
||||||
|
groupTitle: '{{count}} tracks share a fingerprint',
|
||||||
|
},
|
||||||
|
broken: {
|
||||||
|
empty: 'No failed tracks.',
|
||||||
|
},
|
||||||
|
missing: {
|
||||||
|
empty: 'Every track has metadata.',
|
||||||
|
},
|
||||||
|
pagination: '{{from}}–{{to}} of {{total}}',
|
||||||
|
prev: 'Previous',
|
||||||
|
next: 'Next',
|
||||||
|
},
|
||||||
device: 'On this device',
|
device: 'On this device',
|
||||||
server: 'On the server',
|
server: 'On the server',
|
||||||
audioCache: 'Cached audio',
|
audioCache: 'Cached audio',
|
||||||
|
|||||||
@@ -287,6 +287,35 @@ const ru: Translations = {
|
|||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Всё, что хранит этот инстанс',
|
subtitle: 'Всё, что хранит этот инстанс',
|
||||||
|
maintenance: {
|
||||||
|
subtitle:
|
||||||
|
'Найдите дубликаты, треки с ошибкой обогащения и файлы без метаданных — и уберите осиротевшие записи.',
|
||||||
|
tabs: {
|
||||||
|
duplicates: 'Дубликаты',
|
||||||
|
broken: 'С ошибкой',
|
||||||
|
missing: 'Без метаданных',
|
||||||
|
},
|
||||||
|
cleanup: {
|
||||||
|
title: 'Очистить осиротевшие файлы',
|
||||||
|
description:
|
||||||
|
'Сверить каталог и удалить записи, у которых пропал файл на диске. Выполняется в фоне.',
|
||||||
|
button: 'Запустить очистку',
|
||||||
|
enqueued: 'Очистка поставлена в очередь',
|
||||||
|
},
|
||||||
|
duplicates: {
|
||||||
|
empty: 'Дубликатов не найдено.',
|
||||||
|
groupTitle: '{{count}} треков с одним отпечатком',
|
||||||
|
},
|
||||||
|
broken: {
|
||||||
|
empty: 'Треков с ошибкой нет.',
|
||||||
|
},
|
||||||
|
missing: {
|
||||||
|
empty: 'У всех треков есть метаданные.',
|
||||||
|
},
|
||||||
|
pagination: '{{from}}–{{to}} из {{total}}',
|
||||||
|
prev: 'Назад',
|
||||||
|
next: 'Вперёд',
|
||||||
|
},
|
||||||
device: 'На этом устройстве',
|
device: 'На этом устройстве',
|
||||||
server: 'На сервере',
|
server: 'На сервере',
|
||||||
audioCache: 'Кэш аудио',
|
audioCache: 'Кэш аудио',
|
||||||
|
|||||||
Reference in New Issue
Block a user