diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index f65363f..3aeae9f 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -1,199 +1,225 @@ -using System.Text; -using System.Text.RegularExpressions; -using LiteCqrs; -using Microsoft.Extensions.Options; -using TeleWave.Api.Common; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; -using TeleWave.Application.Media; -using TeleWave.Application.Media.Delete; -using TeleWave.Application.Media.ListMedia; -using TeleWave.Application.Media.Register; -using TeleWave.Application.Media.Stats; -using TeleWave.Domain.Media; -using TeleWave.Infrastructure.Identity; -using TeleWave.Infrastructure.Media; - -namespace TeleWave.Api.Endpoints; - -public static class MediaEndpoints -{ - private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled); - - public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app) - { - var admin = app.MapGroup("/api/admin/media") - .WithTags("Admin.Media") - .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - - admin.MapPost("", Upload).Produces(StatusCodes.Status201Created); - admin.MapGet("", List).Produces>(); - admin.MapGet("/stats", Stats).Produces(); - admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); - - // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт - // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. - admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); - admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment); - - return app; - } - - /// - /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». - /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. - /// - private static async Task Upload( - string fileName, - HttpRequest request, - IMediaStorage storage, - IMediaProcessingQueue queue, - ISender sender, - IOptions mediaOptions, - IOptions storageOptions, - CancellationToken cancellationToken - ) - { - if (string.IsNullOrWhiteSpace(fileName)) - return Results.Problem( - title: MediaErrors.EmptyFileName.Code, - detail: MediaErrors.EmptyFileName.Message, - statusCode: StatusCodes.Status400BadRequest - ); - - if (!MediaFormats.IsAllowed(fileName)) - return Results.Problem( - title: MediaErrors.UnsupportedFormat.Code, - detail: MediaErrors.UnsupportedFormat.Message, - statusCode: StatusCodes.Status400BadRequest - ); - - var contentLength = request.ContentLength ?? 0; - if (contentLength > mediaOptions.Value.MaxUploadBytes) - return Results.Problem( - title: MediaErrors.FileTooLarge.Code, - detail: MediaErrors.FileTooLarge.Message, - statusCode: StatusCodes.Status400BadRequest - ); - - var free = storage.GetAvailableFreeSpaceBytes(); - if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes) - return Results.Problem( - title: MediaErrors.InsufficientStorage.Code, - detail: MediaErrors.InsufficientStorage.Message, - statusCode: StatusCodes.Status409Conflict - ); - - var extension = Path.GetExtension(fileName); - var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken); - - var result = await sender.Send( - new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), - cancellationToken - ); - if (!result.IsSuccess) - { - await storage.DeleteUploadAsync(token, cancellationToken); - return result.ToHttpResult(); - } - - queue.Enqueue(result.Value); - return Results.Created( - $"/api/admin/media/{result.Value}", - new UploadMediaResponse(result.Value) - ); - } - - private static async Task List( - int page, - int pageSize, - MediaAssetStatus[]? status, - string? search, - string? sort, - bool desc, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new ListMediaAssetsQuery( - page <= 0 ? 1 : page, - pageSize <= 0 ? 20 : pageSize, - status ?? [], - search, - sort, - desc - ), - cancellationToken - ); - return Results.Ok(result); - } - - private static async Task Stats(ISender sender, CancellationToken cancellationToken) - { - var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken); - return Results.Ok(result); - } - - private static async Task Delete( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken); - return result.ToHttpResult(); - } - - /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. - private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) - { - string indexPath; - try - { - indexPath = paths.SegmentPath(id, "index.m3u8"); - } - catch (UnauthorizedAccessException) - { - return Results.NotFound(); - } - if (!File.Exists(indexPath)) - return Results.NotFound(); - - var baseUrl = $"/api/admin/media/{id}/preview/"; - var sb = new StringBuilder(); - foreach (var line in File.ReadLines(indexPath)) - { - var trimmed = line.Trim(); - if (trimmed.Length == 0) - continue; - // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL. - sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed)) - .Append('\n'); - } - - return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); - } - - private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) - { - if (!SegmentFileName.IsMatch(file)) - return Results.NotFound(); - - string path; - try - { - path = paths.SegmentPath(id, file); - } - catch (UnauthorizedAccessException) - { - return Results.NotFound(); - } - if (!File.Exists(path)) - return Results.NotFound(); - - return Results.File(path, "video/mp2t", enableRangeProcessing: true); - } -} - -public sealed record UploadMediaResponse(Guid Id); +using System.Text; +using System.Text.RegularExpressions; +using LiteCqrs; +using Microsoft.Extensions.Options; +using TeleWave.Api.Common; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Media; +using TeleWave.Application.Media.Delete; +using TeleWave.Application.Media.ListMedia; +using TeleWave.Application.Media.ManualInbox; +using TeleWave.Application.Media.Register; +using TeleWave.Application.Media.Stats; +using TeleWave.Domain.Media; +using TeleWave.Infrastructure.Identity; +using TeleWave.Infrastructure.Media; + +namespace TeleWave.Api.Endpoints; + +public static class MediaEndpoints +{ + private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled); + + public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/media") + .WithTags("Admin.Media") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapPost("", Upload).Produces(StatusCodes.Status201Created); + admin.MapGet("", List).Produces>(); + admin.MapGet("/stats", Stats).Produces(); + admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); + + // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу. + admin.MapGet("/manual", ListManual).Produces(); + admin.MapPost("/manual/import", ImportManual).Produces(); + + // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт + // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. + admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); + admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment); + + return app; + } + + /// + /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». + /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. + /// + private static async Task Upload( + string fileName, + HttpRequest request, + IMediaStorage storage, + IMediaProcessingQueue queue, + ISender sender, + IOptions mediaOptions, + IOptions storageOptions, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(fileName)) + return Results.Problem( + title: MediaErrors.EmptyFileName.Code, + detail: MediaErrors.EmptyFileName.Message, + statusCode: StatusCodes.Status400BadRequest + ); + + if (!MediaFormats.IsAllowed(fileName)) + return Results.Problem( + title: MediaErrors.UnsupportedFormat.Code, + detail: MediaErrors.UnsupportedFormat.Message, + statusCode: StatusCodes.Status400BadRequest + ); + + var contentLength = request.ContentLength ?? 0; + if (contentLength > mediaOptions.Value.MaxUploadBytes) + return Results.Problem( + title: MediaErrors.FileTooLarge.Code, + detail: MediaErrors.FileTooLarge.Message, + statusCode: StatusCodes.Status400BadRequest + ); + + var free = storage.GetAvailableFreeSpaceBytes(); + if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes) + return Results.Problem( + title: MediaErrors.InsufficientStorage.Code, + detail: MediaErrors.InsufficientStorage.Message, + statusCode: StatusCodes.Status409Conflict + ); + + var extension = Path.GetExtension(fileName); + var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken); + + var result = await sender.Send( + new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), + cancellationToken + ); + if (!result.IsSuccess) + { + await storage.DeleteUploadAsync(token, cancellationToken); + return result.ToHttpResult(); + } + + queue.Enqueue(result.Value); + return Results.Created( + $"/api/admin/media/{result.Value}", + new UploadMediaResponse(result.Value) + ); + } + + private static async Task List( + int page, + int pageSize, + MediaAssetStatus[]? status, + string? search, + string? sort, + bool desc, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ListMediaAssetsQuery( + page <= 0 ? 1 : page, + pageSize <= 0 ? 20 : pageSize, + status ?? [], + search, + sort, + desc + ), + cancellationToken + ); + return Results.Ok(result); + } + + private static async Task Stats(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task Delete( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ListManual(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListManualInboxQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task ImportManual( + ImportManualInboxBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ImportManualInboxCommand(body.RelativePaths, body.ShowId), + cancellationToken + ); + return result.ToHttpResult(); + } + + /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. + private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) + { + string indexPath; + try + { + indexPath = paths.SegmentPath(id, "index.m3u8"); + } + catch (UnauthorizedAccessException) + { + return Results.NotFound(); + } + if (!File.Exists(indexPath)) + return Results.NotFound(); + + var baseUrl = $"/api/admin/media/{id}/preview/"; + var sb = new StringBuilder(); + foreach (var line in File.ReadLines(indexPath)) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0) + continue; + // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL. + sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed)) + .Append('\n'); + } + + return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); + } + + private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) + { + if (!SegmentFileName.IsMatch(file)) + return Results.NotFound(); + + string path; + try + { + path = paths.SegmentPath(id, file); + } + catch (UnauthorizedAccessException) + { + return Results.NotFound(); + } + if (!File.Exists(path)) + return Results.NotFound(); + + return Results.File(path, "video/mp2t", enableRangeProcessing: true); + } +} + +public sealed record UploadMediaResponse(Guid Id); + +public sealed record ImportManualInboxBody(IReadOnlyList RelativePaths, Guid ShowId); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs index f8c725a..931492e 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -1,45 +1,56 @@ -using TeleWave.Domain.Media; - -namespace TeleWave.Application.Common.Interfaces; - -/// -/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня -/// (Storage:RootPath) — защита от path traversal лежит на реализации. -/// -public interface IMediaStorage -{ - /// Свободное место на томе хранилища, байт. - long GetAvailableFreeSpaceBytes(); - - /// - /// Стримит загружаемый контент во временный файл в uploads/ без буферизации в память. - /// Возвращает непрозрачный токен (имя временного файла) для последующего . - /// - Task SaveUploadAsync( - Stream content, - string extension, - CancellationToken cancellationToken - ); - - /// Удаляет временный файл загрузки (откат при ошибке до регистрации ассета). - Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken); - - /// - /// Переносит исходник из источника (uploads/ для либо - /// inbox/ для ) в originals/{assetId}{ext}. - /// - Task PromoteToOriginalAsync( - MediaSource source, - string sourceToken, - Guid assetId, - string extension, - CancellationToken cancellationToken - ); - - /// Удаляет все артефакты ассета: исходник в originals/ и каталог сегментов assets/{id}/. - Task DeleteAssetArtifactsAsync( - Guid assetId, - string extension, - CancellationToken cancellationToken - ); -} +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня +/// (Storage:RootPath) — защита от path traversal лежит на реализации. +/// +public interface IMediaStorage +{ + /// Свободное место на томе хранилища, байт. + long GetAvailableFreeSpaceBytes(); + + /// + /// Стримит загружаемый контент во временный файл в uploads/ без буферизации в память. + /// Возвращает непрозрачный токен (имя временного файла) для последующего . + /// + Task SaveUploadAsync( + Stream content, + string extension, + CancellationToken cancellationToken + ); + + /// Удаляет временный файл загрузки (откат при ошибке до регистрации ассета). + Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken); + + /// Файл ручного inbox. + /// Путь относительно manual/ — может содержать подкаталоги. + public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes); + + /// + /// Что лежит в manual/, включая подкаталоги. ограничивает выдачу: + /// каталог наполняет человек, и он может оказаться большим. + /// + IReadOnlyList ListManualInbox(int max); + + /// + /// Переносит исходник в originals/{assetId}{ext}. Каталог-источник определяется + /// : uploads/, inbox/ либо manual/. Именно + /// переносит — файл из каталога-источника уходит. + /// + Task PromoteToOriginalAsync( + MediaSource source, + string sourceToken, + Guid assetId, + string extension, + CancellationToken cancellationToken + ); + + /// Удаляет все артефакты ассета: исходник в originals/ и каталог сегментов assets/{id}/. + Task DeleteAssetArtifactsAsync( + Guid assetId, + string extension, + CancellationToken cancellationToken + ); +} diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommand.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommand.cs new file mode 100644 index 0000000..026ceff --- /dev/null +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommand.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Media.ManualInbox; + +/// +/// Забирает выбранные файлы из manual/ в шоу. Файлы уходят из каталога, как и из обычного +/// inbox/. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить файлы +/// по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox. +/// +public sealed record ImportManualInboxCommand(IReadOnlyList RelativePaths, Guid ShowId) + : ICommand>; + +/// Итог разбора: что взяли и что не смогли — по файлам, а не одной ошибкой на всё. +public sealed record ImportManualInboxResultDto( + int Imported, + IReadOnlyList Failed +); + +public sealed record ImportFailureDto(string RelativePath, string Reason); + +public sealed class ImportManualInboxCommandValidator + : AbstractValidator +{ + public ImportManualInboxCommandValidator() + { + RuleFor(x => x.RelativePaths).NotEmpty(); + RuleFor(x => x.RelativePaths.Count).LessThanOrEqualTo(200); + RuleFor(x => x.ShowId).NotEmpty(); + } +} diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs new file mode 100644 index 0000000..db849a0 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs @@ -0,0 +1,126 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Media.ManualInbox; + +public sealed class ImportManualInboxCommandHandler( + IAppDbContext dbContext, + IMediaStorage storage, + IMediaProcessingQueue queue +) : ICommandHandler> +{ + public async Task> Handle( + ImportManualInboxCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext + .Shows.Include(s => s.Episodes) + .FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + var available = storage + .ListManualInbox(int.MaxValue) + .ToDictionary(f => f.RelativePath, StringComparer.Ordinal); + + var failed = new List(); + var imported = 0; + + // Файлы разбираются по одному и независимо: один неподдерживаемый контейнер не должен + // отменять весь пакет — в ручном разборе это как раз частый случай. + foreach (var relativePath in command.RelativePaths.Distinct(StringComparer.Ordinal)) + { + if (!available.TryGetValue(relativePath, out var file)) + { + failed.Add(new ImportFailureDto(relativePath, MediaErrors.SourceNotFound.Message)); + continue; + } + + if (!MediaFormats.IsAllowed(file.Name)) + { + failed.Add( + new ImportFailureDto(relativePath, MediaErrors.UnsupportedFormat.Message) + ); + continue; + } + + if (!show.CanAddEpisode) + { + failed.Add( + new ImportFailureDto(relativePath, ShowErrors.SingleAlreadyHasEpisode.Message) + ); + continue; + } + + var duplicate = await dbContext.MediaAssets.AnyAsync( + a => a.OriginalFileName == file.Name && a.Status != MediaAssetStatus.Failed, + cancellationToken + ); + if (duplicate) + { + failed.Add( + new ImportFailureDto(relativePath, MediaErrors.DuplicateFileName.Message) + ); + continue; + } + + var assetId = await RegisterAsync(file, relativePath, cancellationToken); + if (assetId is null) + { + failed.Add(new ImportFailureDto(relativePath, MediaErrors.SourceNotFound.Message)); + continue; + } + + // Номера серии — из имени файла, тем же разбором, что и при обычном добавлении серии. + var episode = show.AddEpisode(assetId.Value); + if (EpisodeName.Parse(file.Name) is { } parsed) + episode.SetNumbers(parsed.Season, parsed.Episode); + + queue.Enqueue(assetId.Value); + imported++; + } + + return Result.Success(new ImportManualInboxResultDto(imported, failed)); + } + + /// + /// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при обычной загрузке: + /// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы + /// не осталось записи без исходника. + /// + private async Task RegisterAsync( + IMediaStorage.ManualInboxFile file, + string relativePath, + CancellationToken cancellationToken + ) + { + var extension = Path.GetExtension(file.Name).ToLowerInvariant(); + var asset = MediaAsset.Register(file.Name, extension, MediaSource.ManualInbox); + + dbContext.MediaAssets.Add(asset); + await dbContext.SaveChangesAsync(cancellationToken); + try + { + await storage.PromoteToOriginalAsync( + MediaSource.ManualInbox, + relativePath, + asset.Id, + extension, + cancellationToken + ); + } + catch (IOException) + { + dbContext.MediaAssets.Remove(asset); + await dbContext.SaveChangesAsync(cancellationToken); + return null; + } + + return asset.Id; + } +} diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQuery.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQuery.cs new file mode 100644 index 0000000..3a99644 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQuery.cs @@ -0,0 +1,27 @@ +using LiteCqrs; + +namespace TeleWave.Application.Media.ManualInbox; + +/// +/// Что лежит в manual/. От обычного inbox/ отличается только тем, кто разбирает: +/// там сканер по таймеру, здесь человек из UI — и сразу указывает шоу. Файл в обоих случаях +/// уходит из каталога. +/// +public sealed record ListManualInboxQuery : IQuery; + +public sealed record ManualInboxFileDto( + /// Путь относительно manual/ — он же идентификатор файла в команде импорта. + string RelativePath, + string Name, + long SizeBytes, + /// Поддерживается ли контейнер. Неподдерживаемые показываем, чтобы не гадать, куда делся файл. + bool IsSupported, + /// Файл с таким именем уже есть в библиотеке — импорт отклонит его как дубликат. + bool AlreadyImported +); + +/// +/// честно говорит, что выдача обрезана лимитом: показать часть каталога +/// молча значило бы «файла нет» там, где он есть. +/// +public sealed record ManualInboxListDto(IReadOnlyList Files, bool Truncated); diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQueryHandler.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQueryHandler.cs new file mode 100644 index 0000000..474f246 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQueryHandler.cs @@ -0,0 +1,47 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Media.ManualInbox; + +public sealed class ListManualInboxQueryHandler(IAppDbContext dbContext, IMediaStorage storage) + : IQueryHandler +{ + /// Потолок выдачи: каталог наполняет человек, и он может оказаться огромным. + private const int MaxFiles = 500; + + public async Task Handle( + ListManualInboxQuery query, + CancellationToken cancellationToken + ) + { + // Просим на один больше лимита — так видно, что каталог не поместился целиком. + var files = storage.ListManualInbox(MaxFiles + 1); + var truncated = files.Count > MaxFiles; + var page = truncated ? files.Take(MaxFiles).ToList() : files; + if (page.Count == 0) + return new ManualInboxListDto([], false); + + // Дедуп — по имени файла, тем же правилом, что и при регистрации ассета. + var names = page.Select(f => f.Name).Distinct().ToList(); + var taken = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => names.Contains(a.OriginalFileName) && a.Status != MediaAssetStatus.Failed) + .Select(a => a.OriginalFileName) + .ToListAsync(cancellationToken); + var takenSet = taken.ToHashSet(StringComparer.Ordinal); + + return new ManualInboxListDto( + page.Select(f => new ManualInboxFileDto( + f.RelativePath, + f.Name, + f.SizeBytes, + MediaFormats.IsAllowed(f.Name), + takenSet.Contains(f.Name) + )) + .ToList(), + truncated + ); + } +} diff --git a/backend/src/TeleWave.Domain/Media/MediaSource.cs b/backend/src/TeleWave.Domain/Media/MediaSource.cs index c83bda6..0f797ae 100644 --- a/backend/src/TeleWave.Domain/Media/MediaSource.cs +++ b/backend/src/TeleWave.Domain/Media/MediaSource.cs @@ -1,15 +1,21 @@ -namespace TeleWave.Domain.Media; - -/// Откуда файл попал в хранилище. -public enum MediaSource -{ - /// Загружен через админку (chunked/stream upload в uploads/). - Upload, - - /// Положен вручную в inbox/ и подобран сканером. - Inbox, - - /// Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком. - /// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя). - Generated, -} +namespace TeleWave.Domain.Media; + +/// Откуда файл попал в хранилище. +public enum MediaSource +{ + /// Загружен через админку (chunked/stream upload в uploads/). + Upload, + + /// Положен вручную в inbox/ и подобран сканером. + Inbox, + + /// + /// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит + /// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу. + /// + ManualInbox = 3, + + /// Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком. + /// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя). + Generated, +} diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index 2918201..fb2f883 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -1,96 +1,118 @@ -using TeleWave.Application.Common.Interfaces; -using TeleWave.Domain.Media; - -namespace TeleWave.Infrastructure.Media; - -/// Файловая реализация поверх . -public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage -{ - private const int CopyBufferSize = 1024 * 1024; - - public long GetAvailableFreeSpaceBytes() - { - try - { - return new DriveInfo(paths.AssetsDir).AvailableFreeSpace; - } - catch (Exception ex) when (ex is ArgumentException or IOException) - { - // Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования). - return long.MaxValue; - } - } - - public async Task SaveUploadAsync( - Stream content, - string extension, - CancellationToken cancellationToken - ) - { - Directory.CreateDirectory(paths.UploadsDir); - var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant(); - var path = paths.UploadPath(token); - - await using var file = new FileStream( - path, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - CopyBufferSize, - useAsync: true - ); - await content.CopyToAsync(file, CopyBufferSize, cancellationToken); - return token; - } - - public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken) - { - var path = paths.UploadPath(uploadToken); - if (File.Exists(path)) - File.Delete(path); - return Task.CompletedTask; - } - - public Task PromoteToOriginalAsync( - MediaSource source, - string sourceToken, - Guid assetId, - string extension, - CancellationToken cancellationToken - ) - { - var sourcePath = - source == MediaSource.Inbox - ? paths.InboxPath(sourceToken) - : paths.UploadPath(sourceToken); - - if (!File.Exists(sourcePath)) - throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath); - - Directory.CreateDirectory(paths.OriginalsDir); - var destination = paths.OriginalPath(assetId, extension); - File.Move(sourcePath, destination, overwrite: true); - return Task.CompletedTask; - } - - public Task DeleteAssetArtifactsAsync( - Guid assetId, - string extension, - CancellationToken cancellationToken - ) => - // Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока - // (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O. - Task.Run( - () => - { - var original = paths.OriginalPath(assetId, extension); - if (File.Exists(original)) - File.Delete(original); - - var assetDir = paths.AssetDir(assetId); - if (Directory.Exists(assetDir)) - Directory.Delete(assetDir, recursive: true); - }, - cancellationToken - ); -} +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Media; + +namespace TeleWave.Infrastructure.Media; + +/// Файловая реализация поверх . +public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage +{ + private const int CopyBufferSize = 1024 * 1024; + + public long GetAvailableFreeSpaceBytes() + { + try + { + return new DriveInfo(paths.AssetsDir).AvailableFreeSpace; + } + catch (Exception ex) when (ex is ArgumentException or IOException) + { + // Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования). + return long.MaxValue; + } + } + + public async Task SaveUploadAsync( + Stream content, + string extension, + CancellationToken cancellationToken + ) + { + Directory.CreateDirectory(paths.UploadsDir); + var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant(); + var path = paths.UploadPath(token); + + await using var file = new FileStream( + path, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + CopyBufferSize, + useAsync: true + ); + await content.CopyToAsync(file, CopyBufferSize, cancellationToken); + return token; + } + + public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken) + { + var path = paths.UploadPath(uploadToken); + if (File.Exists(path)) + File.Delete(path); + return Task.CompletedTask; + } + + public IReadOnlyList ListManualInbox(int max) + { + if (!Directory.Exists(paths.ManualDir)) + return []; + + return Directory + .EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories) + .Take(Math.Max(1, max)) + .Select(path => new FileInfo(path)) + .Where(file => file.Exists) + .Select(file => new IMediaStorage.ManualInboxFile( + // Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса. + Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'), + file.Name, + file.Length + )) + .OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public Task PromoteToOriginalAsync( + MediaSource source, + string sourceToken, + Guid assetId, + string extension, + CancellationToken cancellationToken + ) + { + var sourcePath = source switch + { + MediaSource.Inbox => paths.InboxPath(sourceToken), + MediaSource.ManualInbox => paths.ManualPath(sourceToken), + _ => paths.UploadPath(sourceToken), + }; + + if (!File.Exists(sourcePath)) + throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath); + + Directory.CreateDirectory(paths.OriginalsDir); + var destination = paths.OriginalPath(assetId, extension); + File.Move(sourcePath, destination, overwrite: true); + return Task.CompletedTask; + } + + public Task DeleteAssetArtifactsAsync( + Guid assetId, + string extension, + CancellationToken cancellationToken + ) => + // Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока + // (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O. + Task.Run( + () => + { + var original = paths.OriginalPath(assetId, extension); + if (File.Exists(original)) + File.Delete(original); + + var assetDir = paths.AssetDir(assetId); + if (Directory.Exists(assetDir)) + Directory.Delete(assetDir, recursive: true); + }, + cancellationToken + ); +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs index 1b4622f..3eedf9e 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -1,98 +1,111 @@ -using Microsoft.Extensions.Options; - -namespace TeleWave.Infrastructure.Media; - -/// -/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из -/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня. -/// -public sealed class MediaPathResolver -{ - private readonly string _root; - - public MediaPathResolver(IOptions options) - { - _root = Path.GetFullPath(options.Value.RootPath); - InboxDir = Path.Combine(_root, "inbox"); - UploadsDir = Path.Combine(_root, "uploads"); - OriginalsDir = Path.Combine(_root, "originals"); - AssetsDir = Path.Combine(_root, "assets"); - BumpersDir = Path.Combine(_root, "bumpers"); - ImagesDir = Path.Combine(_root, "images"); - } - - public string InboxDir { get; } - public string UploadsDir { get; } - public string OriginalsDir { get; } - public string AssetsDir { get; } - - /// Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS. - public string BumpersDir { get; } - - /// Общий реестр изображений (галерея): файлы images/{imageId}{ext}. - public string ImagesDir { get; } - - public void EnsureDirectories() - { - Directory.CreateDirectory(InboxDir); - Directory.CreateDirectory(UploadsDir); - Directory.CreateDirectory(OriginalsDir); - Directory.CreateDirectory(AssetsDir); - Directory.CreateDirectory(BumpersDir); - Directory.CreateDirectory(ImagesDir); - } - - public string BumperTemplateDir(Guid templateId) => - EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"))); - - /// Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой). - public string BumperTemplateFilePath(Guid templateId, string kind, string extension) => - EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension)); - - /// Путь к файлу изображения общего реестра (extension — с точкой). - public string ImagePath(Guid imageId, string extension) => - EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension)); - - public string OriginalPath(Guid assetId, string extension) => - EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension)); - - public string AssetDir(Guid assetId) => - EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N"))); - - public string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}"; - - /// Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal). - public string SegmentPath(Guid assetId, string fileName) - { - var assetDir = AssetDir(assetId); - return EnsureWithin(assetDir, Path.Combine(assetDir, fileName)); - } - - /// Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога. - public string UploadPath(string token) => - EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token)); - - /// Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога. - public string InboxPath(string fileName) => - EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName)); - - private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate); - - private static string EnsureWithin(string baseDir, string candidate) - { - var full = Path.GetFullPath(candidate); - var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar) - ? baseDir - : baseDir + Path.DirectorySeparatorChar; - - if ( - !full.StartsWith(normalizedBase, StringComparison.Ordinal) - && !string.Equals(full, baseDir, StringComparison.Ordinal) - ) - throw new UnauthorizedAccessException( - $"Путь '{candidate}' выходит за пределы каталога хранилища." - ); - - return full; - } -} +using Microsoft.Extensions.Options; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из +/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня. +/// +public sealed class MediaPathResolver +{ + private readonly string _root; + + public MediaPathResolver(IOptions options) + { + _root = Path.GetFullPath(options.Value.RootPath); + InboxDir = Path.Combine(_root, "inbox"); + ManualDir = Path.Combine(_root, "manual"); + UploadsDir = Path.Combine(_root, "uploads"); + OriginalsDir = Path.Combine(_root, "originals"); + AssetsDir = Path.Combine(_root, "assets"); + BumpersDir = Path.Combine(_root, "bumpers"); + ImagesDir = Path.Combine(_root, "images"); + } + + public string InboxDir { get; } + + /// Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу. + public string ManualDir { get; } + public string UploadsDir { get; } + public string OriginalsDir { get; } + public string AssetsDir { get; } + + /// Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS. + public string BumpersDir { get; } + + /// Общий реестр изображений (галерея): файлы images/{imageId}{ext}. + public string ImagesDir { get; } + + public void EnsureDirectories() + { + Directory.CreateDirectory(InboxDir); + Directory.CreateDirectory(ManualDir); + Directory.CreateDirectory(UploadsDir); + Directory.CreateDirectory(OriginalsDir); + Directory.CreateDirectory(AssetsDir); + Directory.CreateDirectory(BumpersDir); + Directory.CreateDirectory(ImagesDir); + } + + public string BumperTemplateDir(Guid templateId) => + EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"))); + + /// Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой). + public string BumperTemplateFilePath(Guid templateId, string kind, string extension) => + EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension)); + + /// Путь к файлу изображения общего реестра (extension — с точкой). + public string ImagePath(Guid imageId, string extension) => + EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension)); + + public string OriginalPath(Guid assetId, string extension) => + EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension)); + + public string AssetDir(Guid assetId) => + EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N"))); + + public string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}"; + + /// Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal). + public string SegmentPath(Guid assetId, string fileName) + { + var assetDir = AssetDir(assetId); + return EnsureWithin(assetDir, Path.Combine(assetDir, fileName)); + } + + /// Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога. + public string UploadPath(string token) => + EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token)); + + /// Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога. + public string InboxPath(string fileName) => + EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName)); + + /// + /// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки + /// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь + /// обязательна: строка приходит из запроса. + /// + public string ManualPath(string relativePath) => + EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath)); + + private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate); + + private static string EnsureWithin(string baseDir, string candidate) + { + var full = Path.GetFullPath(candidate); + var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar) + ? baseDir + : baseDir + Path.DirectorySeparatorChar; + + if ( + !full.StartsWith(normalizedBase, StringComparison.Ordinal) + && !string.Equals(full, baseDir, StringComparison.Ordinal) + ) + throw new UnauthorizedAccessException( + $"Путь '{candidate}' выходит за пределы каталога хранилища." + ); + + return full; + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs new file mode 100644 index 0000000..b5f8297 --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs @@ -0,0 +1,133 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Media.ManualInbox; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Infrastructure.Persistence; +using Xunit; + +namespace TeleWave.Integration.Tests; + +/// +/// Ручной разбор manual/: выбранные файлы уходят из каталога в шоу. Проверяется против +/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске». +/// +[Collection("postgres")] +public sealed class ManualInboxIntegrationTests(PostgresFixture fixture) +{ + [SkippableFact] + public async Task Import_AddsEpisodesToShow_AndConsumesFiles() + { + Skip.IfNot(fixture.Available, "Docker недоступен"); + + await using var seedDb = fixture.CreateContext(); + var showId = await SeedShowAsync(seedDb); + var tag = Guid.NewGuid().ToString("N")[..8]; + var first = $"{tag}-s01e01.mkv"; + var second = $"{tag}-s01e02.mkv"; + + var storage = Substitute.For(); + storage + .ListManualInbox(Arg.Any()) + .Returns( + [ + new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000), + new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000), + ] + ); + + await using var db = fixture.CreateContext(); + var result = await new ImportManualInboxCommandHandler( + db, + storage, + Substitute.For() + ).Handle( + new ImportManualInboxCommand([$"Сериал/{first}", $"Сериал/{second}"], showId), + default + ); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value.Imported); + Assert.Empty(result.Value.Failed); + await db.SaveChangesAsync(); + + // Файлы должны быть перенесены из manual/ — ровно как из обычного inbox. + await storage + .Received(2) + .PromoteToOriginalAsync( + MediaSource.ManualInbox, + Arg.Any(), + Arg.Any(), + ".mkv", + Arg.Any() + ); + + await using var verify = fixture.CreateContext(); + var episodes = verify + .Shows.Where(s => s.Id == showId) + .SelectMany(s => s.Episodes) + .OrderBy(e => e.Position) + .ToList(); + + Assert.Equal(2, episodes.Count); + // Номера сезона и серии разбираются из имени файла, как при обычном добавлении. + Assert.Equal(1, episodes[0].Season); + Assert.Equal(1, episodes[0].Episode); + Assert.Equal(2, episodes[1].Episode); + + // Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже + // заводит записи из ручного inbox. + var assetIds = episodes.Select(e => e.MediaAssetId).ToList(); + var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList(); + Assert.Equal(2, assets.Count); + Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source)); + Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status)); + } + + [SkippableFact] + public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch() + { + Skip.IfNot(fixture.Available, "Docker недоступен"); + + await using var seedDb = fixture.CreateContext(); + var showId = await SeedShowAsync(seedDb); + + var tag = Guid.NewGuid().ToString("N")[..8]; + var good = $"{tag}-ok.mkv"; + + var storage = Substitute.For(); + storage + .ListManualInbox(Arg.Any()) + .Returns( + [ + new IMediaStorage.ManualInboxFile(good, good, 1000), + new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10), + ] + ); + + await using var db = fixture.CreateContext(); + var result = await new ImportManualInboxCommandHandler( + db, + storage, + Substitute.For() + ).Handle( + new ImportManualInboxCommand([good, "readme.txt", "ушёл.mkv"], showId), + default + ); + + // Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет. + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.Imported); + Assert.Equal(2, result.Value.Failed.Count); + Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt"); + Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv"); + } + + private static async Task SeedShowAsync(AppDbContext db) + { + var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series); + db.Shows.Add(show); + await db.SaveChangesAsync(); + return show.Id; + } +} diff --git a/docs/server-storage-setup.md b/docs/server-storage-setup.md index f71b2a6..442a719 100644 --- a/docs/server-storage-setup.md +++ b/docs/server-storage-setup.md @@ -1,200 +1,208 @@ -# Подготовка диска под медиахранилище (tvvm) - -Runbook: разметка, форматирование и монтирование диска `sdb` (700G) под хранилище TeleWave. -Выполняется **один раз** на сервере `tvvm` перед запуском фичи медиа. Все команды — от root -(`sudo -i` или с `sudo` перед каждой). - -> ⚠️ Команды разметки **уничтожают данные** на целевом диске. Диск `sdb` сейчас пустой (нет -> разделов), но каждый шаг ниже содержит проверку — не пропускайте их. - -Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги, -запись из контейнера (сейчас работает под root) возможна. - ---- - -## 0. Проверить, что это тот самый диск - -```bash -lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,FSTYPE /dev/sdb -``` - -Ожидается: `sdb` размером `700G`, тип `disk`, **без разделов и без точек монтирования**. Убедитесь -дополнительно, что на диске нет файловой системы и подписей: - -```bash -sudo wipefs -n /dev/sdb # -n = «сухой прогон», ничего не меняет -``` - -Если вывод пустой — диск чист, продолжаем. Если что-то нашлось (следы старой ФС/RAID) — -**остановитесь** и разберитесь, что это, прежде чем идти дальше. - ---- - -## 1. Создать таблицу разделов и один раздел - -GPT + один раздел на весь диск: - -```bash -sudo parted -s /dev/sdb mklabel gpt -sudo parted -s -a optimal /dev/sdb mkpart primary ext4 0% 100% -sudo partprobe /dev/sdb -lsblk /dev/sdb -``` - -Должен появиться `sdb1` размером ~700G. - ---- - -## 2. Отформатировать в ext4 - -`-m 1` уменьшает резерв под root с 5% до 1% (на диске данных резерв в 35 ГБ не нужен), `-L` даёт -метку тома: - -```bash -sudo mkfs.ext4 -m 1 -L telewave-media /dev/sdb1 -``` - ---- - -## 3. Создать точку монтирования - -```bash -sudo mkdir -p /srv/telewave/media -``` - ---- - -## 4. Прописать в /etc/fstab (монтирование по UUID) - -Монтируем по UUID, а не по имени `sdb1` — имя может измениться при добавлении дисков. Узнать UUID: - -```bash -sudo blkid /dev/sdb1 -``` - -Скопируйте значение `UUID="..."` и добавьте строку в `/etc/fstab` (подставьте свой UUID): - -``` -UUID=ВАШ-UUID /srv/telewave/media ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2 -``` - -Пояснения к опциям: -- `noatime` — не обновлять время доступа при чтении сегментов; заметно снижает лишние записи при - раздаче видео. -- `nofail` — если диск не подключился, система всё равно загрузится (приложение просто не сможет - писать, а не «висит» на загрузке). -- `x-systemd.device-timeout=10` — не ждать диск дольше 10с при старте. - -Проверить, что fstab корректен, и смонтировать: - -```bash -sudo systemctl daemon-reload -sudo mount -a -findmnt /srv/telewave/media -``` - -`findmnt` должен показать смонтированный `ext4` на `/dev/sdb1`. **Ошибка на этом шаге лучше, чем -на следующей перезагрузке** — если `mount -a` ругается, чините fstab сейчас. - ---- - -## 5. Рабочие каталоги и права - -Создать структуру, ожидаемую приложением: - -```bash -sudo mkdir -p /srv/telewave/media/{inbox,uploads,originals,assets} -``` - -**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount -пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root: - -```bash -sudo chown -R root:root /srv/telewave/media -sudo chmod -R 755 /srv/telewave/media -``` - -Чтобы вы могли класть файлы в `inbox/` вручную (SFTP/rsync) под своим пользователем, откройте на -запись именно этот каталог вашей группе: - -```bash -sudo chown root:$(id -gn) /srv/telewave/media/inbox -sudo chmod 775 /srv/telewave/media/inbox -``` - -> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile — -> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid: -> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox`, оставленного вам). - ---- - -## 6. Проброс в контейнер (bind mount) - -В `docker-compose.yml`, в сервис `app`, добавить том (этого пока **нет** в репозитории — появится -вместе с реализацией фичи, здесь для справки): - -```yaml -services: - app: - # ... существующая конфигурация ... - volumes: - - /srv/telewave/media:/media - mem_limit: 3g -``` - -И переменные окружения в `.env` (см. `docs/media-storage-and-streaming.md`): - -``` -Storage__RootPath=/media -``` - -Выбран **bind mount**: диск смонтирован в ОС через fstab (шаги 1–5) и виден всегда, независимо от -Docker — это важно, потому что файлы в `inbox/` кладутся вручную по SFTP/rsync, в том числе когда -контейнер остановлен. - -> **Альтернатива — named volume, монтируемый самим Docker** (не используем, для справки). Позволяет -> пропустить шаги 3–5, но диск тогда доступен только при запущенном контейнере, а подкаталоги всё -> равно надо создавать заранее. Пример: -> -> ```yaml -> services: -> app: -> volumes: -> - media:/media -> mem_limit: 3g -> volumes: -> media: -> driver: local -> driver_opts: -> type: ext4 -> device: /dev/disk/by-uuid/ВАШ-UUID -> o: noatime -> ``` -> -> Разметка и `mkfs.ext4` (шаги 1–2) обязательны в любом случае. - ---- - -## 7. Проверка записи из контейнера - -После добавления тома и пересборки образа убедиться, что контейнер пишет на диск: - -```bash -docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest' -``` - -Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее -(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение. - ---- - -## Приложение: если диск нужно расширить в будущем - -Если VM отдаст диску больше места (например `sdb` вырастет с 700G), после увеличения на стороне -гипервизора: - -```bash -sudo growpart /dev/sdb 1 # расширить раздел на весь диск -sudo resize2fs /dev/sdb1 # расширить ext4 (можно на смонтированном) -df -h /srv/telewave/media -``` +# Подготовка диска под медиахранилище (tvvm) + +Runbook: разметка, форматирование и монтирование диска `sdb` (700G) под хранилище TeleWave. +Выполняется **один раз** на сервере `tvvm` перед запуском фичи медиа. Все команды — от root +(`sudo -i` или с `sudo` перед каждой). + +> ⚠️ Команды разметки **уничтожают данные** на целевом диске. Диск `sdb` сейчас пустой (нет +> разделов), но каждый шаг ниже содержит проверку — не пропускайте их. + +Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги, +запись из контейнера (сейчас работает под root) возможна. + +--- + +## 0. Проверить, что это тот самый диск + +```bash +lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,FSTYPE /dev/sdb +``` + +Ожидается: `sdb` размером `700G`, тип `disk`, **без разделов и без точек монтирования**. Убедитесь +дополнительно, что на диске нет файловой системы и подписей: + +```bash +sudo wipefs -n /dev/sdb # -n = «сухой прогон», ничего не меняет +``` + +Если вывод пустой — диск чист, продолжаем. Если что-то нашлось (следы старой ФС/RAID) — +**остановитесь** и разберитесь, что это, прежде чем идти дальше. + +--- + +## 1. Создать таблицу разделов и один раздел + +GPT + один раздел на весь диск: + +```bash +sudo parted -s /dev/sdb mklabel gpt +sudo parted -s -a optimal /dev/sdb mkpart primary ext4 0% 100% +sudo partprobe /dev/sdb +lsblk /dev/sdb +``` + +Должен появиться `sdb1` размером ~700G. + +--- + +## 2. Отформатировать в ext4 + +`-m 1` уменьшает резерв под root с 5% до 1% (на диске данных резерв в 35 ГБ не нужен), `-L` даёт +метку тома: + +```bash +sudo mkfs.ext4 -m 1 -L telewave-media /dev/sdb1 +``` + +--- + +## 3. Создать точку монтирования + +```bash +sudo mkdir -p /srv/telewave/media +``` + +--- + +## 4. Прописать в /etc/fstab (монтирование по UUID) + +Монтируем по UUID, а не по имени `sdb1` — имя может измениться при добавлении дисков. Узнать UUID: + +```bash +sudo blkid /dev/sdb1 +``` + +Скопируйте значение `UUID="..."` и добавьте строку в `/etc/fstab` (подставьте свой UUID): + +``` +UUID=ВАШ-UUID /srv/telewave/media ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2 +``` + +Пояснения к опциям: +- `noatime` — не обновлять время доступа при чтении сегментов; заметно снижает лишние записи при + раздаче видео. +- `nofail` — если диск не подключился, система всё равно загрузится (приложение просто не сможет + писать, а не «висит» на загрузке). +- `x-systemd.device-timeout=10` — не ждать диск дольше 10с при старте. + +Проверить, что fstab корректен, и смонтировать: + +```bash +sudo systemctl daemon-reload +sudo mount -a +findmnt /srv/telewave/media +``` + +`findmnt` должен показать смонтированный `ext4` на `/dev/sdb1`. **Ошибка на этом шаге лучше, чем +на следующей перезагрузке** — если `mount -a` ругается, чините fstab сейчас. + +--- + +## 5. Рабочие каталоги и права + +Создать структуру, ожидаемую приложением: + +```bash +sudo mkdir -p /srv/telewave/media/{inbox,manual,uploads,originals,assets} +``` + +**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount +пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root: + +```bash +sudo chown -R root:root /srv/telewave/media +sudo chmod -R 755 /srv/telewave/media +``` + +Два каталога наполняете вы, а не приложение: + +- `inbox/` — разбирается сканером автоматически: файл с допустимым расширением и стабильным + размером регистрируется сам и уходит в обработку; +- `manual/` — **ручной разбор**: сканер сюда не заглядывает. Файлы видны в админке + (Медиа → «Из папки manual»), выбираются галочками и сразу привязываются к шоу. Импортированные + файлы уходят из каталога так же, как из `inbox/`. + +Чтобы класть в них файлы вручную (SFTP/rsync) под своим пользователем, откройте на запись именно +эти каталоги вашей группе: + +```bash +sudo chown root:$(id -gn) /srv/telewave/media/inbox /srv/telewave/media/manual +sudo chmod 775 /srv/telewave/media/inbox /srv/telewave/media/manual +``` + +> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile — +> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid: +> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox` и `manual`, оставленных вам). + +--- + +## 6. Проброс в контейнер (bind mount) + +В `docker-compose.yml`, в сервис `app`, добавить том (этого пока **нет** в репозитории — появится +вместе с реализацией фичи, здесь для справки): + +```yaml +services: + app: + # ... существующая конфигурация ... + volumes: + - /srv/telewave/media:/media + mem_limit: 3g +``` + +И переменные окружения в `.env` (см. `docs/media-storage-and-streaming.md`): + +``` +Storage__RootPath=/media +``` + +Выбран **bind mount**: диск смонтирован в ОС через fstab (шаги 1–5) и виден всегда, независимо от +Docker — это важно, потому что файлы в `inbox/` кладутся вручную по SFTP/rsync, в том числе когда +контейнер остановлен. + +> **Альтернатива — named volume, монтируемый самим Docker** (не используем, для справки). Позволяет +> пропустить шаги 3–5, но диск тогда доступен только при запущенном контейнере, а подкаталоги всё +> равно надо создавать заранее. Пример: +> +> ```yaml +> services: +> app: +> volumes: +> - media:/media +> mem_limit: 3g +> volumes: +> media: +> driver: local +> driver_opts: +> type: ext4 +> device: /dev/disk/by-uuid/ВАШ-UUID +> o: noatime +> ``` +> +> Разметка и `mkfs.ext4` (шаги 1–2) обязательны в любом случае. + +--- + +## 7. Проверка записи из контейнера + +После добавления тома и пересборки образа убедиться, что контейнер пишет на диск: + +```bash +docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest' +``` + +Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее +(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение. + +--- + +## Приложение: если диск нужно расширить в будущем + +Если VM отдаст диску больше места (например `sdb` вырастет с 700G), после увеличения на стороне +гипервизора: + +```bash +sudo growpart /dev/sdb 1 # расширить раздел на весь диск +sudo resize2fs /dev/sdb1 # расширить ext4 (можно на смонтированном) +df -h /srv/telewave/media +``` diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxDialog.tsx new file mode 100644 index 0000000..5f9f43e --- /dev/null +++ b/frontend/src/features/admin/media/ManualInboxDialog.tsx @@ -0,0 +1,189 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { listShows } from '@/features/admin/shows/api' +import { HttpError } from '@/shared/api/client' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { importManualInbox, listManualInbox } from './api' + +/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */ +function formatSize(bytes: number): string { + const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}` +} + +/** + * Ручной inbox (см. `manual/`): каталог не сканируется, файлы выбирает админ и сразу указывает шоу. + * Импортированные файлы уходят из каталога — ровно как из обычного inbox. + */ +export function ManualInboxDialog({ onClose }: { onClose: () => void }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [selected, setSelected] = useState([]) + const [showId, setShowId] = useState('') + const [query, setQuery] = useState('') + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'media', 'manual'], + queryFn: listManualInbox, + }) + const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() }) + + const importMutation = useMutation({ + mutationFn: () => importManualInbox(selected, showId), + onSuccess: (result) => { + if (result.imported > 0) + toast.success(t('admin.media.manualImported', { count: result.imported })) + // Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге. + for (const failure of result.failed) + toast.error(`${failure.relativePath}: ${failure.reason}`) + + setSelected([]) + void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) + void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] }) + if (result.failed.length === 0) onClose() + }, + onError: (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')), + }) + + const files = (data?.files ?? []).filter((file) => + query.trim() ? file.relativePath.toLowerCase().includes(query.trim().toLowerCase()) : true, + ) + const importable = files.filter((f) => f.isSupported && !f.alreadyImported) + + const toggle = (path: string) => + setSelected((current) => + current.includes(path) ? current.filter((p) => p !== path) : [...current, path], + ) + + return ( + !open && onClose()}> + + + {t('admin.media.manualTitle')} + {t('admin.media.manualHint')} + + +
+
+ setQuery(e.target.value)} + /> + + + {t('admin.media.manualSelected', { count: selected.length })} + +
+ +
    + {isLoading && ( +
  • {t('common.loading')}
  • + )} + {!isLoading && files.length === 0 && ( +
  • {t('admin.media.manualEmpty')}
  • + )} + {files.map((file) => { + const blocked = !file.isSupported || file.alreadyImported + return ( +
  • + toggle(file.relativePath)} + /> + + {file.relativePath} + + {!file.isSupported && ( + {t('admin.media.manualUnsupported')} + )} + {file.alreadyImported && ( + {t('admin.media.manualAlready')} + )} + + {formatSize(file.sizeBytes)} + +
  • + ) + })} +
