From 6e7db4a6a94d18edc5d38735d7e12883a65079d7 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 24 Jul 2026 19:22:22 +0300 Subject: [PATCH] Refactor media asset handling: update ListMediaAssetsQuery to accept an array of statuses, modify MediaEndpoints to handle multiple statuses, and enhance MediaProcessingQueue for improved signal handling. Update frontend components to support new status filtering and improve user experience with asset selection. --- TeleWave.sln | 70 ++++++++ .../TeleWave.Api/Endpoints/MediaEndpoints.cs | 4 +- .../Interfaces/IMediaProcessingQueue.cs | 9 +- .../Media/ListMedia/ListMediaAssetsQuery.cs | 2 +- .../ListMedia/ListMediaAssetsQueryHandler.cs | 4 +- .../Media/MediaProcessingBackgroundService.cs | 66 +++++--- .../Media/MediaProcessingQueue.cs | 10 +- .../features/admin/channels/ChannelDetail.tsx | 2 +- .../src/features/admin/media/MediaPanel.tsx | 30 +++- frontend/src/features/admin/media/api.ts | 4 +- .../src/features/admin/shows/ShowDetail.tsx | 152 ++++++++++++++---- frontend/src/shared/lib/i18n.ts | 16 ++ 12 files changed, 303 insertions(+), 66 deletions(-) create mode 100644 TeleWave.sln diff --git a/TeleWave.sln b/TeleWave.sln new file mode 100644 index 0000000..80cb8be --- /dev/null +++ b/TeleWave.sln @@ -0,0 +1,70 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Api", "backend\src\TeleWave.Api\TeleWave.Api.csproj", "{637772B7-C13F-21C4-0C66-BA606166F604}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Application", "backend\src\TeleWave.Application\TeleWave.Application.csproj", "{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Domain", "backend\src\TeleWave.Domain\TeleWave.Domain.csproj", "{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Infrastructure", "backend\src\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj", "{9D8E3873-E632-F209-1AA0-619B3FC683AC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F57642F3-C37C-D174-720E-6A6AAD5BEE22}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Application.Tests", "backend\tests\TeleWave.Application.Tests\TeleWave.Application.Tests.csproj", "{E27F38DB-A6A4-A33F-0C57-9885DD209034}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Domain.Tests", "backend\tests\TeleWave.Domain.Tests\TeleWave.Domain.Tests.csproj", "{EA5A4356-A5CE-7650-74A5-53D50CB50872}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {637772B7-C13F-21C4-0C66-BA606166F604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {637772B7-C13F-21C4-0C66-BA606166F604}.Debug|Any CPU.Build.0 = Debug|Any CPU + {637772B7-C13F-21C4-0C66-BA606166F604}.Release|Any CPU.ActiveCfg = Release|Any CPU + {637772B7-C13F-21C4-0C66-BA606166F604}.Release|Any CPU.Build.0 = Release|Any CPU + {128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Release|Any CPU.Build.0 = Release|Any CPU + {5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Release|Any CPU.Build.0 = Release|Any CPU + {9D8E3873-E632-F209-1AA0-619B3FC683AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D8E3873-E632-F209-1AA0-619B3FC683AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D8E3873-E632-F209-1AA0-619B3FC683AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D8E3873-E632-F209-1AA0-619B3FC683AC}.Release|Any CPU.Build.0 = Release|Any CPU + {E27F38DB-A6A4-A33F-0C57-9885DD209034}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E27F38DB-A6A4-A33F-0C57-9885DD209034}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E27F38DB-A6A4-A33F-0C57-9885DD209034}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E27F38DB-A6A4-A33F-0C57-9885DD209034}.Release|Any CPU.Build.0 = Release|Any CPU + {EA5A4356-A5CE-7650-74A5-53D50CB50872}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA5A4356-A5CE-7650-74A5-53D50CB50872}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA5A4356-A5CE-7650-74A5-53D50CB50872}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA5A4356-A5CE-7650-74A5-53D50CB50872}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {637772B7-C13F-21C4-0C66-BA606166F604} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} + {128E7BA6-6F6C-30A8-1C85-66DDE98604B6} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} + {5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} + {9D8E3873-E632-F209-1AA0-619B3FC683AC} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} + {F57642F3-C37C-D174-720E-6A6AAD5BEE22} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {E27F38DB-A6A4-A33F-0C57-9885DD209034} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22} + {EA5A4356-A5CE-7650-74A5-53D50CB50872} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CD8641E5-D878-4C5E-BE1C-314F2280EC6E} + EndGlobalSection +EndGlobal diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 15dccad..f810886 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -95,7 +95,7 @@ public static class MediaEndpoints private static async Task List( int page, int pageSize, - MediaAssetStatus? status, + MediaAssetStatus[]? status, string? search, ISender sender, CancellationToken cancellationToken @@ -105,7 +105,7 @@ public static class MediaEndpoints new ListMediaAssetsQuery( page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, - status, + status ?? [], search ), cancellationToken diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingQueue.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingQueue.cs index 6e3b495..79f5921 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingQueue.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingQueue.cs @@ -1,12 +1,15 @@ namespace TeleWave.Application.Common.Interfaces; /// -/// Очередь фоновой обработки ассетов. Продюсеры (endpoint загрузки, inbox-сканер) ставят id ассета, -/// единственный фоновый потребитель обрабатывает по одному за раз. +/// Сигнал «появилась работа» для фонового обработчика. Источник истины — статус ассета в БД +/// (обработчик всегда берёт следующий Pending из базы), а очередь лишь будит его без задержки; +/// поэтому потеря сигнала при рестарте не теряет задачи — они подхватываются из БД. /// public interface IMediaProcessingQueue { + /// Разбудить обработчик: появился ассет в статусе Pending. void Enqueue(Guid assetId); - IAsyncEnumerable DequeueAllAsync(CancellationToken cancellationToken); + /// Ждать сигнала о новой работе (с дренажом накопленных). + ValueTask WaitAsync(CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs index 71c540b..4423992 100644 --- a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs +++ b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs @@ -7,6 +7,6 @@ namespace TeleWave.Application.Media.ListMedia; public sealed record ListMediaAssetsQuery( int Page, int PageSize, - MediaAssetStatus? Status, + IReadOnlyList Statuses, string? Search ) : IQuery>; diff --git a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs index 1bb5adf..486d3df 100644 --- a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs @@ -15,8 +15,8 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext) { var q = dbContext.MediaAssets.AsNoTracking(); - if (query.Status is { } status) - q = q.Where(x => x.Status == status); + if (query.Statuses.Count > 0) + q = q.Where(x => query.Statuses.Contains(x.Status)); if (!string.IsNullOrWhiteSpace(query.Search)) { diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs index c73328d..33d5e33 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs @@ -9,10 +9,11 @@ using TeleWave.Domain.Media; namespace TeleWave.Infrastructure.Media; /// -/// Единственный потребитель очереди обработки: по одному ассету за раз прогоняет через ffmpeg. -/// На старте восстанавливает прерванные задачи (Pending/Processing) — переживает рестарт/краш. -/// БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне scope, чтобы -/// не держать соединение открытым минутами. +/// Единственный обработчик медиа: по одному ассету за раз прогоняет через ffmpeg. Источник истины — +/// статус в БД: сервис в цикле берёт следующий Pending из базы и обрабатывает, а очередь лишь будит +/// его без задержки. Поэтому рестарт/краш ничего не теряет — незавершённые задачи подхватываются из +/// БД (прерванные Processing на старте сбрасываются в Pending). БД-контекст держится короткими +/// отрезками (пометить статус), сам транскод идёт вне scope, чтобы не держать соединение минутами. /// public sealed class MediaProcessingBackgroundService( IMediaProcessingQueue queue, @@ -23,18 +24,34 @@ public sealed class MediaProcessingBackgroundService( ILogger logger ) : BackgroundService { + // Периодически перепроверяем БД, даже если сигнал не пришёл — страховка на любой случай. + private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30); private readonly StorageOptions _storage = storageOptions.Value; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { paths.EnsureDirectories(); - await RecoverPendingAsync(stoppingToken); + await ResetInterruptedAsync(stoppingToken); - await foreach (var assetId in queue.DequeueAllAsync(stoppingToken)) + while (!stoppingToken.IsCancellationRequested) { try { - await ProcessAsync(assetId, stoppingToken); + // Разобрать всю накопившуюся работу из БД. + while (await NextPendingIdAsync(stoppingToken) is { } assetId) + await ProcessAsync(assetId, stoppingToken); + + // Работы нет — ждём сигнала о новой либо периодического опроса. + using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + wake.CancelAfter(IdlePoll); + try + { + await queue.WaitAsync(wake.Token); + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + // Тайм-аут опроса — просто перепроверяем БД. + } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -42,30 +59,41 @@ public sealed class MediaProcessingBackgroundService( } catch (Exception ex) { - logger.LogError(ex, "Необработанная ошибка обработки ассета {AssetId}", assetId); + logger.LogError(ex, "Ошибка цикла обработки медиа"); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } } } - private async Task RecoverPendingAsync(CancellationToken cancellationToken) + /// Сброс прерванных рестартом задач (Processing → Pending) на старте. + private async Task ResetInterruptedAsync(CancellationToken cancellationToken) { await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - var pending = await db.MediaAssets - .Where(x => - x.Status == MediaAssetStatus.Pending || x.Status == MediaAssetStatus.Processing - ) + var interrupted = await db.MediaAssets + .Where(x => x.Status == MediaAssetStatus.Processing) .ToListAsync(cancellationToken); + if (interrupted.Count == 0) + return; - foreach (var asset in pending.Where(x => x.Status == MediaAssetStatus.Processing)) + foreach (var asset in interrupted) asset.ResetToPending(); + await db.SaveChangesAsync(cancellationToken); + } - if (pending.Count > 0) - await db.SaveChangesAsync(cancellationToken); + /// Id самого раннего ассета в статусе Pending, либо null если работы нет. + private async Task NextPendingIdAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); - foreach (var asset in pending) - queue.Enqueue(asset.Id); + var id = await db.MediaAssets + .Where(x => x.Status == MediaAssetStatus.Pending) + .OrderBy(x => x.CreatedAt) + .Select(x => (Guid?)x.Id) + .FirstOrDefaultAsync(cancellationToken); + return id; } private async Task ProcessAsync(Guid assetId, CancellationToken cancellationToken) @@ -107,7 +135,7 @@ public sealed class MediaProcessingBackgroundService( var db = scope.ServiceProvider.GetRequiredService(); var asset = await db.MediaAssets.FirstOrDefaultAsync(x => x.Id == assetId, cancellationToken); - if (asset is null || asset.Status is MediaAssetStatus.Ready or MediaAssetStatus.Failed) + if (asset is null || asset.Status != MediaAssetStatus.Pending) return null; asset.MarkProcessing(); diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingQueue.cs b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingQueue.cs index 5dcc0aa..2fb6f6d 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingQueue.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingQueue.cs @@ -3,7 +3,7 @@ using TeleWave.Application.Common.Interfaces; namespace TeleWave.Infrastructure.Media; -/// Неограниченная in-memory очередь id ассетов на обработку (один потребитель). +/// Сигнальная очередь-будильник поверх Channel (id ассета используется лишь как сигнал). public sealed class MediaProcessingQueue : IMediaProcessingQueue { private readonly Channel _channel = Channel.CreateUnbounded( @@ -12,6 +12,10 @@ public sealed class MediaProcessingQueue : IMediaProcessingQueue public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId); - public IAsyncEnumerable DequeueAllAsync(CancellationToken cancellationToken) => - _channel.Reader.ReadAllAsync(cancellationToken); + public async ValueTask WaitAsync(CancellationToken cancellationToken) + { + await _channel.Reader.ReadAsync(cancellationToken); + // Сдренировать накопившиеся сигналы — работу всё равно берём из БД пачкой. + while (_channel.Reader.TryRead(out _)) { } + } } diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index ee054e9..aed7ace 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -54,7 +54,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) { const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) const { data: ready } = useQuery({ queryKey: ['admin', 'media', 'ready'], - queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }), + queryFn: () => listMedia({ page: 1, pageSize: 100, statuses: ['Ready'] }), }) const { data: schedule } = useQuery({ queryKey: ['admin', 'channels', channelId, 'schedule'], diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 02e0ac4..8902ef6 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -6,11 +6,21 @@ import { HttpError } from '@/shared/api/client' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import { Badge, type BadgeProps } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { deleteMedia, listMedia, uploadMedia } from './api' const PAGE_SIZE = 20 +type MediaFilter = 'active' | 'all' | 'Ready' | 'Failed' + +const filterStatuses: Record = { + active: ['Pending', 'Processing'], + all: [], + Ready: ['Ready'], + Failed: ['Failed'], +} + export function formatDuration(seconds: number | null): string { if (seconds == null) return '—' const total = Math.round(seconds) @@ -35,10 +45,11 @@ export function MediaPanel() { const [upload, setUpload] = useState<{ current: number; total: number; percent: number } | null>( null, ) + const [filter, setFilter] = useState('active') const { data, isLoading } = useQuery({ - queryKey: ['admin', 'media'], - queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE }), + queryKey: ['admin', 'media', filter], + queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }), // Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI. refetchInterval: (query) => query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending') @@ -77,7 +88,20 @@ export function MediaPanel() { return (
-

{t('admin.media.title')}

+
+

{t('admin.media.title')}

+ +
{upload != null && ( diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts index 4e7979a..34182cf 100644 --- a/frontend/src/features/admin/media/api.ts +++ b/frontend/src/features/admin/media/api.ts @@ -9,7 +9,7 @@ import type { export type ListMediaParams = { page: number pageSize: number - status?: MediaAssetStatus + statuses?: MediaAssetStatus[] search?: string } @@ -18,7 +18,7 @@ export function listMedia(params: ListMediaParams) { page: String(params.page), pageSize: String(params.pageSize), }) - if (params.status) query.set('status', params.status) + for (const status of params.statuses ?? []) query.append('status', status) if (params.search) query.set('search', params.search) return apiRequest>(`/admin/media?${query.toString()}`) } diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index b7fd383..7d03dce 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -1,21 +1,37 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronLeft } from 'lucide-react' import { HttpError } from '@/shared/api/client' +import type { MediaAssetDto } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { Input } from '@/shared/ui/input' import { toast } from '@/shared/ui/toast-store' import { listMedia } from '@/features/admin/media/api' import { formatDuration } from '@/features/admin/media/MediaPanel' import { addEpisode, getShow, removeEpisode } from './api' +const pad2 = (n: number) => String(n).padStart(2, '0') + +/** Достаёт сезон/серию из имени файла: SxxEyy либо NxNN. Нужно для сортировки в правильный порядок. */ +function parseEpisode(name: string): { season: number; episode: number } | null { + const m1 = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/) + if (m1) return { season: Number(m1[1]), episode: Number(m1[2]) } + const m2 = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i) + if (m2) return { season: Number(m2[1]), episode: Number(m2[2]) } + return null +} + +type Candidate = { asset: MediaAssetDto; se: { season: number; episode: number } | null } + export function ShowDetail({ showId }: { showId: string }) { const { t } = useTranslation() const queryClient = useQueryClient() - const [assetId, setAssetId] = useState('') + const [filter, setFilter] = useState('') + const [deselected, setDeselected] = useState>(new Set()) + const [adding, setAdding] = useState<{ current: number; total: number } | null>(null) const { data: show, isLoading } = useQuery({ queryKey: ['admin', 'shows', showId], @@ -23,30 +39,70 @@ export function ShowDetail({ showId }: { showId: string }) { }) const { data: ready } = useQuery({ queryKey: ['admin', 'media', 'ready'], - queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }), + queryFn: () => listMedia({ page: 1, pageSize: 500, statuses: ['Ready'] }), }) const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] }) const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) - const addMutation = useMutation({ - mutationFn: () => addEpisode(showId, assetId), - onSuccess: () => { - setAssetId('') - invalidate() - }, - onError, - }) const removeMutation = useMutation({ mutationFn: (episodeId: string) => removeEpisode(showId, episodeId), onSuccess: invalidate, onError, }) + // Кандидаты: готовые ассеты, ещё не добавленные в шоу, отфильтрованные по строке и упорядоченные + // по распознанному номеру сезона/серии (нераспознанные — в конец по имени). + const candidates = useMemo(() => { + if (!show) return [] + const existing = new Set(show.episodes.map((e) => e.mediaAssetId)) + const term = filter.trim().toLowerCase() + return (ready?.items ?? []) + .filter((a) => !existing.has(a.id)) + .filter((a) => !term || a.originalFileName.toLowerCase().includes(term)) + .map((asset) => ({ asset, se: parseEpisode(asset.originalFileName) })) + .sort((a, b) => { + if (a.se && b.se) + return a.se.season - b.se.season || a.se.episode - b.se.episode + if (a.se) return -1 + if (b.se) return 1 + return a.asset.originalFileName.localeCompare(b.asset.originalFileName) + }) + }, [show, ready, filter]) + + const selected = candidates.filter((c) => !deselected.has(c.asset.id)) + if (isLoading || !show) return

