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,45 +1,56 @@
using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
/// </summary>
public interface IMediaStorage
{
/// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes();
/// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
/// </summary>
Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary>
/// Переносит исходник из источника (<c>uploads/</c> для <see cref="MediaSource.Upload"/> либо
/// <c>inbox/</c> для <see cref="MediaSource.Inbox"/>) в <c>originals/{assetId}{ext}</c>.
/// </summary>
Task PromoteToOriginalAsync(
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
);
}
using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
/// </summary>
public interface IMediaStorage
{
/// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes();
/// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
/// </summary>
Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary>Файл ручного inbox.</summary>
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
/// <summary>
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
/// каталог наполняет человек, и он может оказаться большим.
/// </summary>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
/// переносит — файл из каталога-источника уходит.
/// </summary>
Task PromoteToOriginalAsync(
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
);
}
}