Add missing seasons endpoint and update metadata handling
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -154,8 +154,10 @@ export type ShowSummaryDto = {
|
||||
year: number | null
|
||||
hasPoster: boolean
|
||||
createdAt: string
|
||||
/** Основной жанр — в списке показывается только он. */
|
||||
/** Основной жанр — его показывает колонка списка. */
|
||||
primaryGenre: string | null
|
||||
/** Прочие жанры — в списке прячутся под «…». */
|
||||
otherGenres: string[]
|
||||
}
|
||||
|
||||
type ShowGenreDto = {
|
||||
@@ -310,6 +312,17 @@ export type MissingEpisodesReport = {
|
||||
seasons: SeasonGapDto[]
|
||||
}
|
||||
|
||||
export type MissingSeasonsReport = {
|
||||
/** Сколько сезонов у сериала по источнику; null — источник не отдал данные. */
|
||||
expected: number | null
|
||||
/** Номера сезонов, где загружена хотя бы одна серия. */
|
||||
loaded: number[]
|
||||
/** Номера сезонов из источника, где не загружено ни одной серии. */
|
||||
missing: number[]
|
||||
/** Загруженные сезоны, которых источник не знает. */
|
||||
unknown: number[]
|
||||
}
|
||||
|
||||
type EpisodeDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
|
||||
@@ -308,6 +308,7 @@ export const en = {
|
||||
loadedSeasons: 'Loaded seasons',
|
||||
genre: 'Genre',
|
||||
allGenres: 'All genres',
|
||||
otherGenres: 'Other genres',
|
||||
genresEmpty: 'No genres set',
|
||||
genresEdit: 'Genres',
|
||||
genresHint:
|
||||
@@ -676,6 +677,12 @@ export const en = {
|
||||
missingUnknown: 'The source did not return the episode count for this season.',
|
||||
missingNone: 'All episodes present.',
|
||||
missingList: 'Missing',
|
||||
findMissingSeasons: 'Find missing seasons',
|
||||
missingSeasonsTitle: 'Missing seasons',
|
||||
missingSeasonsUnknownTotal: 'The source did not return the season list for this series.',
|
||||
missingSeasonsNone: 'All seasons present.',
|
||||
missingSeasonsList: 'Seasons with no loaded episodes',
|
||||
missingSeasonsUnknown: 'The source does not know these loaded seasons: {{list}}.',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ export const ru = {
|
||||
loadedSeasons: 'Загружены сезоны',
|
||||
genre: 'Жанр',
|
||||
allGenres: 'Все жанры',
|
||||
otherGenres: 'Остальные жанры',
|
||||
genresEmpty: 'Жанры не проставлены',
|
||||
genresEdit: 'Жанры',
|
||||
genresHint:
|
||||
@@ -682,6 +683,12 @@ export const ru = {
|
||||
missingUnknown: 'Источник не отдал число серий этого сезона.',
|
||||
missingNone: 'Все серии на месте.',
|
||||
missingList: 'Не хватает',
|
||||
findMissingSeasons: 'Найти отсутствующие сезоны',
|
||||
missingSeasonsTitle: 'Отсутствующие сезоны',
|
||||
missingSeasonsUnknownTotal: 'Источник не отдал список сезонов этого сериала.',
|
||||
missingSeasonsNone: 'Все сезоны на месте.',
|
||||
missingSeasonsList: 'Не загружено ни одной серии из сезонов',
|
||||
missingSeasonsUnknown: 'Источник не знает загруженные сезоны: {{list}}.',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user