{t('common.loading')}

- const canAdd = show.kind !== 'Single' || show.episodes.length === 0 + const isSingle = show.kind === 'Single' + const canAdd = !isSingle || show.episodes.length === 0 + const toggle = (id: string) => + setDeselected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + + const bulkAdd = async () => { + // Полнометражке — максимум одна серия. + const items = isSingle ? selected.slice(0, 1) : selected + let added = 0 + for (let i = 0; i < items.length; i++) { + setAdding({ current: i + 1, total: items.length }) + try { + await addEpisode(showId, items[i].asset.id) + added++ + } catch (error) { + onError(error) + } + } + setAdding(null) + setDeselected(new Set()) + void invalidate() + if (added > 0) toast.success(t('admin.shows.addedCount', { count: added })) + } return (
@@ -65,22 +121,58 @@ export function ShowDetail({ showId }: { showId: string }) {
{canAdd && ( -
- - +
+
+ setFilter(e.target.value)} + /> + + + +
+ +
+ {candidates.length === 0 ? ( +

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

+ ) : ( +
    + {candidates.map(({ asset, se }) => ( +
  • + +
  • + ))} +
+ )} +
)} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 38274d3..ad6839c 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -106,6 +106,8 @@ const resources = { upload: 'Загрузить', uploaded: 'Файл загружен, идёт обработка', uploadedCount: 'Загружено файлов: {{count}}', + filterActive: 'Активные', + filterAll: 'Все', name: 'Файл', status: 'Статус', duration: 'Длительность', @@ -128,6 +130,12 @@ const resources = { addEpisode: 'Добавить серию', pickAsset: 'Выберите файл', noEpisodes: 'Серий пока нет', + filterAssets: 'Фильтр по имени, напр. Family.Guy.S16', + selectAll: 'Выбрать все', + deselectAll: 'Снять все', + addSelected: 'Добавить выбранные', + noMatches: 'Нет подходящих файлов', + addedCount: 'Добавлено серий: {{count}}', }, channels: { title: 'Каналы', @@ -275,6 +283,8 @@ const resources = { upload: 'Upload', uploaded: 'File uploaded, processing started', uploadedCount: 'Uploaded files: {{count}}', + filterActive: 'Active', + filterAll: 'All', name: 'File', status: 'Status', duration: 'Duration', @@ -297,6 +307,12 @@ const resources = { addEpisode: 'Add episode', pickAsset: 'Pick a file', noEpisodes: 'No episodes yet', + filterAssets: 'Filter by name, e.g. Family.Guy.S16', + selectAll: 'Select all', + deselectAll: 'Clear', + addSelected: 'Add selected', + noMatches: 'No matching files', + addedCount: 'Episodes added: {{count}}', }, channels: { title: 'Channels',