Implement bulk tagging and enriching of shows with new API endpoints and frontend integration
Added new API endpoints for bulk tagging and enriching shows, allowing for mass updates of genres and audience ratings. Implemented backend logic to handle bulk operations and updated the Dependency Injection configuration to include necessary services. Enhanced the frontend with new components for selecting shows and applying bulk actions, improving the user experience for managing multiple shows simultaneously. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
@@ -101,6 +101,26 @@ export function GroupFilterPanel({
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.audienceHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Нижняя граница — под ночные слоты. Ведёт себя не зеркально верхней, поэтому и подсказка своя. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.minAudience')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={filter.minAudience ?? ''}
|
||||
onChange={(e) =>
|
||||
patch({ minAudience: e.target.value === '' ? null : (e.target.value as ShowAudience) })
|
||||
}
|
||||
>
|
||||
<option value="">{t('admin.groups.filter.any')}</option>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.minAudienceHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.year')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Sparkles, Tags } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { AUDIENCE_UNSET, SHOW_AUDIENCES, type ShowAudience } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { bulkEnrichShows, bulkTagShows, getMetadataProviders } from './api'
|
||||
|
||||
/** Значение «не трогать рейтинг» в селекте — отдельное от «снять рейтинг» (AUDIENCE_UNSET). */
|
||||
const KEEP = 'keep'
|
||||
|
||||
/**
|
||||
* Массовые действия над отмеченными шоу. Появляется, только когда что-то отмечено, — в обычном
|
||||
* просмотре списка не мешает.
|
||||
*
|
||||
* Жанры дописываются, а не заменяют: у шоу уже есть жанры из метаданных, и разметка «это ещё и
|
||||
* детское» не должна их стирать. Замену при необходимости делают на карточке шоу.
|
||||
*/
|
||||
export function BulkTagBar({
|
||||
showIds,
|
||||
onDone,
|
||||
}: Readonly<{ showIds: string[]; onDone: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const onError = useApiError()
|
||||
const [genreId, setGenreId] = useState('')
|
||||
const [audience, setAudience] = useState<string>(KEEP)
|
||||
|
||||
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
|
||||
const { data: providers } = useQuery({
|
||||
queryKey: qk.metadata.providers,
|
||||
queryFn: getMetadataProviders,
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||
onDone()
|
||||
}
|
||||
|
||||
const tag = useMutation({
|
||||
mutationFn: () =>
|
||||
bulkTagShows({
|
||||
showIds,
|
||||
genreIds: genreId ? [genreId] : undefined,
|
||||
audience: audience === AUDIENCE_UNSET ? null : (audience as ShowAudience),
|
||||
setAudience: audience !== KEEP,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('admin.shows.bulk.tagged', { count: result.updated }))
|
||||
setGenreId('')
|
||||
setAudience(KEEP)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const enrich = useMutation({
|
||||
mutationFn: () => bulkEnrichShows(showIds, providers?.[0] ?? ''),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('admin.shows.bulk.enriched', { count: result.applied }))
|
||||
// Пропущенное показываем по одному: у каждого своя причина, и это подсказка, что делать руками.
|
||||
for (const skip of result.skipped.slice(0, 5)) toast.error(`${skip.showName}: ${skip.reason}`)
|
||||
if (result.skipped.length > 5)
|
||||
toast.error(t('admin.shows.bulk.moreSkipped', { count: result.skipped.length - 5 }))
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const nothingToApply = !genreId && audience === KEEP
|
||||
|
||||
return (
|
||||
<div className="crt-panel flex flex-wrap items-center gap-2 rounded-md px-3 py-2 text-sm">
|
||||
<span className="font-medium">
|
||||
{t('admin.shows.bulk.selected', { count: showIds.length })}
|
||||
</span>
|
||||
|
||||
<Select value={genreId || 'none'} onValueChange={(v) => setGenreId(v === 'none' ? '' : v)}>
|
||||
<SelectTrigger className="h-8 w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.shows.bulk.noGenre')}</SelectItem>
|
||||
{(genres ?? []).map((genre) => (
|
||||
<SelectItem key={genre.id} value={genre.id}>
|
||||
{genre.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={audience} onValueChange={setAudience}>
|
||||
<SelectTrigger className="h-8 w-64 whitespace-nowrap">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={KEEP}>{t('admin.shows.bulk.keepAudience')}</SelectItem>
|
||||
<SelectItem value={AUDIENCE_UNSET}>{t('admin.shows.audienceUnset')}</SelectItem>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button size="sm" disabled={nothingToApply || tag.isPending} onClick={() => tag.mutate()}>
|
||||
<Tags className="h-4 w-4" />
|
||||
{t('admin.shows.bulk.apply')}
|
||||
</Button>
|
||||
|
||||
{providers && providers.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={enrich.isPending}
|
||||
title={t('admin.shows.bulk.enrichHint')}
|
||||
onClick={() => enrich.mutate()}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{enrich.isPending
|
||||
? t('admin.shows.bulk.enriching')
|
||||
: t('admin.shows.bulk.enrich', { provider: providers[0].toUpperCase() })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
||||
{t('admin.shows.bulk.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import { createShow, deleteShow, listShows } from './api'
|
||||
import { BulkTagBar } from './BulkTagBar'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -40,6 +41,8 @@ export function ShowsPanel() {
|
||||
|
||||
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
|
||||
const [genreFilter, setGenreFilter] = useState('all')
|
||||
// Отмеченные для массовых действий. Живут отдельно от фильтров: сузил список — выбор не потерялся.
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.shows.byGenre(genreFilter),
|
||||
@@ -171,10 +174,26 @@ export function ShowsPanel() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && <BulkTagBar showIds={selected} onDone={() => setSelected([])} />}
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-8 px-4 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('admin.shows.bulk.selectPage')}
|
||||
checked={pageItems.length > 0 && pageItems.every((s) => selected.includes(s.id))}
|
||||
onChange={(e) =>
|
||||
setSelected((current) =>
|
||||
e.target.checked
|
||||
? [...new Set([...current, ...pageItems.map((s) => s.id)])]
|
||||
: current.filter((id) => !pageItems.some((s) => s.id === id)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<SortHeader
|
||||
label={t('admin.shows.name')}
|
||||
sortKey="name"
|
||||
@@ -217,13 +236,27 @@ export function ShowsPanel() {
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={7}>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={8}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{pageItems.map((show) => (
|
||||
<tr key={show.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={show.name}
|
||||
checked={selected.includes(show.id)}
|
||||
onChange={() =>
|
||||
setSelected((current) =>
|
||||
current.includes(show.id)
|
||||
? current.filter((id) => id !== show.id)
|
||||
: [...current, show.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
to="/admin/shows/$showId"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
EnrichShowsResult,
|
||||
MetadataCandidate,
|
||||
MissingEpisodesReport,
|
||||
MissingSeasonsReport,
|
||||
@@ -53,6 +54,25 @@ export function setShowOriginalName(id: string, originalName: string | null) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Массовая разметка отмеченных шоу: жанры и рейтинг применяются независимо. */
|
||||
export function bulkTagShows(body: {
|
||||
showIds: string[]
|
||||
genreIds?: string[]
|
||||
replaceGenres?: boolean
|
||||
audience?: ShowAudience | null
|
||||
setAudience?: boolean
|
||||
}) {
|
||||
return apiRequest<{ updated: number }>('/admin/shows/bulk/tag', { method: 'POST', body })
|
||||
}
|
||||
|
||||
/** Массовое обогащение: найти метаданные по названию и применить тем, у кого нет привязки. */
|
||||
export function bulkEnrichShows(showIds: string[], provider: string) {
|
||||
return apiRequest<EnrichShowsResult>('/admin/shows/bulk/enrich', {
|
||||
method: 'POST',
|
||||
body: { showIds, provider },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteShow(id: string) {
|
||||
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ export type GroupFilter = {
|
||||
showKinds?: ShowKind[] | null
|
||||
genreIds?: string[] | null
|
||||
maxAudience?: ShowAudience | null
|
||||
minAudience?: ShowAudience | null
|
||||
yearMin?: number | null
|
||||
yearMax?: number | null
|
||||
unitMinutesMin?: number | null
|
||||
@@ -253,6 +254,12 @@ export type GroupSummaryDto = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** Итог массового обогащения метаданными: что применилось и что пропущено с причиной. */
|
||||
export type EnrichShowsResult = {
|
||||
applied: number
|
||||
skipped: { showId: string; showName: string; reason: string }[]
|
||||
}
|
||||
|
||||
/** Откуда группа берёт состав: явный список либо вычисление правилом. */
|
||||
export type GroupMode = 'Static' | 'Dynamic'
|
||||
|
||||
|
||||
@@ -134,12 +134,15 @@ export const en = {
|
||||
},
|
||||
filter: {
|
||||
title: 'Selection rule',
|
||||
hint: 'The rule only finds candidates — the group composition stays an explicit list.',
|
||||
hint: 'In an explicit list the rule only finds candidates; in a rule-based group it is the composition.',
|
||||
elementKinds: 'What to search',
|
||||
showKinds: 'Show type',
|
||||
genres: 'Genres',
|
||||
genresHint: 'Any of the checked ones.',
|
||||
maxAudience: 'No stricter than',
|
||||
minAudience: 'No softer than',
|
||||
minAudienceHint:
|
||||
'For late-night slots. Unlike the upper bound, unrated shows are excluded — otherwise unrated cartoons would slip into the adult group.',
|
||||
audienceHint:
|
||||
'Ratings are ordered by strictness: G → PG → PG-13 → R → NC-17. Unrated shows stay in the selection.',
|
||||
year: 'Year',
|
||||
@@ -346,6 +349,21 @@ export const en = {
|
||||
genre: 'Genre',
|
||||
allGenres: 'All genres',
|
||||
otherGenres: 'Other genres',
|
||||
bulk: {
|
||||
selected: 'Selected: {{count}}',
|
||||
selectPage: 'Select all on this page',
|
||||
noGenre: 'Keep genres',
|
||||
keepAudience: 'Keep rating',
|
||||
apply: 'Apply',
|
||||
tagged: 'Shows updated: {{count}}',
|
||||
clear: 'Clear selection',
|
||||
enrich: 'Metadata from {{provider}}',
|
||||
enrichHint:
|
||||
'Search by name and apply — only for shows with no source linked. Ambiguous matches are skipped.',
|
||||
enriching: 'Searching…',
|
||||
enriched: 'Metadata applied: {{count}}',
|
||||
moreSkipped: 'Skipped as well: {{count}}',
|
||||
},
|
||||
genresEmpty: 'No genres set',
|
||||
genresEdit: 'Genres',
|
||||
genresHint:
|
||||
|
||||
@@ -134,12 +134,15 @@ export const ru = {
|
||||
},
|
||||
filter: {
|
||||
title: 'Правило набора',
|
||||
hint: 'Правило только ищет кандидатов — состав группы остаётся явным списком.',
|
||||
hint: 'В явном списке правило только ищет кандидатов; в группе «по правилу» оно и есть состав.',
|
||||
elementKinds: 'Что искать',
|
||||
showKinds: 'Тип шоу',
|
||||
genres: 'Жанры',
|
||||
genresHint: 'Любой из отмеченных.',
|
||||
maxAudience: 'Возраст не строже',
|
||||
minAudience: 'Возраст не мягче',
|
||||
minAudienceHint:
|
||||
'Для ночных слотов. В отличие от верхней границы, шоу без рейтинга сюда не попадают — иначе в «взрослое» просочились бы непроставленные мультики.',
|
||||
audienceHint:
|
||||
'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.',
|
||||
year: 'Год',
|
||||
@@ -347,6 +350,21 @@ export const ru = {
|
||||
genre: 'Жанр',
|
||||
allGenres: 'Все жанры',
|
||||
otherGenres: 'Остальные жанры',
|
||||
bulk: {
|
||||
selected: 'Отмечено: {{count}}',
|
||||
selectPage: 'Отметить все на странице',
|
||||
noGenre: 'Жанр не менять',
|
||||
keepAudience: 'Рейтинг не менять',
|
||||
apply: 'Применить',
|
||||
tagged: 'Обновлено шоу: {{count}}',
|
||||
clear: 'Снять выбор',
|
||||
enrich: 'Метаданные из {{provider}}',
|
||||
enrichHint:
|
||||
'Найти по названию и применить — только тем, у кого источник ещё не привязан. Неоднозначные совпадения пропускаются.',
|
||||
enriching: 'Ищем…',
|
||||
enriched: 'Метаданные применены: {{count}}',
|
||||
moreSkipped: 'Ещё пропущено: {{count}}',
|
||||
},
|
||||
genresEmpty: 'Жанры не проставлены',
|
||||
genresEdit: 'Жанры',
|
||||
genresHint:
|
||||
|
||||
Reference in New Issue
Block a user