+ + {data?.truncated && ( +

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

+ )} + +
+ + + {t('admin.media.manualOrderHint')} + +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 635ab6b..612a444 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -1,274 +1,282 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { ListPlus, Upload } from 'lucide-react' -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 { Pager } from '@/shared/ui/pager' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' -import { SortHeader, useTableSort } from '@/shared/ui/sortable' -import { toast } from '@/shared/ui/toast-store' -import { deleteMedia, getMediaStats, listMedia } from './api' -import { UploadToShowDialog } from './UploadToShowDialog' -import { useUploadStore } from './upload-store' - -const PAGE_SIZE = 20 - -type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed' - -const filterStatuses: Record = { - active: ['Pending', 'Processing'], - Pending: ['Pending'], - Processing: ['Processing'], - all: [], - Ready: ['Ready'], - Failed: ['Failed'], -} - -export function formatDuration(seconds: number | null): string { - if (seconds == null) return '—' - const total = Math.round(seconds) - const h = Math.floor(total / 3600) - const m = Math.floor((total % 3600) / 60) - const s = total % 60 - const pad = (n: number) => String(n).padStart(2, '0') - return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}` -} - -const statusVariant: Record = { - Ready: 'default', - Processing: 'muted', - Pending: 'muted', - Failed: 'destructive', -} - -export function MediaPanel() { - const { t } = useTranslation() - const queryClient = useQueryClient() - const fileInput = useRef(null) - const fileInputShow = useRef(null) - const [filter, setFilter] = useState('active') - const [page, setPage] = useState(1) - const { sort, toggle } = useTableSort('created', true) - const [filesForShow, setFilesForShow] = useState(null) - const enqueue = useUploadStore((s) => s.enqueue) - - const sortColumn = (key: string) => { - setPage(1) - toggle(key) - } - - const { data, isLoading } = useQuery({ - queryKey: ['admin', 'media', filter, page, sort.key, sort.desc], - queryFn: () => - listMedia({ - page, - pageSize: PAGE_SIZE, - statuses: filterStatuses[filter], - sort: sort.key, - desc: sort.desc, - }), - // Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI. - refetchInterval: (query) => - query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending') - ? 4000 - : false, - }) - - const { data: stats } = useQuery({ - queryKey: ['admin', 'media', 'stats'], - queryFn: getMediaStats, - // Пока есть незавершённая работа — освежаем чипы очереди/обработки. - refetchInterval: (query) => - (query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000, - }) - - const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) - const onError = (error: unknown) => - toast.error(error instanceof HttpError ? error.detail : t('common.error')) - - const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError }) - - return ( -
-
-
-

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

- - - {stats && ( -
- - {t('admin.media.stats.queuedShort')}:{' '} - {stats.queued} - - - {t('admin.media.stats.processingShort')}:{' '} - {stats.processing} - - - {t('admin.media.stats.averageShort')}:{' '} - - {formatDuration(stats.averageProcessingSeconds)} - - -
- )} -
-
- { - const files = e.target.files - if (files && files.length > 0) void enqueue(Array.from(files)) - e.target.value = '' - }} - /> - { - const files = e.target.files - if (files && files.length > 0) setFilesForShow(Array.from(files)) - e.target.value = '' - }} - /> - - -
-
- - {filesForShow && ( - setFilesForShow(null)} /> - )} - -
- - - - - - - - - - - - - {isLoading && ( - - - - )} - {data?.items.map((asset) => ( - deleteMutation.mutate(asset.id)} - /> - ))} - {data && data.items.length === 0 && !isLoading && ( - - - - )} - -
{t('common.actions')}
- {t('common.loading')} -
- {t('admin.media.empty')} -
-
- - -
- ) -} - -function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => void }) { - const { t } = useTranslation() - return ( - - {asset.originalFileName} - - - {t(`admin.media.statuses.${asset.status}`)} - - - {formatDuration(asset.durationSeconds)} - - {asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'} - - - {asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'} - - - - - - ) -} +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FolderInput, ListPlus, Upload } from 'lucide-react' +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 { Pager } from '@/shared/ui/pager' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { SortHeader, useTableSort } from '@/shared/ui/sortable' +import { toast } from '@/shared/ui/toast-store' +import { deleteMedia, getMediaStats, listMedia } from './api' +import { ManualInboxDialog } from './ManualInboxDialog' +import { UploadToShowDialog } from './UploadToShowDialog' +import { useUploadStore } from './upload-store' + +const PAGE_SIZE = 20 + +type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed' + +const filterStatuses: Record = { + active: ['Pending', 'Processing'], + Pending: ['Pending'], + Processing: ['Processing'], + all: [], + Ready: ['Ready'], + Failed: ['Failed'], +} + +export function formatDuration(seconds: number | null): string { + if (seconds == null) return '—' + const total = Math.round(seconds) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + const pad = (n: number) => String(n).padStart(2, '0') + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}` +} + +const statusVariant: Record = { + Ready: 'default', + Processing: 'muted', + Pending: 'muted', + Failed: 'destructive', +} + +export function MediaPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const fileInput = useRef(null) + const fileInputShow = useRef(null) + const [filter, setFilter] = useState('active') + const [page, setPage] = useState(1) + const { sort, toggle } = useTableSort('created', true) + const [filesForShow, setFilesForShow] = useState(null) + const [manualOpen, setManualOpen] = useState(false) + const enqueue = useUploadStore((s) => s.enqueue) + + const sortColumn = (key: string) => { + setPage(1) + toggle(key) + } + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'media', filter, page, sort.key, sort.desc], + queryFn: () => + listMedia({ + page, + pageSize: PAGE_SIZE, + statuses: filterStatuses[filter], + sort: sort.key, + desc: sort.desc, + }), + // Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI. + refetchInterval: (query) => + query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending') + ? 4000 + : false, + }) + + const { data: stats } = useQuery({ + queryKey: ['admin', 'media', 'stats'], + queryFn: getMediaStats, + // Пока есть незавершённая работа — освежаем чипы очереди/обработки. + refetchInterval: (query) => + (query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000, + }) + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError }) + + return ( +
+
+
+

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

