Implement episode metadata management: add functionality to refresh episode metadata from external sources, including new API endpoints and UI integration. Enhance Show and Episode models to support additional metadata fields, and update database schema accordingly. Update ShowDetail and ShowMetadataCard components to display refreshed episode information and provide user feedback on metadata updates.

This commit is contained in:
Leonid Pershin
2026-07-25 09:46:19 +03:00
parent 0d2dee815e
commit 7fb46b5e0d
30 changed files with 1563 additions and 81 deletions
@@ -18,7 +18,7 @@ import {
} from '@/features/admin/media/episode-parse'
import { formatDuration } from '@/features/admin/media/MediaPanel'
import { ShowMetadataCard } from './ShowMetadataCard'
import { addEpisode, getShow, removeEpisode } from './api'
import { addEpisode, episodeStillUrl, getShow, removeEpisode } from './api'
type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
@@ -209,14 +209,32 @@ export function ShowDetail({ showId }: { showId: string }) {
</thead>
<tbody>
{show.episodes.map((episode, index) => {
const label = formatSeasonEpisode(parseEpisodeName(episode.assetName ?? ''))
const parsed =
episode.season != null && episode.episode != null
? { season: episode.season, episode: episode.episode }
: parseEpisodeName(episode.assetName ?? '')
const label = formatSeasonEpisode(parsed)
return (
<tr key={episode.id} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-muted-foreground">{index + 1}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{episode.hasStill && (
<img
src={episodeStillUrl(episode.id)}
alt=""
className="h-9 w-16 shrink-0 rounded object-cover"
/>
)}
{label && <Badge>{label}</Badge>}
<span>{episode.assetName ?? '—'}</span>
<div className="min-w-0">
<div className="truncate">{episode.title ?? episode.assetName ?? '—'}</div>
{episode.title && (
<div className="truncate text-xs text-muted-foreground">
{episode.assetName}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-2 text-muted-foreground">
@@ -13,6 +13,7 @@ import {
applyMetadata,
clearMetadata,
getMetadataProviders,
refreshEpisodesMetadata,
searchMetadata,
showPosterUrl,
updateMetadata,
@@ -83,6 +84,17 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
onSuccess: changed,
onError,
})
const refreshEpisodes = useMutation({
mutationFn: () => refreshEpisodesMetadata(show.id),
onSuccess: (count) => {
toast.success(t('admin.metadata.refreshedCount', { count }))
onChanged()
},
onError,
})
const linked =
!!show.metadataExternalId && !!show.metadataProvider && show.metadataProvider !== 'manual'
return (
<Card>
@@ -209,10 +221,22 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" disabled={saveManual.isPending} onClick={() => saveManual.mutate()}>
{t('common.save')}
</Button>
{linked && (
<Button
size="sm"
variant="outline"
disabled={refreshEpisodes.isPending}
onClick={() => refreshEpisodes.mutate()}
>
{refreshEpisodes.isPending
? t('admin.metadata.refreshing')
: t('admin.metadata.refreshEpisodes')}
</Button>
)}
{(show.metadataProvider || show.hasPoster) && (
<Button
size="sm"
+10
View File
@@ -65,6 +65,16 @@ export function showPosterUrl(showId: string, bust?: string) {
return `/api/metadata/shows/${showId}/poster${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
}
/** Ссылка на локальный кадр серии. */
export function episodeStillUrl(episodeId: string, bust?: string) {
return `/api/metadata/episodes/${episodeId}/still${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
}
/** Довыгрузить метаданные серий из привязанного источника. Возвращает число обновлённых. */
export function refreshEpisodesMetadata(showId: string) {
return apiRequest<number>(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' })
}
/** Загрузка постера вручную (сырое тело, имя в query — как uploadMedia). */
export function uploadPoster(showId: string, file: File): Promise<void> {
return new Promise((resolve, reject) => {
+65 -20
View File
@@ -2,12 +2,12 @@ import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio, RotateCw } from 'lucide-react'
import type { ScheduleEntryDto } from '@/shared/api/types'
import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, listChannels, watchChannel } from './api'
import { episodeStillUrl, getEpg, listChannels, showPosterUrl, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -62,7 +62,7 @@ export function AirPage() {
refetchInterval: 60_000,
})
const { current, upcoming } = useMemo(() => buildGuide(epg ?? []), [epg])
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
@@ -89,8 +89,23 @@ export function AirPage() {
selected === channel.slug && 'border-primary text-primary',
)}
>
<Radio className="h-4 w-4" />
{channel.name}
{channel.currentShowHasPoster && channel.currentShowId ? (
<img
src={showPosterUrl(channel.currentShowId)}
alt=""
className="h-10 w-7 shrink-0 rounded object-cover"
/>
) : (
<Radio className="h-4 w-4 shrink-0" />
)}
<span className="flex min-w-0 flex-col">
<span className="truncate">{channel.name}</span>
{channel.currentShowName && (
<span className="truncate text-xs text-muted-foreground">
{channel.currentShowName}
</span>
)}
</span>
</button>
))}
</aside>
@@ -122,14 +137,37 @@ export function AirPage() {
<div className="flex flex-col gap-3">
{current && (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{current.showName}</span>
<div className="crt-panel flex gap-3 rounded-md p-3">
{currentEntry?.episodeHasStill && currentEntry.episodeId ? (
<img
src={episodeStillUrl(currentEntry.episodeId)}
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
) : currentEntry?.showHasPoster && current.showId ? (
<img
src={showPosterUrl(current.showId)}
alt=""
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
/>
) : null}
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{current.showName}</span>
</div>
{currentEntry?.episodeTitle && (
<span className="text-sm">{currentEntry.episodeTitle}</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
{currentEntry?.episodeOverview && (
<p className="line-clamp-3 text-xs text-muted-foreground">
{currentEntry.episodeOverview}
</p>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
</div>
)}
@@ -166,10 +204,14 @@ type GuideBlock = {
}
/**
* Строит телегид: рекламу не показываем, а подряд идущие серии одного шоу склеиваем в один блок
* с диапазоном «с – по». Реклама между сериями одного шоу поглощается блоком (как в обычном EPG).
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
*/
function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcoming: GuideBlock[] } {
function buildGuide(entries: PublicEpgEntryDto[]): {
current?: GuideBlock
upcoming: GuideBlock[]
currentEntry?: PublicEpgEntryDto
} {
const blocks: GuideBlock[] = []
for (const entry of entries) {
if (entry.kind !== 'Program') continue
@@ -178,7 +220,7 @@ function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcomi
last.endsAtUtc = entry.endsAtUtc
} else {
blocks.push({
key: entry.id,
key: entry.startsAtUtc,
showId: entry.showId,
showName: entry.showName ?? '—',
startsAtUtc: entry.startsAtUtc,
@@ -188,9 +230,12 @@ function buildGuide(entries: ScheduleEntryDto[]): { current?: GuideBlock; upcomi
}
const now = Date.now()
const current = blocks.find(
(b) => new Date(b.startsAtUtc).getTime() <= now && new Date(b.endsAtUtc).getTime() > now,
)
const active = (start: string, end: string) =>
new Date(start).getTime() <= now && new Date(end).getTime() > now
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
return { current, upcoming }
const currentEntry = entries.find(
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
)
return { current, upcoming, currentEntry }
}
+10 -2
View File
@@ -1,5 +1,13 @@
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, ScheduleEntryDto } from '@/shared/api/types'
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
/** Ссылки на локальные постер шоу / кадр серии (публичные эндпоинты метаданных). */
export function showPosterUrl(showId: string) {
return `/api/metadata/shows/${showId}/poster`
}
export function episodeStillUrl(episodeId: string) {
return `/api/metadata/episodes/${episodeId}/still`
}
export function listChannels() {
return apiRequest<PublicChannelDto[]>('/channels')
@@ -15,5 +23,5 @@ export function getEpg(slug: string, from?: Date, to?: Date) {
if (from) query.set('from', from.toISOString())
if (to) query.set('to', to.toISOString())
const qs = query.toString()
return apiRequest<ScheduleEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
}