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.

This commit is contained in:
Leonid Pershin
2026-07-24 19:22:22 +03:00
parent 8281832e1d
commit 6e7db4a6a9
12 changed files with 303 additions and 66 deletions
+70
View File
@@ -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
@@ -95,7 +95,7 @@ public static class MediaEndpoints
private static async Task<IResult> List( private static async Task<IResult> List(
int page, int page,
int pageSize, int pageSize,
MediaAssetStatus? status, MediaAssetStatus[]? status,
string? search, string? search,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -105,7 +105,7 @@ public static class MediaEndpoints
new ListMediaAssetsQuery( new ListMediaAssetsQuery(
page <= 0 ? 1 : page, page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize, pageSize <= 0 ? 20 : pageSize,
status, status ?? [],
search search
), ),
cancellationToken cancellationToken
@@ -1,12 +1,15 @@
namespace TeleWave.Application.Common.Interfaces; namespace TeleWave.Application.Common.Interfaces;
/// <summary> /// <summary>
/// Очередь фоновой обработки ассетов. Продюсеры (endpoint загрузки, inbox-сканер) ставят id ассета, /// Сигнал «появилась работа» для фонового обработчика. Источник истины — статус ассета в БД
/// единственный фоновый потребитель обрабатывает по одному за раз. /// (обработчик всегда берёт следующий Pending из базы), а очередь лишь будит его без задержки;
/// поэтому потеря сигнала при рестарте не теряет задачи — они подхватываются из БД.
/// </summary> /// </summary>
public interface IMediaProcessingQueue public interface IMediaProcessingQueue
{ {
/// <summary>Разбудить обработчик: появился ассет в статусе Pending.</summary>
void Enqueue(Guid assetId); void Enqueue(Guid assetId);
IAsyncEnumerable<Guid> DequeueAllAsync(CancellationToken cancellationToken); /// <summary>Ждать сигнала о новой работе (с дренажом накопленных).</summary>
ValueTask WaitAsync(CancellationToken cancellationToken);
} }
@@ -7,6 +7,6 @@ namespace TeleWave.Application.Media.ListMedia;
public sealed record ListMediaAssetsQuery( public sealed record ListMediaAssetsQuery(
int Page, int Page,
int PageSize, int PageSize,
MediaAssetStatus? Status, IReadOnlyList<MediaAssetStatus> Statuses,
string? Search string? Search
) : IQuery<PagedList<MediaAssetDto>>; ) : IQuery<PagedList<MediaAssetDto>>;
@@ -15,8 +15,8 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
{ {
var q = dbContext.MediaAssets.AsNoTracking(); var q = dbContext.MediaAssets.AsNoTracking();
if (query.Status is { } status) if (query.Statuses.Count > 0)
q = q.Where(x => x.Status == status); q = q.Where(x => query.Statuses.Contains(x.Status));
if (!string.IsNullOrWhiteSpace(query.Search)) if (!string.IsNullOrWhiteSpace(query.Search))
{ {
@@ -9,10 +9,11 @@ using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary> /// <summary>
/// Единственный потребитель очереди обработки: по одному ассету за раз прогоняет через ffmpeg. /// Единственный обработчик медиа: по одному ассету за раз прогоняет через ffmpeg. Источник истины —
/// На старте восстанавливает прерванные задачи (Pending/Processing) — переживает рестарт/краш. /// статус в БД: сервис в цикле берёт следующий Pending из базы и обрабатывает, а очередь лишь будит
/// БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне scope, чтобы /// его без задержки. Поэтому рестарт/краш ничего не теряет — незавершённые задачи подхватываются из
/// не держать соединение открытым минутами. /// БД (прерванные Processing на старте сбрасываются в Pending). БД-контекст держится короткими
/// отрезками (пометить статус), сам транскод идёт вне scope, чтобы не держать соединение минутами.
/// </summary> /// </summary>
public sealed class MediaProcessingBackgroundService( public sealed class MediaProcessingBackgroundService(
IMediaProcessingQueue queue, IMediaProcessingQueue queue,
@@ -23,18 +24,34 @@ public sealed class MediaProcessingBackgroundService(
ILogger<MediaProcessingBackgroundService> logger ILogger<MediaProcessingBackgroundService> logger
) : BackgroundService ) : BackgroundService
{ {
// Периодически перепроверяем БД, даже если сигнал не пришёл — страховка на любой случай.
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
private readonly StorageOptions _storage = storageOptions.Value; private readonly StorageOptions _storage = storageOptions.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
paths.EnsureDirectories(); paths.EnsureDirectories();
await RecoverPendingAsync(stoppingToken); await ResetInterruptedAsync(stoppingToken);
await foreach (var assetId in queue.DequeueAllAsync(stoppingToken)) while (!stoppingToken.IsCancellationRequested)
{ {
try 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) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@@ -42,30 +59,41 @@ public sealed class MediaProcessingBackgroundService(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Необработанная ошибка обработки ассета {AssetId}", assetId); logger.LogError(ex, "Ошибка цикла обработки медиа");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
} }
} }
} }
private async Task RecoverPendingAsync(CancellationToken cancellationToken) /// <summary>Сброс прерванных рестартом задач (Processing → Pending) на старте.</summary>
private async Task ResetInterruptedAsync(CancellationToken cancellationToken)
{ {
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var pending = await db.MediaAssets var interrupted = await db.MediaAssets
.Where(x => .Where(x => x.Status == MediaAssetStatus.Processing)
x.Status == MediaAssetStatus.Pending || x.Status == MediaAssetStatus.Processing
)
.ToListAsync(cancellationToken); .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(); asset.ResetToPending();
await db.SaveChangesAsync(cancellationToken);
}
if (pending.Count > 0) /// <summary>Id самого раннего ассета в статусе Pending, либо null если работы нет.</summary>
await db.SaveChangesAsync(cancellationToken); private async Task<Guid?> NextPendingIdAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
foreach (var asset in pending) var id = await db.MediaAssets
queue.Enqueue(asset.Id); .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) private async Task ProcessAsync(Guid assetId, CancellationToken cancellationToken)
@@ -107,7 +135,7 @@ public sealed class MediaProcessingBackgroundService(
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(x => x.Id == assetId, cancellationToken); 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; return null;
asset.MarkProcessing(); asset.MarkProcessing();
@@ -3,7 +3,7 @@ using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary>Неограниченная in-memory очередь id ассетов на обработку (один потребитель).</summary> /// <summary>Сигнальная очередь-будильник поверх Channel (id ассета используется лишь как сигнал).</summary>
public sealed class MediaProcessingQueue : IMediaProcessingQueue public sealed class MediaProcessingQueue : IMediaProcessingQueue
{ {
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>( private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
@@ -12,6 +12,10 @@ public sealed class MediaProcessingQueue : IMediaProcessingQueue
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId); public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
public IAsyncEnumerable<Guid> DequeueAllAsync(CancellationToken cancellationToken) => public async ValueTask WaitAsync(CancellationToken cancellationToken)
_channel.Reader.ReadAllAsync(cancellationToken); {
await _channel.Reader.ReadAsync(cancellationToken);
// Сдренировать накопившиеся сигналы — работу всё равно берём из БД пачкой.
while (_channel.Reader.TryRead(out _)) { }
}
} }
@@ -54,7 +54,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const { data: ready } = useQuery({ const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'], queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }), queryFn: () => listMedia({ page: 1, pageSize: 100, statuses: ['Ready'] }),
}) })
const { data: schedule } = useQuery({ const { data: schedule } = useQuery({
queryKey: ['admin', 'channels', channelId, 'schedule'], queryKey: ['admin', 'channels', channelId, 'schedule'],
@@ -6,11 +6,21 @@ import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { Badge, type BadgeProps } from '@/shared/ui/badge' import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia, uploadMedia } from './api' import { deleteMedia, listMedia, uploadMedia } from './api'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
type MediaFilter = 'active' | 'all' | 'Ready' | 'Failed'
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
active: ['Pending', 'Processing'],
all: [],
Ready: ['Ready'],
Failed: ['Failed'],
}
export function formatDuration(seconds: number | null): string { export function formatDuration(seconds: number | null): string {
if (seconds == null) return '—' if (seconds == null) return '—'
const total = Math.round(seconds) const total = Math.round(seconds)
@@ -35,10 +45,11 @@ export function MediaPanel() {
const [upload, setUpload] = useState<{ current: number; total: number; percent: number } | null>( const [upload, setUpload] = useState<{ current: number; total: number; percent: number } | null>(
null, null,
) )
const [filter, setFilter] = useState<MediaFilter>('active')
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'media'], queryKey: ['admin', 'media', filter],
queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE }), queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI. // Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending') query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
@@ -77,7 +88,20 @@ export function MediaPanel() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2> <div className="flex items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
<Select value={filter} onValueChange={(v) => setFilter(v as MediaFilter)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{upload != null && ( {upload != null && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
+2 -2
View File
@@ -9,7 +9,7 @@ import type {
export type ListMediaParams = { export type ListMediaParams = {
page: number page: number
pageSize: number pageSize: number
status?: MediaAssetStatus statuses?: MediaAssetStatus[]
search?: string search?: string
} }
@@ -18,7 +18,7 @@ export function listMedia(params: ListMediaParams) {
page: String(params.page), page: String(params.page),
pageSize: String(params.pageSize), 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) if (params.search) query.set('search', params.search)
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`) return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
} }
+122 -30
View File
@@ -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 { Link } from '@tanstack/react-router'
import { useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' 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 { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api' import { listMedia } from '@/features/admin/media/api'
import { formatDuration } from '@/features/admin/media/MediaPanel' import { formatDuration } from '@/features/admin/media/MediaPanel'
import { addEpisode, getShow, removeEpisode } from './api' 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 }) { export function ShowDetail({ showId }: { showId: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [assetId, setAssetId] = useState('') const [filter, setFilter] = useState('')
const [deselected, setDeselected] = useState<Set<string>>(new Set())
const [adding, setAdding] = useState<{ current: number; total: number } | null>(null)
const { data: show, isLoading } = useQuery({ const { data: show, isLoading } = useQuery({
queryKey: ['admin', 'shows', showId], queryKey: ['admin', 'shows', showId],
@@ -23,30 +39,70 @@ export function ShowDetail({ showId }: { showId: string }) {
}) })
const { data: ready } = useQuery({ const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'], 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 invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
const onError = (error: unknown) => const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')) toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const addMutation = useMutation({
mutationFn: () => addEpisode(showId, assetId),
onSuccess: () => {
setAssetId('')
invalidate()
},
onError,
})
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (episodeId: string) => removeEpisode(showId, episodeId), mutationFn: (episodeId: string) => removeEpisode(showId, episodeId),
onSuccess: invalidate, onSuccess: invalidate,
onError, onError,
}) })
// Кандидаты: готовые ассеты, ещё не добавленные в шоу, отфильтрованные по строке и упорядоченные
// по распознанному номеру сезона/серии (нераспознанные — в конец по имени).
const candidates = useMemo<Candidate[]>(() => {
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 <p className="text-muted-foreground">{t('common.loading')}</p> if (isLoading || !show) return <p className="text-muted-foreground">{t('common.loading')}</p>
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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -65,22 +121,58 @@ export function ShowDetail({ showId }: { showId: string }) {
</div> </div>
{canAdd && ( {canAdd && (
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-col gap-2">
<Select value={assetId} onValueChange={setAssetId}> <div className="flex flex-wrap items-center gap-2">
<SelectTrigger className="max-w-md"> <Input
<SelectValue placeholder={t('admin.shows.pickAsset')} /> className="max-w-md"
</SelectTrigger> placeholder={t('admin.shows.filterAssets')}
<SelectContent> value={filter}
{ready?.items.map((asset) => ( onChange={(e) => setFilter(e.target.value)}
<SelectItem key={asset.id} value={asset.id}> />
{asset.originalFileName} <Button size="sm" variant="outline" onClick={() => setDeselected(new Set())}>
</SelectItem> {t('admin.shows.selectAll')}
))} </Button>
</SelectContent> <Button
</Select> size="sm"
<Button size="sm" disabled={!assetId || addMutation.isPending} onClick={() => addMutation.mutate()}> variant="outline"
{t('admin.shows.addEpisode')} onClick={() => setDeselected(new Set(candidates.map((c) => c.asset.id)))}
</Button> >
{t('admin.shows.deselectAll')}
</Button>
<Button size="sm" disabled={selected.length === 0 || adding != null} onClick={() => void bulkAdd()}>
{adding
? `${adding.current}/${adding.total}`
: `${t('admin.shows.addSelected')} (${isSingle ? Math.min(1, selected.length) : selected.length})`}
</Button>
</div>
<div className="crt-panel max-h-72 overflow-y-auto rounded-md">
{candidates.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">{t('admin.shows.noMatches')}</p>
) : (
<ul className="divide-y divide-border text-sm">
{candidates.map(({ asset, se }) => (
<li key={asset.id}>
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
<input
type="checkbox"
checked={!deselected.has(asset.id)}
onChange={() => toggle(asset.id)}
/>
{se ? (
<Badge>
S{pad2(se.season)}E{pad2(se.episode)}
</Badge>
) : (
<Badge variant="muted"></Badge>
)}
<span className="truncate">{asset.originalFileName}</span>
</label>
</li>
))}
</ul>
)}
</div>
</div> </div>
)} )}
+16
View File
@@ -106,6 +106,8 @@ const resources = {
upload: 'Загрузить', upload: 'Загрузить',
uploaded: 'Файл загружен, идёт обработка', uploaded: 'Файл загружен, идёт обработка',
uploadedCount: 'Загружено файлов: {{count}}', uploadedCount: 'Загружено файлов: {{count}}',
filterActive: 'Активные',
filterAll: 'Все',
name: 'Файл', name: 'Файл',
status: 'Статус', status: 'Статус',
duration: 'Длительность', duration: 'Длительность',
@@ -128,6 +130,12 @@ const resources = {
addEpisode: 'Добавить серию', addEpisode: 'Добавить серию',
pickAsset: 'Выберите файл', pickAsset: 'Выберите файл',
noEpisodes: 'Серий пока нет', noEpisodes: 'Серий пока нет',
filterAssets: 'Фильтр по имени, напр. Family.Guy.S16',
selectAll: 'Выбрать все',
deselectAll: 'Снять все',
addSelected: 'Добавить выбранные',
noMatches: 'Нет подходящих файлов',
addedCount: 'Добавлено серий: {{count}}',
}, },
channels: { channels: {
title: 'Каналы', title: 'Каналы',
@@ -275,6 +283,8 @@ const resources = {
upload: 'Upload', upload: 'Upload',
uploaded: 'File uploaded, processing started', uploaded: 'File uploaded, processing started',
uploadedCount: 'Uploaded files: {{count}}', uploadedCount: 'Uploaded files: {{count}}',
filterActive: 'Active',
filterAll: 'All',
name: 'File', name: 'File',
status: 'Status', status: 'Status',
duration: 'Duration', duration: 'Duration',
@@ -297,6 +307,12 @@ const resources = {
addEpisode: 'Add episode', addEpisode: 'Add episode',
pickAsset: 'Pick a file', pickAsset: 'Pick a file',
noEpisodes: 'No episodes yet', 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: { channels: {
title: 'Channels', title: 'Channels',