diff --git a/src/api/endpoints/storage.ts b/src/api/endpoints/storage.ts index 4b1da32..81990b6 100644 --- a/src/api/endpoints/storage.ts +++ b/src/api/endpoints/storage.ts @@ -1,10 +1,32 @@ import { api } from '../index'; -import { toStorageStats, type RawStorageStats } from '../mappers'; -import type { StorageStats } from '../types'; +import { + 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 -// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`, -// `POST /storage/cleanup`) are still backend stubs and unused by the UI. +// routes back the Storage Maintenance screen: `/duplicates`, `/broken`, +// `/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({ endpoints: (build) => ({ @@ -13,16 +35,32 @@ export const storageApi = api.injectEndpoints({ transformResponse: (raw: RawStorageStats) => toStorageStats(raw), providesTags: ['Storage'], }), - scanStorage: build.mutation<{ jobId: string }, void>({ - query: () => ({ url: '/storage/scan', method: 'POST' }), - invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'], + getDuplicates: build.query({ + query: () => '/storage/duplicates', + transformResponse: (raw: RawDuplicateGroup[]) => + raw.map((g) => ({ + fingerprint: g.fingerprint, + tracks: g.tracks.map(toTrack), + })), + providesTags: ['Storage', 'Track'], }), - deleteTrackFile: build.mutation({ - query: (trackId) => ({ - url: `/storage/tracks/${trackId}`, - method: 'DELETE', + getBrokenFiles: build.query, Paging | void>({ + query: (p) => ({ url: '/storage/broken', params: p ?? {} }), + transformResponse: (raw: RawPaged) => toPage(raw, toTrack), + providesTags: ['Storage', 'Track'], + }), + getMissingMetadata: build.query, Paging | void>({ + query: (p) => ({ url: '/storage/missing-metadata', params: p ?? {} }), + transformResponse: (raw: RawPaged) => 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', { type: 'Track', id: undefined }], + invalidatesTags: ['Storage', 'Track'], }), }), overrideExisting: false, @@ -30,6 +68,8 @@ export const storageApi = api.injectEndpoints({ export const { useGetStorageStatsQuery, - useScanStorageMutation, - useDeleteTrackFileMutation, + useGetDuplicatesQuery, + useGetBrokenFilesQuery, + useGetMissingMetadataQuery, + useRunCleanupMutation, } = storageApi; diff --git a/src/api/types.ts b/src/api/types.ts index 975bbce..7434aa4 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -176,6 +176,12 @@ export interface StorageStats { disk?: StorageDiskUsage; } +/** A set of tracks sharing one acoustic fingerprint — de-dup candidates (§A6). */ +export interface DuplicateGroup { + fingerprint: string; + tracks: Track[]; +} + export interface User { id: string; username: string; diff --git a/src/features/storage/StorageMaintenancePage.tsx b/src/features/storage/StorageMaintenancePage.tsx index a926d1d..c487e1e 100644 --- a/src/features/storage/StorageMaintenancePage.tsx +++ b/src/features/storage/StorageMaintenancePage.tsx @@ -1,8 +1,232 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; 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() { const { t } = useTranslation(); - return ; + const offline = useIsOffline(); + const [tab, setTab] = useState('duplicates'); + + return ( +
+ +

+ {t('storage.maintenance.subtitle')} +

+ + {offline && ( + {t('common.offlineBanner')} + )} + + + +
+ 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') }, + ]} + /> +
+ + {/* Only the active tab is mounted, so only its query fires. */} + {tab === 'duplicates' && } + {tab === 'broken' && ( + + )} + {tab === 'missing' && ( + + )} +
+
+ ); +} + +/** 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 ( + +
+
+ {t('storage.maintenance.cleanup.title')} +
+
+ {t('storage.maintenance.cleanup.description')} +
+
+ {done && ( + + {t('storage.maintenance.cleanup.enqueued')} + + )} + +
+ ); +} + +/** Duplicate groups (tracks sharing an acoustic fingerprint). */ +function DuplicatesTab() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { data, isLoading, isError, refetch } = useGetDuplicatesQuery(); + + if (isLoading) return ; + if (isError) return ; + if (!data || data.length === 0) { + return ( + + ); + } + + return ( +
+ {data.map((group) => ( + +
+ {t('storage.maintenance.duplicates.groupTitle', { + count: group.tracks.length, + })} +
+ {group.tracks.map((track) => ( + void navigate(`/tracks/${tr.id}/metadata`)} + /> + ))} +
+ ))} +
+ ); +} + +/** 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 ; + if (isError) return ; + if (!data || data.items.length === 0) { + return ( + + ); + } + + const from = offset + 1; + const to = offset + data.items.length; + + return ( +
+ {data.items.map((track) => ( + void navigate(`/tracks/${tr.id}/metadata`)} + /> + ))} + {(offset > 0 || data.hasMore) && ( +
+ + {t('storage.maintenance.pagination', { + from, + to, + total: data.total, + })} + + + +
+ )} +
+ ); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c268b21..ffaaf0f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -286,6 +286,35 @@ const en = { }, storage: { 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', server: 'On the server', audioCache: 'Cached audio', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 5cb34bc..9aef23a 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -287,6 +287,35 @@ const ru: Translations = { }, storage: { 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: 'На этом устройстве', server: 'На сервере', audioCache: 'Кэш аудио',