+ + + {stats && ( +
+ + {t('admin.media.stats.queuedShort')}:{' '} + {stats.queued} + + + {t('admin.media.stats.processingShort')}:{' '} + {stats.processing} + + + {t('admin.media.stats.averageShort')}:{' '} + + {formatDuration(stats.averageProcessingSeconds)} + + +
+ )} +
+
+ { + const files = e.target.files + if (files && files.length > 0) void enqueue(Array.from(files)) + e.target.value = '' + }} + /> + { + const files = e.target.files + if (files && files.length > 0) setFilesForShow(Array.from(files)) + e.target.value = '' + }} + /> + + + +
+
+ + {filesForShow && ( + setFilesForShow(null)} /> + )} + + {manualOpen && setManualOpen(false)} />} + +
+ + + + + + + + + + + + + {isLoading && ( + + + + )} + {data?.items.map((asset) => ( + deleteMutation.mutate(asset.id)} + /> + ))} + {data && data.items.length === 0 && !isLoading && ( + + + + )} + +
{t('common.actions')}
+ {t('common.loading')} +
+ {t('admin.media.empty')} +
+
+ + +
+ ) +} + +function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => void }) { + const { t } = useTranslation() + return ( + + {asset.originalFileName} + + + {t(`admin.media.statuses.${asset.status}`)} + + + {formatDuration(asset.durationSeconds)} + + {asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'} + + + {asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'} + + + + + + ) +} diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts index c3a65ff..2c1230d 100644 --- a/frontend/src/features/admin/media/api.ts +++ b/frontend/src/features/admin/media/api.ts @@ -1,111 +1,126 @@ -import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' -import type { - CreatedIdResponse, - MediaAssetDto, - MediaAssetStatus, - MediaStatsDto, - PagedList, -} from '@/shared/api/types' - -export type ListMediaParams = { - page: number - pageSize: number - statuses?: MediaAssetStatus[] - search?: string - sort?: string - desc?: boolean -} - -export function listMedia(params: ListMediaParams) { - const query = new URLSearchParams({ - page: String(params.page), - pageSize: String(params.pageSize), - }) - for (const status of params.statuses ?? []) query.append('status', status) - if (params.search) query.set('search', params.search) - if (params.sort) query.set('sort', params.sort) - if (params.desc) query.set('desc', 'true') - return apiRequest>(`/admin/media?${query.toString()}`) -} - -export function getMediaStats() { - return apiRequest('/admin/media/stats') -} - -/** - * Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий, - * пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`: - * если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид, - * что список полон). - */ -export async function listAllMedia( - params: Omit & { cap?: number }, -): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> { - const pageSize = 200 - const cap = params.cap ?? 5000 - const items: MediaAssetDto[] = [] - let total = 0 - for (let page = 1; ; page++) { - const res = await listMedia({ - page, - pageSize, - statuses: params.statuses, - search: params.search, - }) - total = res.total - items.push(...res.items) - if (res.items.length === 0 || items.length >= total || items.length >= cap) break - } - return { items, total, truncated: items.length < total } -} - -export function deleteMedia(id: string) { - return apiRequest(`/admin/media/${id}`, { method: 'DELETE' }) -} - -/** - * Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса. - */ -export function uploadMedia( - file: File, - onProgress?: (percent: number) => void, - signal?: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new DOMException('Aborted', 'AbortError')) - return - } - - const xhr = new XMLHttpRequest() - const query = new URLSearchParams({ fileName: file.name }) - xhr.open('POST', `/api/admin/media?${query.toString()}`) - - const token = getAccessToken() - if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) - - signal?.addEventListener('abort', () => xhr.abort()) - xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError')) - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable && onProgress) - onProgress(Math.round((event.loaded / event.total) * 100)) - } - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(JSON.parse(xhr.responseText) as CreatedIdResponse) - } else { - let detail = `HTTP ${xhr.status}` - try { - const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string } - detail = problem.detail ?? problem.title ?? detail - } catch { - /* пусто */ - } - reject(new HttpError({ detail }, xhr.status)) - } - } - xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0)) - xhr.send(file) - }) -} +import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' +import type { + CreatedIdResponse, + ImportManualInboxResultDto, + ManualInboxListDto, + MediaAssetDto, + MediaAssetStatus, + MediaStatsDto, + PagedList, +} from '@/shared/api/types' + +export type ListMediaParams = { + page: number + pageSize: number + statuses?: MediaAssetStatus[] + search?: string + sort?: string + desc?: boolean +} + +export function listMedia(params: ListMediaParams) { + const query = new URLSearchParams({ + page: String(params.page), + pageSize: String(params.pageSize), + }) + for (const status of params.statuses ?? []) query.append('status', status) + if (params.search) query.set('search', params.search) + if (params.sort) query.set('sort', params.sort) + if (params.desc) query.set('desc', 'true') + return apiRequest>(`/admin/media?${query.toString()}`) +} + +export function getMediaStats() { + return apiRequest('/admin/media/stats') +} + +/** + * Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий, + * пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`: + * если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид, + * что список полон). + */ +export async function listAllMedia( + params: Omit & { cap?: number }, +): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> { + const pageSize = 200 + const cap = params.cap ?? 5000 + const items: MediaAssetDto[] = [] + let total = 0 + for (let page = 1; ; page++) { + const res = await listMedia({ + page, + pageSize, + statuses: params.statuses, + search: params.search, + }) + total = res.total + items.push(...res.items) + if (res.items.length === 0 || items.length >= total || items.length >= cap) break + } + return { items, total, truncated: items.length < total } +} + +/** Что лежит в ручном inbox (manual/) и ждёт разбора. */ +export function listManualInbox() { + return apiRequest('/admin/media/manual') +} + +/** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */ +export function importManualInbox(relativePaths: string[], showId: string) { + return apiRequest('/admin/media/manual/import', { + method: 'POST', + body: { relativePaths, showId }, + }) +} + +export function deleteMedia(id: string) { + return apiRequest(`/admin/media/${id}`, { method: 'DELETE' }) +} + +/** + * Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса. + */ +export function uploadMedia( + file: File, + onProgress?: (percent: number) => void, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + + const xhr = new XMLHttpRequest() + const query = new URLSearchParams({ fileName: file.name }) + xhr.open('POST', `/api/admin/media?${query.toString()}`) + + const token = getAccessToken() + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) + + signal?.addEventListener('abort', () => xhr.abort()) + xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError')) + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && onProgress) + onProgress(Math.round((event.loaded / event.total) * 100)) + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText) as CreatedIdResponse) + } else { + let detail = `HTTP ${xhr.status}` + try { + const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string } + detail = problem.detail ?? problem.title ?? detail + } catch { + /* пусто */ + } + reject(new HttpError({ detail }, xhr.status)) + } + } + xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0)) + xhr.send(file) + }) +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index daad4ea..ae2d5bc 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -62,7 +62,27 @@ export type CreatedIdResponse = { id: string } // ── Медиа ──────────────────────────────────────────────────────────────── export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed' -export type MediaSource = 'Upload' | 'Inbox' +export type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated' + +/** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */ +export type ManualInboxFileDto = { + relativePath: string + name: string + sizeBytes: number + isSupported: boolean + alreadyImported: boolean +} + +export type ManualInboxListDto = { + files: ManualInboxFileDto[] + /** Выдача обрезана лимитом — в каталоге есть ещё. */ + truncated: boolean +} + +export type ImportManualInboxResultDto = { + imported: number + failed: { relativePath: string; reason: string }[] +} export type MediaAssetDto = { id: string diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 746cf16..5e45c15 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -201,6 +201,20 @@ const resources = { media: { title: 'Медиа', upload: 'Загрузить', + manualButton: 'Из папки manual', + manualTitle: 'Ручной разбор папки manual', + manualHint: + 'Файлы из manual/ не разбираются сканером — выберите нужные и укажите шоу. Импортированные файлы уходят из папки, как и из inbox.', + manualSelectAll: 'Выбрать все', + manualSelected: 'Выбрано: {{count}}', + manualEmpty: 'В папке manual пусто', + manualUnsupported: 'формат не поддерживается', + manualAlready: 'уже в библиотеке', + manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.', + manualPickShow: 'Выберите шоу', + manualOrderHint: 'Номера сезона и серии определяются по именам файлов.', + manualImport: 'Забрать в шоу', + manualImported: 'Импортировано файлов: {{count}}', uploadToShow: 'Загрузить в шоу', toShowTitle: 'Загрузить и добавить в шоу', autoDetectHint: @@ -863,6 +877,20 @@ const resources = { media: { title: 'Media', upload: 'Upload', + manualButton: 'From manual folder', + manualTitle: 'Manual pick from the manual folder', + manualHint: + 'Files in manual/ are not picked up by the scanner — select the ones you need and choose a show. Imported files leave the folder, just like from inbox.', + manualSelectAll: 'Select all', + manualSelected: 'Selected: {{count}}', + manualEmpty: 'The manual folder is empty', + manualUnsupported: 'unsupported format', + manualAlready: 'already in the library', + manualTruncated: 'Showing the first 500 files — there are more in the folder.', + manualPickShow: 'Pick a show', + manualOrderHint: 'Season and episode numbers are taken from the file names.', + manualImport: 'Import into show', + manualImported: 'Files imported: {{count}}', uploadToShow: 'Upload to show', toShowTitle: 'Upload and add to show', autoDetectHint: