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:
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user