Add missing seasons endpoint and update metadata handling
ci / build-backend (push) Successful in 4m7s
ci / build-frontend (push) Successful in 41s
ci / tests (push) Successful in 2m4s
ci / sonar (push) Successful in 4m41s

Implemented a new endpoint to retrieve missing seasons for shows, enhancing the MetadataEndpoints class. Updated the IMetadataProvider interface to include a method for fetching season numbers. Adjusted the ShowSummaryDto to include other genres and modified the ListShowsQueryHandler to handle multiple genres. Enhanced the Omdb and Tmdb metadata providers to support season number retrieval. Updated frontend components to integrate missing seasons functionality, including API calls and UI elements for displaying missing seasons reports.
This commit is contained in:
Leonid Pershin
2026-07-27 02:40:09 +03:00
parent 07c43a6875
commit 986f7ff52d
17 changed files with 540 additions and 53 deletions
@@ -3,7 +3,12 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types'
import type {
MetadataCandidate,
MissingEpisodesReport,
MissingSeasonsReport,
ShowDto,
} from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
@@ -19,6 +24,7 @@ import {
applyMetadata,
clearMetadata,
findMissingEpisodes,
findMissingSeasons,
getMetadataProviders,
refreshEpisodesMetadata,
renameShow,
@@ -132,6 +138,12 @@ export function ShowMetadataCard({
onSuccess: (report) => setMissing(report),
onError,
})
const [missingSeasons, setMissingSeasons] = useState<MissingSeasonsReport | null>(null)
const findSeasons = useMutation({
mutationFn: () => findMissingSeasons(show.id),
onSuccess: (report) => setMissingSeasons(report),
onError,
})
const linked =
!!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual'
@@ -319,6 +331,17 @@ export function ShowMetadataCard({
{t('admin.metadata.findMissing')}
</Button>
)}
{linked && show.kind === 'Series' && (
<Button
size="sm"
variant="outline"
disabled={findSeasons.isPending}
onClick={() => findSeasons.mutate()}
>
{findSeasons.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{t('admin.metadata.findMissingSeasons')}
</Button>
)}
{(show.metadataProvider || show.posterImageId) && (
<Button
size="sm"
@@ -370,10 +393,53 @@ export function ShowMetadataCard({
)}
</DialogContent>
</Dialog>
<Dialog
open={missingSeasons != null}
onOpenChange={(open) => !open && setMissingSeasons(null)}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('admin.metadata.missingSeasonsTitle')}</DialogTitle>
</DialogHeader>
{missingSeasons && <MissingSeasonsBody report={missingSeasons} />}
</DialogContent>
</Dialog>
</>
)
}
/** Итог по сезонам: чего нет вовсе, что загружено сверх известного источнику. */
function MissingSeasonsBody({ report }: Readonly<{ report: MissingSeasonsReport }>) {
const { t } = useTranslation()
if (report.expected == null)
return (
<p className="text-sm text-amber-500">{t('admin.metadata.missingSeasonsUnknownTotal')}</p>
)
return (
<div className="flex flex-col gap-2 text-sm">
<p className="text-xs text-muted-foreground">
{t('admin.metadata.loadedOf', { loaded: report.loaded.length, total: report.expected })}
</p>
{report.missing.length === 0 ? (
<p className="text-emerald-500">{t('admin.metadata.missingSeasonsNone')}</p>
) : (
<p className="text-muted-foreground">
{t('admin.metadata.missingSeasonsList')}:{' '}
<span className="text-foreground">{report.missing.join(', ')}</span>
</p>
)}
{report.unknown.length > 0 && (
<p className="text-xs text-amber-500">
{t('admin.metadata.missingSeasonsUnknown', { list: report.unknown.join(', ') })}
</p>
)}
</div>
)
}
/** Итог по сезону: чего не хватает — или что полный состав сезона неизвестен. */
function SeasonGapNote({ gap }: Readonly<{ gap: MissingEpisodesReport['seasons'][number] }>) {
const { t } = useTranslation()
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import {
@@ -244,7 +244,9 @@ export function ShowsPanel() {
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-2 text-muted-foreground">{show.primaryGenre ?? '—'}</td>
<td className="px-4 py-2 text-muted-foreground">
<GenreCell primary={show.primaryGenre} others={show.otherGenres} />
</td>
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
<td className="px-4 py-2">
@@ -266,3 +268,70 @@ export function ShowsPanel() {
</div>
)
}
/** Основной жанр шоу; остальные — под кнопкой «…», всплывающим списком. */
function GenreCell({ primary, others }: Readonly<{ primary: string | null; others: string[] }>) {
const { t } = useTranslation()
const buttonRef = useRef<HTMLButtonElement>(null)
// Всплывашку позиционируем fixed по кнопке: таблица лежит в контейнере с overflow-x-auto,
// и absolute-список он обрезал бы по нижней границе. Прокрутка/ресайз сдвинули бы её мимо
// кнопки — на них просто закрываем.
const [at, setAt] = useState<{ left: number; top: number } | null>(null)
useEffect(() => {
if (at == null) return
const close = () => setAt(null)
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [at])
if (!primary) return <span></span>
if (others.length === 0) return <span>{primary}</span>
const toggle = () => {
if (at) {
setAt(null)
return
}
const rect = buttonRef.current?.getBoundingClientRect()
if (rect) setAt({ left: rect.left, top: rect.bottom + 4 })
}
return (
<span className="inline-flex items-center gap-1.5">
{primary}
<button
ref={buttonRef}
type="button"
title={t('admin.shows.otherGenres')}
aria-label={t('admin.shows.otherGenres')}
aria-expanded={at != null}
className="rounded-sm border border-border px-1 text-xs leading-tight hover:border-primary/60 hover:text-foreground"
onClick={toggle}
onBlur={() => setAt(null)}
onKeyDown={(e) => e.key === 'Escape' && setAt(null)}
>
</button>
{at && (
<div
className="crt-panel fixed z-50 max-w-56 rounded-md border border-border bg-background px-2 py-1.5 text-xs"
style={{ left: at.left, top: at.top }}
>
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('admin.shows.otherGenres')}
</p>
<ul className="flex flex-col gap-0.5 text-foreground">
{others.map((genre) => (
<li key={genre}>{genre}</li>
))}
</ul>
</div>
)}
</span>
)
}
+6
View File
@@ -3,6 +3,7 @@ import type {
CreatedIdResponse,
MetadataCandidate,
MissingEpisodesReport,
MissingSeasonsReport,
ShowAudience,
ShowDto,
ShowKind,
@@ -114,3 +115,8 @@ export function refreshEpisodesMetadata(showId: string) {
export function findMissingEpisodes(showId: string) {
return apiRequest<MissingEpisodesReport>(`/admin/metadata/shows/${showId}/missing-episodes`)
}
/** Отчёт: каких сезонов нет в библиотеке вовсе (по данным источника). */
export function findMissingSeasons(showId: string) {
return apiRequest<MissingSeasonsReport>(`/admin/metadata/shows/${showId}/missing-seasons`)
}