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',