Refactor ShowAudience handling across the application to align with MPAA rating system. Update ShowAudience enum to reflect new ratings (G, PG, PG-13, R, NC-17) and adjust related data models, API types, and frontend components to support nullable audience values. Enhance metadata handling to incorporate content ratings from external sources, ensuring proper audience filtering in scheduling logic. Update documentation to clarify changes in audience categorization and its implications for content management.
ci / build-backend (push) Successful in 1m28s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m7s
ci / sonar (push) Successful in 4m31s

This commit is contained in:
Leonid Pershin
2026-07-26 22:18:14 +03:00
parent c4c93cff02
commit fdb0f321e4
34 changed files with 1868 additions and 116 deletions
@@ -15,7 +15,7 @@ import { Label } from '@/shared/ui/label'
import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'PG-13' }
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
@@ -4,7 +4,12 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import { SHOW_AUDIENCES, type MediaAssetDto, type ShowAudience } from '@/shared/api/types'
import {
AUDIENCE_UNSET,
SHOW_AUDIENCES,
type MediaAssetDto,
type ShowAudience,
} from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
@@ -51,7 +56,7 @@ export function ShowDetail({ showId }: { showId: string }) {
const onError = useApiError()
const audienceMutation = useMutation({
mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience),
mutationFn: (audience: ShowAudience | null) => setShowAudience(showId, audience),
onSuccess: invalidate,
onError,
})
@@ -150,13 +155,16 @@ export function ShowDetail({ showId }: { showId: string }) {
<h2 className="crt-glow text-xl font-semibold">{show.name}</h2>
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
<Select
value={show.audience}
onValueChange={(v) => audienceMutation.mutate(v as ShowAudience)}
value={show.audience ?? AUDIENCE_UNSET}
onValueChange={(v) =>
audienceMutation.mutate(v === AUDIENCE_UNSET ? null : (v as ShowAudience))
}
>
<SelectTrigger className="h-7 w-32">
<SelectTrigger className="h-7 w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUDIENCE_UNSET}>{t('admin.shows.audienceUnset')}</SelectItem>
{SHOW_AUDIENCES.map((value) => (
<SelectItem key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
@@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
@@ -199,10 +200,17 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
<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 className="flex flex-wrap items-center gap-2 text-sm font-medium">
<span>
{r.title}
{r.year != null && (
<span className="text-muted-foreground"> ({r.year})</span>
)}
</span>
{/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки
одноимённые фильм и сериал не различить. */}
{r.kind && (
<Badge variant="muted">{t(`admin.shows.kinds.${r.kind}`)}</Badge>
)}
</div>
{r.overview && (
@@ -3,7 +3,12 @@ import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import { SHOW_AUDIENCES, type ShowAudience, type ShowKind } from '@/shared/api/types'
import {
AUDIENCE_UNSET,
SHOW_AUDIENCES,
type ShowAudience,
type ShowKind,
} from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
@@ -22,7 +27,8 @@ export function ShowsPanel() {
const [name, setName] = useState('')
const [originalName, setOriginalName] = useState('')
const [kind, setKind] = useState<ShowKind>('Series')
const [audience, setAudience] = useState<ShowAudience>('General')
// Новое шоу заводится без рейтинга: проставят метаданные либо админ руками.
const [audience, setAudience] = useState<ShowAudience | null>(null)
const [query, setQuery] = useState('')
const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('name', false)
@@ -52,7 +58,8 @@ export function ShowsPanel() {
return sortRows(matched, sort, {
name: (s) => s.name.toLowerCase(),
kind: (s) => s.kind,
audience: (s) => s.audience,
// Без рейтинга — в начало: пустое значение сортируется раньше любого кода.
audience: (s) => s.audience ?? '',
genre: (s) => (s.primaryGenre ?? '').toLowerCase(),
seasons: (s) => s.seasonCount,
episodes: (s) => s.episodeCount,
@@ -106,11 +113,15 @@ export function ShowsPanel() {
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
</SelectContent>
</Select>
<Select value={audience} onValueChange={(v) => setAudience(v as ShowAudience)}>
<SelectTrigger className="w-40">
<Select
value={audience ?? AUDIENCE_UNSET}
onValueChange={(v) => setAudience(v === AUDIENCE_UNSET ? null : (v as ShowAudience))}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUDIENCE_UNSET}>{t('admin.shows.audienceUnset')}</SelectItem>
{SHOW_AUDIENCES.map((value) => (
<SelectItem key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
@@ -224,7 +235,13 @@ export function ShowsPanel() {
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</td>
<td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.audiences.${show.audience}`)}</Badge>
{/* В таблице показываем сам код MPAA: он короткий и одинаков во всех локалях,
а расшифровка есть в селекте на карточке шоу. */}
{show.audience ? (
<Badge variant="muted">{show.audience}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-2 text-muted-foreground">{show.primaryGenre ?? '—'}</td>
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
+3 -2
View File
@@ -23,12 +23,13 @@ export function createShow(body: {
kind: ShowKind
description?: string
originalName?: string
audience?: ShowAudience
audience?: ShowAudience | null
}) {
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
}
export function setShowAudience(id: string, audience: ShowAudience) {
/** `null` — снять проставленный рейтинг. */
export function setShowAudience(id: string, audience: ShowAudience | null) {
return apiRequest<void>(`/admin/shows/${id}/audience`, { method: 'PUT', body: { audience } })
}
+17 -6
View File
@@ -128,18 +128,27 @@ export type GenreDto = {
/** `Interstitial` — ролик-врезка: то же шоу, но со своим экраном и вне общей библиотеки. */
export type ShowKind = 'Series' | 'Single' | 'Interstitial'
/** Возрастная категория, по возрастанию строгости — порядок значим для правил планировщика. */
export type ShowAudience = 'Kids' | 'Family' | 'Teen' | 'General' | 'Adult'
/**
* Возрастной рейтинг (MPAA), по возрастанию строгости — порядок значим для правил планировщика.
* `null` у шоу означает «не проставлен», а не «подходит всем»: такой контент планировщик не отсекает.
*/
export type ShowAudience = 'G' | 'PG' | 'PG-13' | 'R' | 'NC-17'
/** Тот же порядок для селектов и списков. */
export const SHOW_AUDIENCES: ShowAudience[] = ['Kids', 'Family', 'Teen', 'General', 'Adult']
export const SHOW_AUDIENCES: ShowAudience[] = ['G', 'PG', 'PG-13', 'R', 'NC-17']
/**
* Значение пункта «рейтинг не проставлен» в селектах. Пустая строка не годится: Radix резервирует её
* под сброс и на пункте с value="" падает.
*/
export const AUDIENCE_UNSET = 'unset'
export type ShowSummaryDto = {
id: string
name: string
originalName: string | null
kind: ShowKind
audience: ShowAudience
audience: ShowAudience | null
episodeCount: number
seasonCount: number
year: number | null
@@ -192,7 +201,7 @@ type CollectionItemDto = {
position: number
showName: string
showKind: ShowKind
showAudience: ShowAudience
showAudience: ShowAudience | null
episodeCount: number
year: number | null
posterImageId: string | null
@@ -286,6 +295,8 @@ export type MetadataCandidate = {
year: number | null
overview: string | null
posterUrl: string | null
/** Сериал или полнометражка; null — источник ищет только по одному типу. */
kind: ShowKind | null
}
type SeasonGapDto = {
@@ -327,7 +338,7 @@ export type ShowDto = {
name: string
originalName: string | null
kind: ShowKind
audience: ShowAudience
audience: ShowAudience | null
description: string | null
metadataProvider: string | null
metadataExternalId: string | null
+9 -7
View File
@@ -108,7 +108,8 @@ export const en = {
genres: 'Genres',
genresHint: 'Any of the checked ones.',
maxAudience: 'No stricter than',
audienceHint: 'Categories are ordered by strictness: kids → family → … → adult.',
audienceHint:
'Ratings are ordered by strictness: G → PG → PG-13 → R → NC-17. Unrated shows stay in the selection.',
year: 'Year',
unitMinutes: 'Unit runtime, min',
unitMinutesHint:
@@ -294,13 +295,14 @@ export const en = {
originalName: 'Original name (eng)',
kind: 'Kind',
kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' },
audience: 'Category',
audience: 'Rating',
audienceUnset: 'Not rated',
audiences: {
Kids: 'Kids',
Family: 'Family',
Teen: 'Teen',
General: 'General',
Adult: 'Adult',
G: 'G — general audiences',
PG: 'PG — parental guidance',
'PG-13': 'PG-13 — over 13',
R: 'R — under 17 with adult',
'NC-17': 'NC-17 — adults only',
},
seasons: 'Seasons',
loadedSeasons: 'Loaded seasons',
+9 -7
View File
@@ -108,7 +108,8 @@ export const ru = {
genres: 'Жанры',
genresHint: 'Любой из отмеченных.',
maxAudience: 'Возраст не строже',
audienceHint: 'Категории упорядочены по строгости: детское → семейное → … → взрослое.',
audienceHint:
'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.',
year: 'Год',
unitMinutes: 'Длительность единицы, мин',
unitMinutesHint:
@@ -294,13 +295,14 @@ export const ru = {
originalName: 'Оригинальное название (eng)',
kind: 'Тип',
kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' },
audience: 'Категория',
audience: 'Рейтинг',
audienceUnset: 'Не проставлен',
audiences: {
Kids: 'Детское',
Family: 'Семейное',
Teen: 'Подростковое',
General: 'Общее',
Adult: 'Взрослое',
G: 'G — без ограничений',
PG: 'PG — с родителями',
'PG-13': 'PG-13 — с 13 лет',
R: 'R — с 17 со взрослым',
'NC-17': 'NC-17 — только взрослым',
},
seasons: 'Сезоны',
loadedSeasons: 'Загружены сезоны',