diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx
new file mode 100644
index 0000000..ff34074
--- /dev/null
+++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx
@@ -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
(null)
+ const [bust, setBust] = useState(0)
+ const [provider, setProvider] = useState('')
+ const [query, setQuery] = useState(show.name)
+ const [results, setResults] = useState([])
+ 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 (
+
+
+ {t('admin.metadata.title')}
+
+
+ {/* Постер */}
+
+
+ {show.hasPoster ? (
+

+ ) : (
+
{t('admin.metadata.noPoster')}
+ )}
+
+
{
+ const file = e.target.files?.[0]
+ if (file) posterUpload.mutate(file)
+ e.target.value = ''
+ }}
+ />
+
+
+
+ {/* Поиск + ручная правка */}
+
+ {providers && providers.length > 0 && (
+
+
+
+
+
+
+
setQuery(e.target.value)}
+ placeholder={t('admin.metadata.searchPlaceholder')}
+ />
+
+
+
+ {results.length > 0 && (
+
+ {results.map((r) => (
+ -
+ {r.posterUrl ? (
+
+ ) : (
+
+ )}
+
+
+ {r.title}
+ {r.year != null && (
+ ({r.year})
+ )}
+
+ {r.overview && (
+
{r.overview}
+ )}
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+ setYear(e.target.value)}
+ />
+
+
+
+
+ {(show.metadataProvider || show.hasPoster) && (
+
+ )}
+ {show.metadataProvider && (
+
+ {t('admin.metadata.sourceLabel')}: {show.metadataProvider}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/admin/shows/api.ts b/frontend/src/features/admin/shows/api.ts
index 040a5f7..4294718 100644
--- a/frontend/src/features/admin/shows/api.ts
+++ b/frontend/src/features/admin/shows/api.ts
@@ -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('/admin/shows')
@@ -27,3 +33,60 @@ export function addEpisode(showId: string, mediaAssetId: string) {
export function removeEpisode(showId: string, episodeId: string) {
return apiRequest(`/admin/shows/${showId}/episodes/${episodeId}`, { method: 'DELETE' })
}
+
+// ── Метаданные ────────────────────────────────────────────────────────────
+
+export function getMetadataProviders() {
+ return apiRequest('/admin/metadata/providers')
+}
+
+export function searchMetadata(provider: string, query: string) {
+ const q = new URLSearchParams({ provider, query })
+ return apiRequest(`/admin/metadata/search?${q.toString()}`)
+}
+
+export function applyMetadata(showId: string, provider: string, externalId: string) {
+ return apiRequest(`/admin/metadata/shows/${showId}/apply`, {
+ method: 'POST',
+ body: { provider, externalId },
+ })
+}
+
+export function updateMetadata(showId: string, body: { description: string | null; year: number | null }) {
+ return apiRequest(`/admin/metadata/shows/${showId}`, { method: 'PUT', body })
+}
+
+export function clearMetadata(showId: string) {
+ return apiRequest(`/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 {
+ 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)
+ })
+}
diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts
index e024a1a..ac9e8dd 100644
--- a/frontend/src/shared/api/types.ts
+++ b/frontend/src/shared/api/types.ts
@@ -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[]
}
diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts
index 2e4813c..1f88fc3 100644
--- a/frontend/src/shared/lib/i18n.ts
+++ b/frontend/src/shared/lib/i18n.ts
@@ -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',
+ },
},
},
},