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:
Цвылев Александр Вадимович
2026-07-28 20:42:05 +03:00
parent 28d9e59e34
commit 3eba3701d0
5 changed files with 345 additions and 17 deletions
+54 -14
View File
@@ -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;
+6
View File
@@ -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;
+227 -3
View File
@@ -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>
);
} }
+29
View File
@@ -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',
+29
View File
@@ -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: 'Кэш аудио',