Refactor media storage and management functionalities: enhance IMediaStorage interface with manual inbox handling, update FileSystemMediaStorage to support manual file imports, and improve MediaPathResolver for better path management. Extend MediaEndpoints to include new manual inbox features and update frontend components for improved media management experience.
build / backend (push) Successful in 2m8s
build / frontend (push) Successful in 36s
tests / backend-tests (push) Successful in 1m35s

This commit is contained in:
Leonid Pershin
2026-07-26 15:16:55 +03:00
parent 2445ba56b5
commit b602d099ca
16 changed files with 1750 additions and 1039 deletions
@@ -1,199 +1,225 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using LiteCqrs; using LiteCqrs;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Media; using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete; using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia; using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.Register; using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.Stats; using TeleWave.Application.Media.Register;
using TeleWave.Domain.Media; using TeleWave.Application.Media.Stats;
using TeleWave.Infrastructure.Identity; using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Media; using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{ public static class MediaEndpoints
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled); {
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{ public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
var admin = app.MapGroup("/api/admin/media") {
.WithTags("Admin.Media") var admin = app.MapGroup("/api/admin/media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>(); admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>(); admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment); admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
return app; // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
} // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
/// <summary> admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. return app;
/// </summary> }
private static async Task<IResult> Upload(
string fileName, /// <summary>
HttpRequest request, /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
IMediaStorage storage, /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
IMediaProcessingQueue queue, /// </summary>
ISender sender, private static async Task<IResult> Upload(
IOptions<MediaOptions> mediaOptions, string fileName,
IOptions<StorageOptions> storageOptions, HttpRequest request,
CancellationToken cancellationToken IMediaStorage storage,
) IMediaProcessingQueue queue,
{ ISender sender,
if (string.IsNullOrWhiteSpace(fileName)) IOptions<MediaOptions> mediaOptions,
return Results.Problem( IOptions<StorageOptions> storageOptions,
title: MediaErrors.EmptyFileName.Code, CancellationToken cancellationToken
detail: MediaErrors.EmptyFileName.Message, )
statusCode: StatusCodes.Status400BadRequest {
); if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem(
if (!MediaFormats.IsAllowed(fileName)) title: MediaErrors.EmptyFileName.Code,
return Results.Problem( detail: MediaErrors.EmptyFileName.Message,
title: MediaErrors.UnsupportedFormat.Code, statusCode: StatusCodes.Status400BadRequest
detail: MediaErrors.UnsupportedFormat.Message, );
statusCode: StatusCodes.Status400BadRequest
); if (!MediaFormats.IsAllowed(fileName))
return Results.Problem(
var contentLength = request.ContentLength ?? 0; title: MediaErrors.UnsupportedFormat.Code,
if (contentLength > mediaOptions.Value.MaxUploadBytes) detail: MediaErrors.UnsupportedFormat.Message,
return Results.Problem( statusCode: StatusCodes.Status400BadRequest
title: MediaErrors.FileTooLarge.Code, );
detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest var contentLength = request.ContentLength ?? 0;
); if (contentLength > mediaOptions.Value.MaxUploadBytes)
return Results.Problem(
var free = storage.GetAvailableFreeSpaceBytes(); title: MediaErrors.FileTooLarge.Code,
if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes) detail: MediaErrors.FileTooLarge.Message,
return Results.Problem( statusCode: StatusCodes.Status400BadRequest
title: MediaErrors.InsufficientStorage.Code, );
detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict var free = storage.GetAvailableFreeSpaceBytes();
); if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes)
return Results.Problem(
var extension = Path.GetExtension(fileName); title: MediaErrors.InsufficientStorage.Code,
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken); detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict
var result = await sender.Send( );
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken var extension = Path.GetExtension(fileName);
); var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
if (!result.IsSuccess)
{ var result = await sender.Send(
await storage.DeleteUploadAsync(token, cancellationToken); new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
return result.ToHttpResult(); cancellationToken
} );
if (!result.IsSuccess)
queue.Enqueue(result.Value); {
return Results.Created( await storage.DeleteUploadAsync(token, cancellationToken);
$"/api/admin/media/{result.Value}", return result.ToHttpResult();
new UploadMediaResponse(result.Value) }
);
} queue.Enqueue(result.Value);
return Results.Created(
private static async Task<IResult> List( $"/api/admin/media/{result.Value}",
int page, new UploadMediaResponse(result.Value)
int pageSize, );
MediaAssetStatus[]? status, }
string? search,
string? sort, private static async Task<IResult> List(
bool desc, int page,
ISender sender, int pageSize,
CancellationToken cancellationToken MediaAssetStatus[]? status,
) string? search,
{ string? sort,
var result = await sender.Send( bool desc,
new ListMediaAssetsQuery( ISender sender,
page <= 0 ? 1 : page, CancellationToken cancellationToken
pageSize <= 0 ? 20 : pageSize, )
status ?? [], {
search, var result = await sender.Send(
sort, new ListMediaAssetsQuery(
desc page <= 0 ? 1 : page,
), pageSize <= 0 ? 20 : pageSize,
cancellationToken status ?? [],
); search,
return Results.Ok(result); sort,
} desc
),
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken) cancellationToken
{ );
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken); return Results.Ok(result);
return Results.Ok(result); }
}
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Delete( {
Guid id, var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
ISender sender, return Results.Ok(result);
CancellationToken cancellationToken }
)
{ private static async Task<IResult> Delete(
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken); Guid id,
return result.ToHttpResult(); ISender sender,
} CancellationToken cancellationToken
)
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary> {
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
{ return result.ToHttpResult();
string indexPath; }
try
{ private static async Task<IResult> ListManual(ISender sender, CancellationToken cancellationToken)
indexPath = paths.SegmentPath(id, "index.m3u8"); {
} var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
catch (UnauthorizedAccessException) return Results.Ok(result);
{ }
return Results.NotFound();
} private static async Task<IResult> ImportManual(
if (!File.Exists(indexPath)) ImportManualInboxBody body,
return Results.NotFound(); ISender sender,
CancellationToken cancellationToken
var baseUrl = $"/api/admin/media/{id}/preview/"; )
var sb = new StringBuilder(); {
foreach (var line in File.ReadLines(indexPath)) var result = await sender.Send(
{ new ImportManualInboxCommand(body.RelativePaths, body.ShowId),
var trimmed = line.Trim(); cancellationToken
if (trimmed.Length == 0) );
continue; return result.ToHttpResult();
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL. }
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n'); /// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
} private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); string indexPath;
} try
{
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) indexPath = paths.SegmentPath(id, "index.m3u8");
{ }
if (!SegmentFileName.IsMatch(file)) catch (UnauthorizedAccessException)
return Results.NotFound(); {
return Results.NotFound();
string path; }
try if (!File.Exists(indexPath))
{ return Results.NotFound();
path = paths.SegmentPath(id, file);
} var baseUrl = $"/api/admin/media/{id}/preview/";
catch (UnauthorizedAccessException) var sb = new StringBuilder();
{ foreach (var line in File.ReadLines(indexPath))
return Results.NotFound(); {
} var trimmed = line.Trim();
if (!File.Exists(path)) if (trimmed.Length == 0)
return Results.NotFound(); continue;
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
return Results.File(path, "video/mp2t", enableRangeProcessing: true); sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
} .Append('\n');
} }
public sealed record UploadMediaResponse(Guid Id); 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<string> RelativePaths, Guid ShowId);
@@ -1,45 +1,56 @@
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces; namespace TeleWave.Application.Common.Interfaces;
/// <summary> /// <summary>
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня /// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации. /// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
/// </summary> /// </summary>
public interface IMediaStorage public interface IMediaStorage
{ {
/// <summary>Свободное место на томе хранилища, байт.</summary> /// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes(); long GetAvailableFreeSpaceBytes();
/// <summary> /// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память. /// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>. /// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
/// </summary> /// </summary>
Task<string> SaveUploadAsync( Task<string> SaveUploadAsync(
Stream content, Stream content,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
); );
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary> /// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken); Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary> /// <summary>Файл ручного inbox.</summary>
/// Переносит исходник из источника (<c>uploads/</c> для <see cref="MediaSource.Upload"/> либо /// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
/// <c>inbox/</c> для <see cref="MediaSource.Inbox"/>) в <c>originals/{assetId}{ext}</c>. public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
/// </summary>
Task PromoteToOriginalAsync( /// <summary>
MediaSource source, /// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
string sourceToken, /// каталог наполняет человек, и он может оказаться большим.
Guid assetId, /// </summary>
string extension, IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
CancellationToken cancellationToken
); /// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary> /// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
Task DeleteAssetArtifactsAsync( /// переносит — файл из каталога-источника уходит.
Guid assetId, /// </summary>
string extension, Task PromoteToOriginalAsync(
CancellationToken cancellationToken MediaSource source,
); string sourceToken,
} Guid assetId,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
Task DeleteAssetArtifactsAsync(
Guid assetId,
string extension,
CancellationToken cancellationToken
);
}
@@ -0,0 +1,32 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.ManualInbox;
/// <summary>
/// Забирает выбранные файлы из <c>manual/</c> в шоу. Файлы уходят из каталога, как и из обычного
/// <c>inbox/</c>. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить файлы
/// по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox.
/// </summary>
public sealed record ImportManualInboxCommand(IReadOnlyList<string> RelativePaths, Guid ShowId)
: ICommand<Result<ImportManualInboxResultDto>>;
/// <summary>Итог разбора: что взяли и что не смогли — по файлам, а не одной ошибкой на всё.</summary>
public sealed record ImportManualInboxResultDto(
int Imported,
IReadOnlyList<ImportFailureDto> Failed
);
public sealed record ImportFailureDto(string RelativePath, string Reason);
public sealed class ImportManualInboxCommandValidator
: AbstractValidator<ImportManualInboxCommand>
{
public ImportManualInboxCommandValidator()
{
RuleFor(x => x.RelativePaths).NotEmpty();
RuleFor(x => x.RelativePaths.Count).LessThanOrEqualTo(200);
RuleFor(x => x.ShowId).NotEmpty();
}
}
@@ -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<ImportManualInboxCommand, Result<ImportManualInboxResultDto>>
{
public async Task<Result<ImportManualInboxResultDto>> 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<ImportManualInboxResultDto>(ShowErrors.NotFound);
var available = storage
.ListManualInbox(int.MaxValue)
.ToDictionary(f => f.RelativePath, StringComparer.Ordinal);
var failed = new List<ImportFailureDto>();
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));
}
/// <summary>
/// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при обычной загрузке:
/// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы
/// не осталось записи без исходника.
/// </summary>
private async Task<Guid?> 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;
}
}
@@ -0,0 +1,27 @@
using LiteCqrs;
namespace TeleWave.Application.Media.ManualInbox;
/// <summary>
/// Что лежит в <c>manual/</c>. От обычного <c>inbox/</c> отличается только тем, кто разбирает:
/// там сканер по таймеру, здесь человек из UI — и сразу указывает шоу. Файл в обоих случаях
/// уходит из каталога.
/// </summary>
public sealed record ListManualInboxQuery : IQuery<ManualInboxListDto>;
public sealed record ManualInboxFileDto(
/// <summary>Путь относительно manual/ — он же идентификатор файла в команде импорта.</summary>
string RelativePath,
string Name,
long SizeBytes,
/// <summary>Поддерживается ли контейнер. Неподдерживаемые показываем, чтобы не гадать, куда делся файл.</summary>
bool IsSupported,
/// <summary>Файл с таким именем уже есть в библиотеке — импорт отклонит его как дубликат.</summary>
bool AlreadyImported
);
/// <summary>
/// <paramref name="Truncated"/> честно говорит, что выдача обрезана лимитом: показать часть каталога
/// молча значило бы «файла нет» там, где он есть.
/// </summary>
public sealed record ManualInboxListDto(IReadOnlyList<ManualInboxFileDto> Files, bool Truncated);
@@ -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<ListManualInboxQuery, ManualInboxListDto>
{
/// <summary>Потолок выдачи: каталог наполняет человек, и он может оказаться огромным.</summary>
private const int MaxFiles = 500;
public async Task<ManualInboxListDto> 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
);
}
}
@@ -1,15 +1,21 @@
namespace TeleWave.Domain.Media; namespace TeleWave.Domain.Media;
/// <summary>Откуда файл попал в хранилище.</summary> /// <summary>Откуда файл попал в хранилище.</summary>
public enum MediaSource public enum MediaSource
{ {
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary> /// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
Upload, Upload,
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary> /// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
Inbox, Inbox,
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком. /// <summary>
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary> /// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
Generated, /// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
} /// </summary>
ManualInbox = 3,
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
Generated,
}
@@ -1,96 +1,118 @@
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary> /// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
{ {
private const int CopyBufferSize = 1024 * 1024; private const int CopyBufferSize = 1024 * 1024;
public long GetAvailableFreeSpaceBytes() public long GetAvailableFreeSpaceBytes()
{ {
try try
{ {
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace; return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
} }
catch (Exception ex) when (ex is ArgumentException or IOException) catch (Exception ex) when (ex is ArgumentException or IOException)
{ {
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования). // Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
return long.MaxValue; return long.MaxValue;
} }
} }
public async Task<string> SaveUploadAsync( public async Task<string> SaveUploadAsync(
Stream content, Stream content,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
Directory.CreateDirectory(paths.UploadsDir); Directory.CreateDirectory(paths.UploadsDir);
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant(); var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
var path = paths.UploadPath(token); var path = paths.UploadPath(token);
await using var file = new FileStream( await using var file = new FileStream(
path, path,
FileMode.CreateNew, FileMode.CreateNew,
FileAccess.Write, FileAccess.Write,
FileShare.None, FileShare.None,
CopyBufferSize, CopyBufferSize,
useAsync: true useAsync: true
); );
await content.CopyToAsync(file, CopyBufferSize, cancellationToken); await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
return token; return token;
} }
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken) public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
{ {
var path = paths.UploadPath(uploadToken); var path = paths.UploadPath(uploadToken);
if (File.Exists(path)) if (File.Exists(path))
File.Delete(path); File.Delete(path);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task PromoteToOriginalAsync( public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
MediaSource source, {
string sourceToken, if (!Directory.Exists(paths.ManualDir))
Guid assetId, return [];
string extension,
CancellationToken cancellationToken return Directory
) .EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
{ .Take(Math.Max(1, max))
var sourcePath = .Select(path => new FileInfo(path))
source == MediaSource.Inbox .Where(file => file.Exists)
? paths.InboxPath(sourceToken) .Select(file => new IMediaStorage.ManualInboxFile(
: paths.UploadPath(sourceToken); // Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
if (!File.Exists(sourcePath)) file.Name,
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath); file.Length
))
Directory.CreateDirectory(paths.OriginalsDir); .OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
var destination = paths.OriginalPath(assetId, extension); .ToList();
File.Move(sourcePath, destination, overwrite: true); }
return Task.CompletedTask;
} public Task PromoteToOriginalAsync(
MediaSource source,
public Task DeleteAssetArtifactsAsync( string sourceToken,
Guid assetId, Guid assetId,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
) => )
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока {
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O. var sourcePath = source switch
Task.Run( {
() => MediaSource.Inbox => paths.InboxPath(sourceToken),
{ MediaSource.ManualInbox => paths.ManualPath(sourceToken),
var original = paths.OriginalPath(assetId, extension); _ => paths.UploadPath(sourceToken),
if (File.Exists(original)) };
File.Delete(original);
if (!File.Exists(sourcePath))
var assetDir = paths.AssetDir(assetId); throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
if (Directory.Exists(assetDir))
Directory.Delete(assetDir, recursive: true); Directory.CreateDirectory(paths.OriginalsDir);
}, var destination = paths.OriginalPath(assetId, extension);
cancellationToken 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
);
}
@@ -1,98 +1,111 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary> /// <summary>
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из /// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня. /// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
/// </summary> /// </summary>
public sealed class MediaPathResolver public sealed class MediaPathResolver
{ {
private readonly string _root; private readonly string _root;
public MediaPathResolver(IOptions<StorageOptions> options) public MediaPathResolver(IOptions<StorageOptions> options)
{ {
_root = Path.GetFullPath(options.Value.RootPath); _root = Path.GetFullPath(options.Value.RootPath);
InboxDir = Path.Combine(_root, "inbox"); InboxDir = Path.Combine(_root, "inbox");
UploadsDir = Path.Combine(_root, "uploads"); ManualDir = Path.Combine(_root, "manual");
OriginalsDir = Path.Combine(_root, "originals"); UploadsDir = Path.Combine(_root, "uploads");
AssetsDir = Path.Combine(_root, "assets"); OriginalsDir = Path.Combine(_root, "originals");
BumpersDir = Path.Combine(_root, "bumpers"); AssetsDir = Path.Combine(_root, "assets");
ImagesDir = Path.Combine(_root, "images"); BumpersDir = Path.Combine(_root, "bumpers");
} ImagesDir = Path.Combine(_root, "images");
}
public string InboxDir { get; }
public string UploadsDir { get; } public string InboxDir { get; }
public string OriginalsDir { get; }
public string AssetsDir { get; } /// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
public string ManualDir { get; }
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary> public string UploadsDir { get; }
public string BumpersDir { get; } public string OriginalsDir { get; }
public string AssetsDir { get; }
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
public string ImagesDir { get; } /// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
public string BumpersDir { get; }
public void EnsureDirectories()
{ /// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
Directory.CreateDirectory(InboxDir); public string ImagesDir { get; }
Directory.CreateDirectory(UploadsDir);
Directory.CreateDirectory(OriginalsDir); public void EnsureDirectories()
Directory.CreateDirectory(AssetsDir); {
Directory.CreateDirectory(BumpersDir); Directory.CreateDirectory(InboxDir);
Directory.CreateDirectory(ImagesDir); Directory.CreateDirectory(ManualDir);
} Directory.CreateDirectory(UploadsDir);
Directory.CreateDirectory(OriginalsDir);
public string BumperTemplateDir(Guid templateId) => Directory.CreateDirectory(AssetsDir);
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"))); Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(ImagesDir);
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary> }
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension)); public string BumperTemplateDir(Guid templateId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
public string ImagePath(Guid imageId, string extension) => /// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension)); public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
public string OriginalPath(Guid assetId, string extension) =>
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension)); /// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
public string ImagePath(Guid imageId, string extension) =>
public string AssetDir(Guid assetId) => EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
public string OriginalPath(Guid assetId, string extension) =>
public string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}"; EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary> public string AssetDir(Guid assetId) =>
public string SegmentPath(Guid assetId, string fileName) EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
{
var assetDir = AssetDir(assetId); public string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
} /// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
public string SegmentPath(Guid assetId, string fileName)
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary> {
public string UploadPath(string token) => var assetDir = AssetDir(assetId);
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token)); return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
}
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
public string InboxPath(string fileName) => /// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName)); public string UploadPath(string token) =>
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
private static string EnsureWithin(string baseDir, string candidate) public string InboxPath(string fileName) =>
{ EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
var full = Path.GetFullPath(candidate);
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar) /// <summary>
? baseDir /// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
: baseDir + Path.DirectorySeparatorChar; /// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
/// обязательна: строка приходит из запроса.
if ( /// </summary>
!full.StartsWith(normalizedBase, StringComparison.Ordinal) public string ManualPath(string relativePath) =>
&& !string.Equals(full, baseDir, StringComparison.Ordinal) EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
)
throw new UnauthorizedAccessException( private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
$"Путь '{candidate}' выходит за пределы каталога хранилища."
); private static string EnsureWithin(string baseDir, string candidate)
{
return full; 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;
}
}
@@ -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;
/// <summary>
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
/// </summary>
[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<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.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<IMediaProcessingQueue>()
).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<string>(),
Arg.Any<Guid>(),
".mkv",
Arg.Any<CancellationToken>()
);
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<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.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<IMediaProcessingQueue>()
).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<Guid> SeedShowAsync(AppDbContext db)
{
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
db.Shows.Add(show);
await db.SaveChangesAsync();
return show.Id;
}
}
+208 -200
View File
@@ -1,200 +1,208 @@
# Подготовка диска под медиахранилище (tvvm) # Подготовка диска под медиахранилище (tvvm)
Runbook: разметка, форматирование и монтирование диска `sdb` (700G) под хранилище TeleWave. Runbook: разметка, форматирование и монтирование диска `sdb` (700G) под хранилище TeleWave.
Выполняется **один раз** на сервере `tvvm` перед запуском фичи медиа. Все команды — от root Выполняется **один раз** на сервере `tvvm` перед запуском фичи медиа. Все команды — от root
(`sudo -i` или с `sudo` перед каждой). (`sudo -i` или с `sudo` перед каждой).
> ⚠️ Команды разметки **уничтожают данные** на целевом диске. Диск `sdb` сейчас пустой (нет > ⚠️ Команды разметки **уничтожают данные** на целевом диске. Диск `sdb` сейчас пустой (нет
> разделов), но каждый шаг ниже содержит проверку — не пропускайте их. > разделов), но каждый шаг ниже содержит проверку — не пропускайте их.
Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги, Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги,
запись из контейнера (сейчас работает под root) возможна. запись из контейнера (сейчас работает под root) возможна.
--- ---
## 0. Проверить, что это тот самый диск ## 0. Проверить, что это тот самый диск
```bash ```bash
lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,FSTYPE /dev/sdb lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,FSTYPE /dev/sdb
``` ```
Ожидается: `sdb` размером `700G`, тип `disk`, **без разделов и без точек монтирования**. Убедитесь Ожидается: `sdb` размером `700G`, тип `disk`, **без разделов и без точек монтирования**. Убедитесь
дополнительно, что на диске нет файловой системы и подписей: дополнительно, что на диске нет файловой системы и подписей:
```bash ```bash
sudo wipefs -n /dev/sdb # -n = «сухой прогон», ничего не меняет sudo wipefs -n /dev/sdb # -n = «сухой прогон», ничего не меняет
``` ```
Если вывод пустой — диск чист, продолжаем. Если что-то нашлось (следы старой ФС/RAID) — Если вывод пустой — диск чист, продолжаем. Если что-то нашлось (следы старой ФС/RAID) —
**остановитесь** и разберитесь, что это, прежде чем идти дальше. **остановитесь** и разберитесь, что это, прежде чем идти дальше.
--- ---
## 1. Создать таблицу разделов и один раздел ## 1. Создать таблицу разделов и один раздел
GPT + один раздел на весь диск: GPT + один раздел на весь диск:
```bash ```bash
sudo parted -s /dev/sdb mklabel gpt sudo parted -s /dev/sdb mklabel gpt
sudo parted -s -a optimal /dev/sdb mkpart primary ext4 0% 100% sudo parted -s -a optimal /dev/sdb mkpart primary ext4 0% 100%
sudo partprobe /dev/sdb sudo partprobe /dev/sdb
lsblk /dev/sdb lsblk /dev/sdb
``` ```
Должен появиться `sdb1` размером ~700G. Должен появиться `sdb1` размером ~700G.
--- ---
## 2. Отформатировать в ext4 ## 2. Отформатировать в ext4
`-m 1` уменьшает резерв под root с 5% до 1% (на диске данных резерв в 35 ГБ не нужен), `-L` даёт `-m 1` уменьшает резерв под root с 5% до 1% (на диске данных резерв в 35 ГБ не нужен), `-L` даёт
метку тома: метку тома:
```bash ```bash
sudo mkfs.ext4 -m 1 -L telewave-media /dev/sdb1 sudo mkfs.ext4 -m 1 -L telewave-media /dev/sdb1
``` ```
--- ---
## 3. Создать точку монтирования ## 3. Создать точку монтирования
```bash ```bash
sudo mkdir -p /srv/telewave/media sudo mkdir -p /srv/telewave/media
``` ```
--- ---
## 4. Прописать в /etc/fstab (монтирование по UUID) ## 4. Прописать в /etc/fstab (монтирование по UUID)
Монтируем по UUID, а не по имени `sdb1` — имя может измениться при добавлении дисков. Узнать UUID: Монтируем по UUID, а не по имени `sdb1` — имя может измениться при добавлении дисков. Узнать UUID:
```bash ```bash
sudo blkid /dev/sdb1 sudo blkid /dev/sdb1
``` ```
Скопируйте значение `UUID="..."` и добавьте строку в `/etc/fstab` (подставьте свой UUID): Скопируйте значение `UUID="..."` и добавьте строку в `/etc/fstab` (подставьте свой UUID):
``` ```
UUID=ВАШ-UUID /srv/telewave/media ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2 UUID=ВАШ-UUID /srv/telewave/media ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2
``` ```
Пояснения к опциям: Пояснения к опциям:
- `noatime` — не обновлять время доступа при чтении сегментов; заметно снижает лишние записи при - `noatime` — не обновлять время доступа при чтении сегментов; заметно снижает лишние записи при
раздаче видео. раздаче видео.
- `nofail` — если диск не подключился, система всё равно загрузится (приложение просто не сможет - `nofail` — если диск не подключился, система всё равно загрузится (приложение просто не сможет
писать, а не «висит» на загрузке). писать, а не «висит» на загрузке).
- `x-systemd.device-timeout=10` — не ждать диск дольше 10с при старте. - `x-systemd.device-timeout=10` — не ждать диск дольше 10с при старте.
Проверить, что fstab корректен, и смонтировать: Проверить, что fstab корректен, и смонтировать:
```bash ```bash
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo mount -a sudo mount -a
findmnt /srv/telewave/media findmnt /srv/telewave/media
``` ```
`findmnt` должен показать смонтированный `ext4` на `/dev/sdb1`. **Ошибка на этом шаге лучше, чем `findmnt` должен показать смонтированный `ext4` на `/dev/sdb1`. **Ошибка на этом шаге лучше, чем
на следующей перезагрузке** — если `mount -a` ругается, чините fstab сейчас. на следующей перезагрузке** — если `mount -a` ругается, чините fstab сейчас.
--- ---
## 5. Рабочие каталоги и права ## 5. Рабочие каталоги и права
Создать структуру, ожидаемую приложением: Создать структуру, ожидаемую приложением:
```bash ```bash
sudo mkdir -p /srv/telewave/media/{inbox,uploads,originals,assets} sudo mkdir -p /srv/telewave/media/{inbox,manual,uploads,originals,assets}
``` ```
**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount **Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount
пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root: пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root:
```bash ```bash
sudo chown -R root:root /srv/telewave/media sudo chown -R root:root /srv/telewave/media
sudo chmod -R 755 /srv/telewave/media sudo chmod -R 755 /srv/telewave/media
``` ```
Чтобы вы могли класть файлы в `inbox/` вручную (SFTP/rsync) под своим пользователем, откройте на Два каталога наполняете вы, а не приложение:
запись именно этот каталог вашей группе:
- `inbox/` — разбирается сканером автоматически: файл с допустимым расширением и стабильным
```bash размером регистрируется сам и уходит в обработку;
sudo chown root:$(id -gn) /srv/telewave/media/inbox - `manual/`**ручной разбор**: сканер сюда не заглядывает. Файлы видны в админке
sudo chmod 775 /srv/telewave/media/inbox (Медиа → «Из папки manual»), выбираются галочками и сразу привязываются к шоу. Импортированные
``` файлы уходят из каталога так же, как из `inbox/`.
> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile — Чтобы класть в них файлы вручную (SFTP/rsync) под своим пользователем, откройте на запись именно
> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid: эти каталоги вашей группе:
> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox`, оставленного вам).
```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
## 6. Проброс в контейнер (bind mount) ```
В `docker-compose.yml`, в сервис `app`, добавить том (этого пока **нет** в репозитории — появится > При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile —
вместе с реализацией фичи, здесь для справки): > в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid:
> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox` и `manual`, оставленных вам).
```yaml
services: ---
app:
# ... существующая конфигурация ... ## 6. Проброс в контейнер (bind mount)
volumes:
- /srv/telewave/media:/media В `docker-compose.yml`, в сервис `app`, добавить том (этого пока **нет** в репозитории — появится
mem_limit: 3g вместе с реализацией фичи, здесь для справки):
```
```yaml
И переменные окружения в `.env` (см. `docs/media-storage-and-streaming.md`): services:
app:
``` # ... существующая конфигурация ...
Storage__RootPath=/media volumes:
``` - /srv/telewave/media:/media
mem_limit: 3g
Выбран **bind mount**: диск смонтирован в ОС через fstab (шаги 1–5) и виден всегда, независимо от ```
Docker — это важно, потому что файлы в `inbox/` кладутся вручную по SFTP/rsync, в том числе когда
контейнер остановлен. И переменные окружения в `.env` (см. `docs/media-storage-and-streaming.md`):
> **Альтернатива — named volume, монтируемый самим Docker** (не используем, для справки). Позволяет ```
> пропустить шаги 3–5, но диск тогда доступен только при запущенном контейнере, а подкаталоги всё Storage__RootPath=/media
> равно надо создавать заранее. Пример: ```
>
> ```yaml Выбран **bind mount**: диск смонтирован в ОС через fstab (шаги 1–5) и виден всегда, независимо от
> services: Docker — это важно, потому что файлы в `inbox/` кладутся вручную по SFTP/rsync, в том числе когда
> app: контейнер остановлен.
> volumes:
> - media:/media > **Альтернатива — named volume, монтируемый самим Docker** (не используем, для справки). Позволяет
> mem_limit: 3g > пропустить шаги 3–5, но диск тогда доступен только при запущенном контейнере, а подкаталоги всё
> volumes: > равно надо создавать заранее. Пример:
> media: >
> driver: local > ```yaml
> driver_opts: > services:
> type: ext4 > app:
> device: /dev/disk/by-uuid/ВАШ-UUID > volumes:
> o: noatime > - media:/media
> ``` > mem_limit: 3g
> > volumes:
> Разметка и `mkfs.ext4` (шаги 1–2) обязательны в любом случае. > media:
> driver: local
--- > driver_opts:
> type: ext4
## 7. Проверка записи из контейнера > device: /dev/disk/by-uuid/ВАШ-UUID
> o: noatime
После добавления тома и пересборки образа убедиться, что контейнер пишет на диск: > ```
>
```bash > Разметка и `mkfs.ext4` (шаги 1–2) обязательны в любом случае.
docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest'
``` ---
Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее ## 7. Проверка записи из контейнера
(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение.
После добавления тома и пересборки образа убедиться, что контейнер пишет на диск:
---
```bash
## Приложение: если диск нужно расширить в будущем docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest'
```
Если VM отдаст диску больше места (например `sdb` вырастет с 700G), после увеличения на стороне
гипервизора: Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее
(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение.
```bash
sudo growpart /dev/sdb 1 # расширить раздел на весь диск ---
sudo resize2fs /dev/sdb1 # расширить ext4 (можно на смонтированном)
df -h /srv/telewave/media ## Приложение: если диск нужно расширить в будущем
```
Если VM отдаст диску больше места (например `sdb` вырастет с 700G), после увеличения на стороне
гипервизора:
```bash
sudo growpart /dev/sdb 1 # расширить раздел на весь диск
sudo resize2fs /dev/sdb1 # расширить ext4 (можно на смонтированном)
df -h /srv/telewave/media
```
@@ -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<string[]>([])
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Input
className="max-w-xs"
placeholder={t('common.search')}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Button
size="sm"
variant="outline"
disabled={importable.length === 0}
onClick={() =>
setSelected(
selected.length === importable.length
? []
: importable.map((f) => f.relativePath),
)
}
>
{t('admin.media.manualSelectAll')}
</Button>
<span className="text-xs text-muted-foreground">
{t('admin.media.manualSelected', { count: selected.length })}
</span>
</div>
<ul className="crt-panel max-h-80 divide-y divide-border overflow-y-auto rounded-md text-sm">
{isLoading && (
<li className="px-3 py-2 text-muted-foreground">{t('common.loading')}</li>
)}
{!isLoading && files.length === 0 && (
<li className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</li>
)}
{files.map((file) => {
const blocked = !file.isSupported || file.alreadyImported
return (
<li key={file.relativePath} className="flex items-center gap-2 px-3 py-1.5">
<input
type="checkbox"
className="shrink-0"
disabled={blocked}
checked={selected.includes(file.relativePath)}
onChange={() => toggle(file.relativePath)}
/>
<span
className={`min-w-0 flex-1 truncate ${blocked ? 'text-muted-foreground' : ''}`}
title={file.relativePath}
>
{file.relativePath}
</span>
{!file.isSupported && (
<Badge variant="muted">{t('admin.media.manualUnsupported')}</Badge>
)}
{file.alreadyImported && (
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
)}
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatSize(file.sizeBytes)}
</span>
</li>
)
})}
</ul>
{data?.truncated && (
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger className="w-72">
<SelectValue placeholder={t('admin.media.manualPickShow')} />
</SelectTrigger>
<SelectContent>
{(shows ?? []).map((show) => (
<SelectItem key={show.id} value={show.id}>
{show.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{t('admin.media.manualOrderHint')}
</span>
</div>
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
size="sm"
disabled={selected.length === 0 || !showId || importMutation.isPending}
onClick={() => importMutation.mutate()}
>
{t('admin.media.manualImport')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+282 -274
View File
@@ -1,274 +1,282 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ListPlus, Upload } from 'lucide-react' import { FolderInput, ListPlus, Upload } from 'lucide-react'
import { HttpError } from '@/shared/api/client' 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 { Pager } from '@/shared/ui/pager' import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, useTableSort } from '@/shared/ui/sortable' import { SortHeader, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, getMediaStats, listMedia } from './api' import { deleteMedia, getMediaStats, listMedia } from './api'
import { UploadToShowDialog } from './UploadToShowDialog' import { ManualInboxDialog } from './ManualInboxDialog'
import { useUploadStore } from './upload-store' import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store'
const PAGE_SIZE = 20
const PAGE_SIZE = 20
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
active: ['Pending', 'Processing'], const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
Pending: ['Pending'], active: ['Pending', 'Processing'],
Processing: ['Processing'], Pending: ['Pending'],
all: [], Processing: ['Processing'],
Ready: ['Ready'], all: [],
Failed: ['Failed'], Ready: ['Ready'],
} Failed: ['Failed'],
}
export function formatDuration(seconds: number | null): string {
if (seconds == null) return '—' export function formatDuration(seconds: number | null): string {
const total = Math.round(seconds) if (seconds == null) return '—'
const h = Math.floor(total / 3600) const total = Math.round(seconds)
const m = Math.floor((total % 3600) / 60) const h = Math.floor(total / 3600)
const s = total % 60 const m = Math.floor((total % 3600) / 60)
const pad = (n: number) => String(n).padStart(2, '0') const s = total % 60
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}` const pad = (n: number) => String(n).padStart(2, '0')
} return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
}
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
Ready: 'default', const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
Processing: 'muted', Ready: 'default',
Pending: 'muted', Processing: 'muted',
Failed: 'destructive', Pending: 'muted',
} Failed: 'destructive',
}
export function MediaPanel() {
const { t } = useTranslation() export function MediaPanel() {
const queryClient = useQueryClient() const { t } = useTranslation()
const fileInput = useRef<HTMLInputElement>(null) const queryClient = useQueryClient()
const fileInputShow = useRef<HTMLInputElement>(null) const fileInput = useRef<HTMLInputElement>(null)
const [filter, setFilter] = useState<MediaFilter>('active') const fileInputShow = useRef<HTMLInputElement>(null)
const [page, setPage] = useState(1) const [filter, setFilter] = useState<MediaFilter>('active')
const { sort, toggle } = useTableSort('created', true) const [page, setPage] = useState(1)
const [filesForShow, setFilesForShow] = useState<File[] | null>(null) const { sort, toggle } = useTableSort('created', true)
const enqueue = useUploadStore((s) => s.enqueue) const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
const [manualOpen, setManualOpen] = useState(false)
const sortColumn = (key: string) => { const enqueue = useUploadStore((s) => s.enqueue)
setPage(1)
toggle(key) const sortColumn = (key: string) => {
} setPage(1)
toggle(key)
const { data, isLoading } = useQuery({ }
queryKey: ['admin', 'media', filter, page, sort.key, sort.desc],
queryFn: () => const { data, isLoading } = useQuery({
listMedia({ queryKey: ['admin', 'media', filter, page, sort.key, sort.desc],
page, queryFn: () =>
pageSize: PAGE_SIZE, listMedia({
statuses: filterStatuses[filter], page,
sort: sort.key, pageSize: PAGE_SIZE,
desc: sort.desc, statuses: filterStatuses[filter],
}), sort: sort.key,
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI. desc: sort.desc,
refetchInterval: (query) => }),
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending') // Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
? 4000 refetchInterval: (query) =>
: false, query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
}) ? 4000
: false,
const { data: stats } = useQuery({ })
queryKey: ['admin', 'media', 'stats'],
queryFn: getMediaStats, const { data: stats } = useQuery({
// Пока есть незавершённая работа — освежаем чипы очереди/обработки. queryKey: ['admin', 'media', 'stats'],
refetchInterval: (query) => queryFn: getMediaStats,
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000, // Пока есть незавершённая работа — освежаем чипы очереди/обработки.
}) 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 invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
const onError = (error: unknown) =>
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError }) toast.error(error instanceof HttpError ? error.detail : t('common.error'))
return ( const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2"> return (
<div className="flex items-center gap-3"> <div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2> <div className="flex flex-wrap items-center justify-between gap-2">
<Select <div className="flex items-center gap-3">
value={filter} <h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
onValueChange={(v) => { <Select
setPage(1) value={filter}
setFilter(v as MediaFilter) onValueChange={(v) => {
}} setPage(1)
> setFilter(v as MediaFilter)
<SelectTrigger className="w-40"> }}
<SelectValue /> >
</SelectTrigger> <SelectTrigger className="w-40">
<SelectContent> <SelectValue />
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem> </SelectTrigger>
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem> <SelectContent>
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem> <SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem> <SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem> <SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem> <SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
</SelectContent> <SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
</Select> <SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
</SelectContent>
{stats && ( </Select>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title={t('admin.media.stats.queued')}> {stats && (
{t('admin.media.stats.queuedShort')}:{' '} <div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="text-foreground tabular-nums">{stats.queued}</span> <span title={t('admin.media.stats.queued')}>
</span> {t('admin.media.stats.queuedShort')}:{' '}
<span title={t('admin.media.stats.processing')}> <span className="text-foreground tabular-nums">{stats.queued}</span>
{t('admin.media.stats.processingShort')}:{' '} </span>
<span className="text-foreground tabular-nums">{stats.processing}</span> <span title={t('admin.media.stats.processing')}>
</span> {t('admin.media.stats.processingShort')}:{' '}
<span title={t('admin.media.stats.average')}> <span className="text-foreground tabular-nums">{stats.processing}</span>
{t('admin.media.stats.averageShort')}:{' '} </span>
<span className="text-foreground tabular-nums"> <span title={t('admin.media.stats.average')}>
{formatDuration(stats.averageProcessingSeconds)} {t('admin.media.stats.averageShort')}:{' '}
</span> <span className="text-foreground tabular-nums">
</span> {formatDuration(stats.averageProcessingSeconds)}
</div> </span>
)} </span>
</div> </div>
<div className="flex items-center gap-3"> )}
<input </div>
ref={fileInput} <div className="flex items-center gap-3">
type="file" <input
accept="video/*,.mkv,.avi,.ts" ref={fileInput}
multiple type="file"
className="hidden" accept="video/*,.mkv,.avi,.ts"
onChange={(e) => { multiple
const files = e.target.files className="hidden"
if (files && files.length > 0) void enqueue(Array.from(files)) onChange={(e) => {
e.target.value = '' const files = e.target.files
}} if (files && files.length > 0) void enqueue(Array.from(files))
/> e.target.value = ''
<input }}
ref={fileInputShow} />
type="file" <input
accept="video/*,.mkv,.avi,.ts" ref={fileInputShow}
multiple type="file"
className="hidden" accept="video/*,.mkv,.avi,.ts"
onChange={(e) => { multiple
const files = e.target.files className="hidden"
if (files && files.length > 0) setFilesForShow(Array.from(files)) onChange={(e) => {
e.target.value = '' const files = e.target.files
}} if (files && files.length > 0) setFilesForShow(Array.from(files))
/> e.target.value = ''
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}> }}
<ListPlus className="h-4 w-4" /> />
{t('admin.media.uploadToShow')} <Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
</Button> <FolderInput className="h-4 w-4" />
<Button size="sm" onClick={() => fileInput.current?.click()}> {t('admin.media.manualButton')}
<Upload className="h-4 w-4" /> </Button>
{t('admin.media.upload')} <Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
</Button> <ListPlus className="h-4 w-4" />
</div> {t('admin.media.uploadToShow')}
</div> </Button>
<Button size="sm" onClick={() => fileInput.current?.click()}>
{filesForShow && ( <Upload className="h-4 w-4" />
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} /> {t('admin.media.upload')}
)} </Button>
</div>
<div className="crt-panel overflow-x-auto rounded-md"> </div>
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground"> {filesForShow && (
<tr> <UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
<SortHeader )}
label={t('admin.media.name')}
sortKey="name" {manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
sort={sort}
onToggle={sortColumn} <div className="crt-panel overflow-x-auto rounded-md">
/> <table className="w-full text-sm">
<SortHeader <thead className="border-b border-border text-left text-muted-foreground">
label={t('admin.media.status')} <tr>
sortKey="status" <SortHeader
sort={sort} label={t('admin.media.name')}
onToggle={sortColumn} sortKey="name"
/> sort={sort}
<SortHeader onToggle={sortColumn}
label={t('admin.media.duration')} />
sortKey="duration" <SortHeader
sort={sort} label={t('admin.media.status')}
onToggle={sortColumn} sortKey="status"
/> sort={sort}
<SortHeader onToggle={sortColumn}
label={t('admin.media.resolution')} />
sortKey="resolution" <SortHeader
sort={sort} label={t('admin.media.duration')}
onToggle={sortColumn} sortKey="duration"
/> sort={sort}
<SortHeader onToggle={sortColumn}
label={t('admin.media.processingTime')} />
sortKey="processing" <SortHeader
sort={sort} label={t('admin.media.resolution')}
onToggle={sortColumn} sortKey="resolution"
/> sort={sort}
<th className="px-4 py-2 font-medium">{t('common.actions')}</th> onToggle={sortColumn}
</tr> />
</thead> <SortHeader
<tbody> label={t('admin.media.processingTime')}
{isLoading && ( sortKey="processing"
<tr> sort={sort}
<td className="px-4 py-3 text-muted-foreground" colSpan={6}> onToggle={sortColumn}
{t('common.loading')} />
</td> <th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr> </tr>
)} </thead>
{data?.items.map((asset) => ( <tbody>
<MediaRow {isLoading && (
key={asset.id} <tr>
asset={asset} <td className="px-4 py-3 text-muted-foreground" colSpan={6}>
onDelete={() => deleteMutation.mutate(asset.id)} {t('common.loading')}
/> </td>
))} </tr>
{data && data.items.length === 0 && !isLoading && ( )}
<tr> {data?.items.map((asset) => (
<td className="px-4 py-3 text-muted-foreground" colSpan={6}> <MediaRow
{t('admin.media.empty')} key={asset.id}
</td> asset={asset}
</tr> onDelete={() => deleteMutation.mutate(asset.id)}
)} />
</tbody> ))}
</table> {data && data.items.length === 0 && !isLoading && (
</div> <tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
<Pager {t('admin.media.empty')}
page={page} </td>
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1} </tr>
onChange={setPage} )}
/> </tbody>
</div> </table>
) </div>
}
<Pager
function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => void }) { page={page}
const { t } = useTranslation() totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
return ( onChange={setPage}
<tr className="border-b border-border last:border-0"> />
<td className="px-4 py-2">{asset.originalFileName}</td> </div>
<td className="px-4 py-2"> )
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}> }
{t(`admin.media.statuses.${asset.status}`)}
</Badge> function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => void }) {
</td> const { t } = useTranslation()
<td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td> return (
<td className="px-4 py-2 text-muted-foreground"> <tr className="border-b border-border last:border-0">
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'} <td className="px-4 py-2">{asset.originalFileName}</td>
</td> <td className="px-4 py-2">
<td className="px-4 py-2 text-muted-foreground tabular-nums"> <Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'} {t(`admin.media.statuses.${asset.status}`)}
</td> </Badge>
<td className="px-4 py-2"> </td>
<Button size="sm" variant="destructive" onClick={onDelete}> <td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td>
{t('common.delete')} <td className="px-4 py-2 text-muted-foreground">
</Button> {asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
</td> </td>
</tr> <td className="px-4 py-2 text-muted-foreground tabular-nums">
) {asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
} </td>
<td className="px-4 py-2">
<Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')}
</Button>
</td>
</tr>
)
}
+126 -111
View File
@@ -1,111 +1,126 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type { import type {
CreatedIdResponse, CreatedIdResponse,
MediaAssetDto, ImportManualInboxResultDto,
MediaAssetStatus, ManualInboxListDto,
MediaStatsDto, MediaAssetDto,
PagedList, MediaAssetStatus,
} from '@/shared/api/types' MediaStatsDto,
PagedList,
export type ListMediaParams = { } from '@/shared/api/types'
page: number
pageSize: number export type ListMediaParams = {
statuses?: MediaAssetStatus[] page: number
search?: string pageSize: number
sort?: string statuses?: MediaAssetStatus[]
desc?: boolean search?: string
} sort?: string
desc?: boolean
export function listMedia(params: ListMediaParams) { }
const query = new URLSearchParams({
page: String(params.page), export function listMedia(params: ListMediaParams) {
pageSize: String(params.pageSize), const query = new URLSearchParams({
}) page: String(params.page),
for (const status of params.statuses ?? []) query.append('status', status) pageSize: String(params.pageSize),
if (params.search) query.set('search', params.search) })
if (params.sort) query.set('sort', params.sort) for (const status of params.statuses ?? []) query.append('status', status)
if (params.desc) query.set('desc', 'true') if (params.search) query.set('search', params.search)
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`) if (params.sort) query.set('sort', params.sort)
} if (params.desc) query.set('desc', 'true')
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
export function getMediaStats() { }
return apiRequest<MediaStatsDto>('/admin/media/stats')
} export function getMediaStats() {
return apiRequest<MediaStatsDto>('/admin/media/stats')
/** }
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`: /**
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид, * Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
* что список полон). * пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
*/ * если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
export async function listAllMedia( * что список полон).
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number }, */
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> { export async function listAllMedia(
const pageSize = 200 params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
const cap = params.cap ?? 5000 ): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
const items: MediaAssetDto[] = [] const pageSize = 200
let total = 0 const cap = params.cap ?? 5000
for (let page = 1; ; page++) { const items: MediaAssetDto[] = []
const res = await listMedia({ let total = 0
page, for (let page = 1; ; page++) {
pageSize, const res = await listMedia({
statuses: params.statuses, page,
search: params.search, pageSize,
}) statuses: params.statuses,
total = res.total search: params.search,
items.push(...res.items) })
if (res.items.length === 0 || items.length >= total || items.length >= cap) break total = res.total
} items.push(...res.items)
return { items, total, truncated: items.length < total } 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<void>(`/admin/media/${id}`, { method: 'DELETE' })
} /** Что лежит в ручном inbox (manual/) и ждёт разбора. */
export function listManualInbox() {
/** return apiRequest<ManualInboxListDto>('/admin/media/manual')
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса. }
*/
export function uploadMedia( /** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */
file: File, export function importManualInbox(relativePaths: string[], showId: string) {
onProgress?: (percent: number) => void, return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
signal?: AbortSignal, method: 'POST',
): Promise<CreatedIdResponse> { body: { relativePaths, showId },
return new Promise((resolve, reject) => { })
if (signal?.aborted) { }
reject(new DOMException('Aborted', 'AbortError'))
return export function deleteMedia(id: string) {
} return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
}
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name }) /**
xhr.open('POST', `/api/admin/media?${query.toString()}`) * Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
*/
const token = getAccessToken() export function uploadMedia(
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) file: File,
onProgress?: (percent: number) => void,
signal?.addEventListener('abort', () => xhr.abort()) signal?: AbortSignal,
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError')) ): Promise<CreatedIdResponse> {
return new Promise((resolve, reject) => {
xhr.upload.onprogress = (event) => { if (signal?.aborted) {
if (event.lengthComputable && onProgress) reject(new DOMException('Aborted', 'AbortError'))
onProgress(Math.round((event.loaded / event.total) * 100)) return
} }
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { const xhr = new XMLHttpRequest()
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse) const query = new URLSearchParams({ fileName: file.name })
} else { xhr.open('POST', `/api/admin/media?${query.toString()}`)
let detail = `HTTP ${xhr.status}`
try { const token = getAccessToken()
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string } if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
detail = problem.detail ?? problem.title ?? detail
} catch { signal?.addEventListener('abort', () => xhr.abort())
/* пусто */ xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
}
reject(new HttpError({ detail }, xhr.status)) xhr.upload.onprogress = (event) => {
} if (event.lengthComputable && onProgress)
} onProgress(Math.round((event.loaded / event.total) * 100))
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0)) }
xhr.send(file) 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)
})
}
+21 -1
View File
@@ -62,7 +62,27 @@ export type CreatedIdResponse = { id: string }
// ── Медиа ──────────────────────────────────────────────────────────────── // ── Медиа ────────────────────────────────────────────────────────────────
export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed' 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 = { export type MediaAssetDto = {
id: string id: string
+28
View File
@@ -201,6 +201,20 @@ const resources = {
media: { media: {
title: 'Медиа', title: 'Медиа',
upload: 'Загрузить', upload: 'Загрузить',
manualButton: 'Из папки manual',
manualTitle: 'Ручной разбор папки manual',
manualHint:
'Файлы из manual/ не разбираются сканером — выберите нужные и укажите шоу. Импортированные файлы уходят из папки, как и из inbox.',
manualSelectAll: 'Выбрать все',
manualSelected: 'Выбрано: {{count}}',
manualEmpty: 'В папке manual пусто',
manualUnsupported: 'формат не поддерживается',
manualAlready: 'уже в библиотеке',
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
manualPickShow: 'Выберите шоу',
manualOrderHint: 'Номера сезона и серии определяются по именам файлов.',
manualImport: 'Забрать в шоу',
manualImported: 'Импортировано файлов: {{count}}',
uploadToShow: 'Загрузить в шоу', uploadToShow: 'Загрузить в шоу',
toShowTitle: 'Загрузить и добавить в шоу', toShowTitle: 'Загрузить и добавить в шоу',
autoDetectHint: autoDetectHint:
@@ -863,6 +877,20 @@ const resources = {
media: { media: {
title: 'Media', title: 'Media',
upload: 'Upload', 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', uploadToShow: 'Upload to show',
toShowTitle: 'Upload and add to show', toShowTitle: 'Upload and add to show',
autoDetectHint: autoDetectHint: