diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs index c586db5..256f8f6 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs @@ -113,14 +113,6 @@ public sealed class GroupCatalog( if (units == 0) return null; - // У коллекции своего типа нет: франшиза — это почти всегда полнометражки. - var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount); - // Ролики распознаются первыми: группа рекламы даёт сотни коротких единиц, то есть самый - // большой UnitCount из всех, и записанная «полным метром» она побеждала бы по баллам - // в любой полосе без предпочтения по типу — в эфир уходила бы полоса рекламы. - var interstitialUnits = resolved - .Where(r => r.ShowKind == ShowKind.Interstitial) - .Sum(r => r.UnitCount); var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration); return new GroupCandidate( @@ -129,11 +121,30 @@ public sealed class GroupCatalog( composition.Count, units, resolved.Max(r => r.Audience), - interstitialUnits * 2 >= units ? ShowKind.Interstitial - : seriesUnits * 2 >= units ? ShowKind.Series - : ShowKind.Single, + DominantKindOf(resolved, units), duration.TotalMinutes / units, primaryGenreId ); } + + /// + /// Чем группа является по составу — по единицам, а не по позициям: полоса собирается под длину + /// того, что в ней играет. + /// + /// Ролики проверяются первыми: группа рекламы даёт сотни коротких единиц, то есть самый большой + /// UnitCount на канале, и записанная «полным метром» она побеждала бы по баллам в любой + /// полосе без предпочтения по типу — в эфир уходила бы полоса рекламы. У коллекции своего типа + /// нет: франшиза — это почти всегда полнометражки. + /// + private static ShowKind DominantKindOf(IReadOnlyList resolved, int units) + { + var interstitialUnits = resolved + .Where(r => r.ShowKind == ShowKind.Interstitial) + .Sum(r => r.UnitCount); + if (interstitialUnits * 2 >= units) + return ShowKind.Interstitial; + + var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount); + return seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single; + } } diff --git a/frontend/src/features/admin/shows/AddEpisodesDialog.tsx b/frontend/src/features/admin/shows/AddEpisodesDialog.tsx index af539fc..38c77ca 100644 --- a/frontend/src/features/admin/shows/AddEpisodesDialog.tsx +++ b/frontend/src/features/admin/shows/AddEpisodesDialog.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Play, X } from 'lucide-react' import { qk } from '@/shared/api/query-keys' import type { MediaAssetDto, ShowDto } from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' @@ -15,9 +16,10 @@ import { DialogTitle, } from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' +import { HlsVideo } from '@/shared/ui/hls-video' import { Pager } from '@/shared/ui/pager' import { toast } from '@/shared/ui/toast-store' -import { listAllMedia } from '@/features/admin/media/api' +import { listAllMedia, mediaPreviewUrl } from '@/features/admin/media/api' import { type ParsedEpisode, compareParsed, @@ -56,6 +58,9 @@ export function AddEpisodesDialog({ const [deselected, setDeselected] = useState>(new Set()) const [adding, setAdding] = useState<{ current: number; total: number } | null>(null) const [page, setPage] = useState(1) + // Предпросмотр встроен в окно, а не открывается поверх: выбирают по именам файлов, и смотреть + // приходится подряд несколько — со второй модалкой это были бы два клика на каждый. + const [preview, setPreview] = useState(null) const { data: ready, isLoading } = useQuery({ queryKey: qk.media.ready, @@ -162,8 +167,9 @@ export function AddEpisodesDialog({ {items.map(({ asset, parsed }) => { const label = formatSeasonEpisode(parsed) return ( -
  • -
  • + {/* Кнопка вне label: внутри неё клик по «плею» заодно снимал бы галочку. */} + +
  • ) })} )} + {preview && ( +
    +
    + {preview.originalFileName} + +
    + +
    + )} {ready?.truncated && (

    {t('admin.shows.candidatesTruncated')}

    )} diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 68422ee..9393141 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -2,16 +2,19 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronLeft, Plus } from 'lucide-react' +import { ChevronLeft, Play, Plus } from 'lucide-react' 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 { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog' +import { HlsVideo } from '@/shared/ui/hls-video' import { Pager } from '@/shared/ui/pager' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { formatSeasonEpisode, parseEpisodeName } from '@/features/admin/media/episode-parse' import { formatDuration } from '@/features/admin/media/format' +import { mediaPreviewUrl } from '@/features/admin/media/api' import { AddEpisodesDialog } from './AddEpisodesDialog' import { ShowGenresField } from './ShowGenresField' import { ShowMetadataCard } from './ShowMetadataCard' @@ -25,6 +28,8 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { const queryClient = useQueryClient() const [addOpen, setAddOpen] = useState(false) const [epPage, setEpPage] = useState(1) + /** Серия, которую смотрят: та же нарезка, что уйдёт в эфир. */ + const [preview, setPreview] = useState<{ assetId: string; title: string } | null>(null) const { data: show, isLoading } = useQuery({ queryKey: qk.shows.detail(showId), @@ -234,13 +239,31 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { )} - +
    + {/* Смотреть можно только нарезанное: у необработанного ассета плейлиста нет. */} + {episode.assetStatus === 'Ready' && ( + + )} + +
    ) @@ -257,6 +280,15 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { + !open && setPreview(null)}> + + + {preview?.title} + + {preview && } + + + {addOpen && (