Add missing episodes feature: implement FindMissingEpisodes endpoint, update metadata providers to retrieve season episode counts, and enhance frontend components for displaying missing episodes report. Update translations for new UI elements.
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { MetadataCandidate, ShowDto } from '@/shared/api/types'
|
||||
import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
@@ -14,6 +16,7 @@ import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||
import {
|
||||
applyMetadata,
|
||||
clearMetadata,
|
||||
findMissingEpisodes,
|
||||
getMetadataProviders,
|
||||
refreshEpisodesMetadata,
|
||||
renameShow,
|
||||
@@ -34,6 +37,14 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
||||
const [description, setDescription] = useState(show.description ?? '')
|
||||
const [year, setYear] = useState(show.year != null ? String(show.year) : '')
|
||||
|
||||
// После «Применить»/обновления с сервера показанные значения меняются — подхватываем их в поля формы
|
||||
// (иначе описание/год оставались бы пустыми, хотя в БД уже записаны). Реагируем только на смену
|
||||
// серверных значений, так что ручной ввод между сохранениями не затирается.
|
||||
useEffect(() => {
|
||||
setDescription(show.description ?? '')
|
||||
setYear(show.year != null ? String(show.year) : '')
|
||||
}, [show.description, show.year])
|
||||
|
||||
const { data: providers } = useQuery({
|
||||
queryKey: ['admin', 'metadata', 'providers'],
|
||||
queryFn: getMetadataProviders,
|
||||
@@ -110,11 +121,18 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const [missing, setMissing] = useState<MissingEpisodesReport | null>(null)
|
||||
const findMissing = useMutation({
|
||||
mutationFn: () => findMissingEpisodes(show.id),
|
||||
onSuccess: (report) => setMissing(report),
|
||||
onError,
|
||||
})
|
||||
|
||||
const linked =
|
||||
!!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.metadata.title')}</CardTitle>
|
||||
@@ -260,11 +278,23 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
||||
disabled={refreshEpisodes.isPending}
|
||||
onClick={() => refreshEpisodes.mutate()}
|
||||
>
|
||||
{refreshEpisodes.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{refreshEpisodes.isPending
|
||||
? t('admin.metadata.refreshing')
|
||||
: t('admin.metadata.refreshEpisodes')}
|
||||
</Button>
|
||||
)}
|
||||
{linked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={findMissing.isPending}
|
||||
onClick={() => findMissing.mutate()}
|
||||
>
|
||||
{findMissing.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('admin.metadata.findMissing')}
|
||||
</Button>
|
||||
)}
|
||||
{(show.metadataProvider || show.posterImageId) && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -285,5 +315,50 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={missing != null} onOpenChange={(open) => !open && setMissing(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{missing && missing.seasons.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
|
||||
)}
|
||||
{missing && missing.seasons.length > 0 && (
|
||||
<div className="flex max-h-[60vh] flex-col gap-3 overflow-y-auto">
|
||||
{missing.seasons.map((s) => (
|
||||
<div key={s.season} className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{t('admin.metadata.seasonN', { n: s.season })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.metadata.loadedOf', {
|
||||
loaded: s.loaded,
|
||||
total: s.expected ?? '?',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{s.expected == null ? (
|
||||
<p className="mt-1 text-xs text-amber-500">
|
||||
{t('admin.metadata.missingUnknown')}
|
||||
</p>
|
||||
) : s.missing.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-emerald-500">
|
||||
{t('admin.metadata.missingNone')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t('admin.metadata.missingList')}:{' '}
|
||||
<span className="text-foreground">{s.missing.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiRequest } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
MetadataCandidate,
|
||||
MissingEpisodesReport,
|
||||
ShowAudience,
|
||||
ShowDto,
|
||||
ShowKind,
|
||||
@@ -94,3 +95,8 @@ export function setShowPoster(showId: string, imageId: string | null) {
|
||||
export function refreshEpisodesMetadata(showId: string) {
|
||||
return apiRequest<number>(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Отчёт: каких серий не хватает в загруженных сезонах (по данным источника). */
|
||||
export function findMissingEpisodes(showId: string) {
|
||||
return apiRequest<MissingEpisodesReport>(`/admin/metadata/shows/${showId}/missing-episodes`)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,17 @@ export type MetadataCandidate = {
|
||||
posterUrl: string | null
|
||||
}
|
||||
|
||||
export type SeasonGapDto = {
|
||||
season: number
|
||||
expected: number | null
|
||||
loaded: number
|
||||
missing: number[]
|
||||
}
|
||||
|
||||
export type MissingEpisodesReport = {
|
||||
seasons: SeasonGapDto[]
|
||||
}
|
||||
|
||||
export type EpisodeDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
|
||||
@@ -380,6 +380,14 @@ const resources = {
|
||||
refreshEpisodes: 'Обновить серии',
|
||||
refreshing: 'Обновляем…',
|
||||
refreshedCount: 'Обновлено серий: {{count}}',
|
||||
findMissing: 'Найти отсутствующие серии',
|
||||
missingTitle: 'Отсутствующие серии',
|
||||
missingNoSeasons: 'В шоу нет загруженных серий с распознанными номерами.',
|
||||
seasonN: 'Сезон {{n}}',
|
||||
loadedOf: 'загружено {{loaded}} из {{total}}',
|
||||
missingUnknown: 'Источник не отдал число серий этого сезона.',
|
||||
missingNone: 'Все серии на месте.',
|
||||
missingList: 'Не хватает',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -762,6 +770,14 @@ const resources = {
|
||||
refreshEpisodes: 'Refresh episodes',
|
||||
refreshing: 'Refreshing…',
|
||||
refreshedCount: 'Episodes updated: {{count}}',
|
||||
findMissing: 'Find missing episodes',
|
||||
missingTitle: 'Missing episodes',
|
||||
missingNoSeasons: 'No loaded episodes with recognized numbers.',
|
||||
seasonN: 'Season {{n}}',
|
||||
loadedOf: 'loaded {{loaded}} of {{total}}',
|
||||
missingUnknown: 'The source did not return the episode count for this season.',
|
||||
missingNone: 'All episodes present.',
|
||||
missingList: 'Missing',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user