6efbf4b68b
/metadata/batch?status=pending|failed edits a whole bucket: bulk re-enrich (AcoustID/MusicBrainz) and stamp common fields onto the selection, with a select-list and per-track checkboxes. Storage Maintenance failed/missing tabs link straight into it, closing the A6→A7 flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
244 lines
7.4 KiB
TypeScript
244 lines
7.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
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';
|
|
|
|
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();
|
|
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;
|
|
// Broken files map to the `failed` metadata bucket; missing → `pending`.
|
|
const batchStatus = emptyKey === 'broken' ? 'failed' : 'pending';
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void navigate(`/metadata/batch?status=${batchStatus}`)}
|
|
>
|
|
{t('storage.maintenance.editBatch')}
|
|
</Button>
|
|
</div>
|
|
{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>
|
|
);
|
|
}
|