Add metadata management for shows: implement metadata retrieval, application, and updates in the API and UI. Enhance Show and ShowDto models to include metadata fields, and update the database schema accordingly. Introduce new endpoints for metadata operations and integrate metadata display in the ShowDetail component.
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
parseEpisodeName,
|
||||
} from '@/features/admin/media/episode-parse'
|
||||
import { formatDuration } from '@/features/admin/media/MediaPanel'
|
||||
import { ShowMetadataCard } from './ShowMetadataCard'
|
||||
import { addEpisode, getShow, removeEpisode } from './api'
|
||||
|
||||
type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
|
||||
@@ -140,6 +141,8 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ShowMetadataCard show={show} onChanged={invalidate} />
|
||||
|
||||
{canAdd && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { MetadataCandidate, ShowDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
applyMetadata,
|
||||
clearMetadata,
|
||||
getMetadataProviders,
|
||||
searchMetadata,
|
||||
showPosterUrl,
|
||||
updateMetadata,
|
||||
uploadPoster,
|
||||
} from './api'
|
||||
|
||||
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const posterInput = useRef<HTMLInputElement>(null)
|
||||
const [bust, setBust] = useState(0)
|
||||
const [provider, setProvider] = useState('')
|
||||
const [query, setQuery] = useState(show.name)
|
||||
const [results, setResults] = useState<MetadataCandidate[]>([])
|
||||
const [description, setDescription] = useState(show.description ?? '')
|
||||
const [year, setYear] = useState(show.year != null ? String(show.year) : '')
|
||||
|
||||
const { data: providers } = useQuery({
|
||||
queryKey: ['admin', 'metadata', 'providers'],
|
||||
queryFn: getMetadataProviders,
|
||||
})
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
const changed = () => {
|
||||
setBust(Date.now())
|
||||
onChanged()
|
||||
}
|
||||
|
||||
const effectiveProvider = provider || providers?.[0] || ''
|
||||
|
||||
const search = useMutation({
|
||||
mutationFn: () => searchMetadata(effectiveProvider, query.trim()),
|
||||
onSuccess: setResults,
|
||||
onError,
|
||||
})
|
||||
const apply = useMutation({
|
||||
mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId),
|
||||
onSuccess: () => {
|
||||
setResults([])
|
||||
toast.success(t('admin.metadata.applied'))
|
||||
changed()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const saveManual = useMutation({
|
||||
mutationFn: () =>
|
||||
updateMetadata(show.id, {
|
||||
description: description.trim() || null,
|
||||
year: year.trim() ? Number(year) : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
changed()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const clear = useMutation({
|
||||
mutationFn: () => clearMetadata(show.id),
|
||||
onSuccess: () => {
|
||||
setDescription('')
|
||||
setYear('')
|
||||
changed()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const posterUpload = useMutation({
|
||||
mutationFn: (file: File) => uploadPoster(show.id, file),
|
||||
onSuccess: changed,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.metadata.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 sm:flex-row">
|
||||
{/* Постер */}
|
||||
<div className="flex w-40 shrink-0 flex-col gap-2">
|
||||
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
|
||||
{show.hasPoster ? (
|
||||
<img
|
||||
src={showPosterUrl(show.id, String(bust))}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={posterInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) posterUpload.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={posterUpload.isPending}
|
||||
onClick={() => posterInput.current?.click()}
|
||||
>
|
||||
{t('admin.metadata.uploadPoster')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Поиск + ручная правка */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
{providers && providers.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.source')}</Label>
|
||||
<Select value={effectiveProvider} onValueChange={setProvider}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
className="min-w-40 flex-1"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('admin.metadata.searchPlaceholder')}
|
||||
/>
|
||||
<Button size="sm" disabled={search.isPending || !query.trim()} onClick={() => search.mutate()}>
|
||||
{t('admin.metadata.searchBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
|
||||
{results.map((r) => (
|
||||
<li key={r.externalId} className="flex items-start gap-3 p-2">
|
||||
{r.posterUrl ? (
|
||||
<img src={r.posterUrl} alt="" className="h-16 w-11 shrink-0 rounded object-cover" />
|
||||
) : (
|
||||
<div className="h-16 w-11 shrink-0 rounded bg-muted/40" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
{r.title}
|
||||
{r.year != null && (
|
||||
<span className="text-muted-foreground"> ({r.year})</span>
|
||||
)}
|
||||
</div>
|
||||
{r.overview && (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">{r.overview}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={apply.isPending}
|
||||
onClick={() => apply.mutate(r.externalId)}
|
||||
>
|
||||
{t('admin.metadata.apply')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.overview')}</Label>
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-24 flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.year')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" disabled={saveManual.isPending} onClick={() => saveManual.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{(show.metadataProvider || show.hasPoster) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={clear.isPending}
|
||||
onClick={() => clear.mutate()}
|
||||
>
|
||||
{t('admin.metadata.clear')}
|
||||
</Button>
|
||||
)}
|
||||
{show.metadataProvider && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.metadata.sourceLabel')}: {show.metadataProvider}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { CreatedIdResponse, ShowDto, ShowKind, ShowSummaryDto } from '@/shared/api/types'
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
MetadataCandidate,
|
||||
ShowDto,
|
||||
ShowKind,
|
||||
ShowSummaryDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listShows() {
|
||||
return apiRequest<ShowSummaryDto[]>('/admin/shows')
|
||||
@@ -27,3 +33,60 @@ export function addEpisode(showId: string, mediaAssetId: string) {
|
||||
export function removeEpisode(showId: string, episodeId: string) {
|
||||
return apiRequest<void>(`/admin/shows/${showId}/episodes/${episodeId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Метаданные ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getMetadataProviders() {
|
||||
return apiRequest<string[]>('/admin/metadata/providers')
|
||||
}
|
||||
|
||||
export function searchMetadata(provider: string, query: string) {
|
||||
const q = new URLSearchParams({ provider, query })
|
||||
return apiRequest<MetadataCandidate[]>(`/admin/metadata/search?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function applyMetadata(showId: string, provider: string, externalId: string) {
|
||||
return apiRequest<void>(`/admin/metadata/shows/${showId}/apply`, {
|
||||
method: 'POST',
|
||||
body: { provider, externalId },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMetadata(showId: string, body: { description: string | null; year: number | null }) {
|
||||
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function clearMetadata(showId: string) {
|
||||
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Ссылка на локальный постер шоу (публичный эндпоинт; cache-buster — по флагу наличия). */
|
||||
export function showPosterUrl(showId: string, bust?: string) {
|
||||
return `/api/metadata/shows/${showId}/poster${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
|
||||
}
|
||||
|
||||
/** Загрузка постера вручную (сырое тело, имя в query — как uploadMedia). */
|
||||
export function uploadPoster(showId: string, file: File): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const q = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open('PUT', `/api/admin/metadata/shows/${showId}/poster?${q.toString()}`)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve()
|
||||
else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const p = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = p.detail ?? p.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,9 +71,19 @@ export type ShowSummaryDto = {
|
||||
kind: ShowKind
|
||||
episodeCount: number
|
||||
seasonCount: number
|
||||
year: number | null
|
||||
hasPoster: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type MetadataCandidate = {
|
||||
externalId: string
|
||||
title: string
|
||||
year: number | null
|
||||
overview: string | null
|
||||
posterUrl: string | null
|
||||
}
|
||||
|
||||
export type EpisodeDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
@@ -88,6 +98,10 @@ export type ShowDto = {
|
||||
name: string
|
||||
kind: ShowKind
|
||||
description: string | null
|
||||
metadataProvider: string | null
|
||||
metadataExternalId: string | null
|
||||
year: number | null
|
||||
hasPoster: boolean
|
||||
episodes: EpisodeDto[]
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,20 @@ const resources = {
|
||||
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
|
||||
registrationLabel: 'Разрешить регистрацию на сайте',
|
||||
},
|
||||
metadata: {
|
||||
title: 'Метаданные',
|
||||
source: 'Источник',
|
||||
sourceLabel: 'Источник',
|
||||
searchPlaceholder: 'Название для поиска',
|
||||
searchBtn: 'Искать',
|
||||
apply: 'Применить',
|
||||
applied: 'Метаданные применены',
|
||||
overview: 'Описание',
|
||||
year: 'Год',
|
||||
clear: 'Очистить',
|
||||
uploadPoster: 'Загрузить постер',
|
||||
noPoster: 'Нет постера',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -521,6 +535,20 @@ const resources = {
|
||||
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
|
||||
registrationLabel: 'Allow public registration',
|
||||
},
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
source: 'Source',
|
||||
sourceLabel: 'Source',
|
||||
searchPlaceholder: 'Title to search',
|
||||
searchBtn: 'Search',
|
||||
apply: 'Apply',
|
||||
applied: 'Metadata applied',
|
||||
overview: 'Overview',
|
||||
year: 'Year',
|
||||
clear: 'Clear',
|
||||
uploadPoster: 'Upload poster',
|
||||
noPoster: 'No poster',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user