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.
This commit is contained in:
@@ -8,6 +8,7 @@ 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.ManualInbox;
|
||||||
using TeleWave.Application.Media.Register;
|
using TeleWave.Application.Media.Register;
|
||||||
using TeleWave.Application.Media.Stats;
|
using TeleWave.Application.Media.Stats;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
@@ -31,6 +32,10 @@ public static class MediaEndpoints
|
|||||||
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
|
||||||
|
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
|
||||||
|
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
||||||
|
|
||||||
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
||||||
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
||||||
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
||||||
@@ -145,6 +150,25 @@ public static class MediaEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListManual(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ImportManual(
|
||||||
|
ImportManualInboxBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new ImportManualInboxCommand(body.RelativePaths, body.ShowId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
||||||
{
|
{
|
||||||
@@ -197,3 +221,5 @@ public static class MediaEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UploadMediaResponse(Guid Id);
|
public sealed record UploadMediaResponse(Guid Id);
|
||||||
|
|
||||||
|
public sealed record ImportManualInboxBody(IReadOnlyList<string> RelativePaths, Guid ShowId);
|
||||||
|
|||||||
@@ -24,9 +24,20 @@ public interface IMediaStorage
|
|||||||
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
|
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
|
||||||
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Переносит исходник из источника (<c>uploads/</c> для <see cref="MediaSource.Upload"/> либо
|
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
|
||||||
/// <c>inbox/</c> для <see cref="MediaSource.Inbox"/>) в <c>originals/{assetId}{ext}</c>.
|
/// каталог наполняет человек, и он может оказаться большим.
|
||||||
|
/// </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>
|
/// </summary>
|
||||||
Task PromoteToOriginalAsync(
|
Task PromoteToOriginalAsync(
|
||||||
MediaSource source,
|
MediaSource source,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ public enum MediaSource
|
|||||||
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
|
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
|
||||||
Inbox,
|
Inbox,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
|
||||||
|
/// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
|
||||||
|
/// </summary>
|
||||||
|
ManualInbox = 3,
|
||||||
|
|
||||||
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
|
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
|
||||||
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
|
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
|
||||||
Generated,
|
Generated,
|
||||||
|
|||||||
@@ -51,6 +51,26 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(paths.ManualDir))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return Directory
|
||||||
|
.EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
|
||||||
|
.Take(Math.Max(1, max))
|
||||||
|
.Select(path => new FileInfo(path))
|
||||||
|
.Where(file => file.Exists)
|
||||||
|
.Select(file => new IMediaStorage.ManualInboxFile(
|
||||||
|
// Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
|
||||||
|
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
|
||||||
|
file.Name,
|
||||||
|
file.Length
|
||||||
|
))
|
||||||
|
.OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public Task PromoteToOriginalAsync(
|
public Task PromoteToOriginalAsync(
|
||||||
MediaSource source,
|
MediaSource source,
|
||||||
string sourceToken,
|
string sourceToken,
|
||||||
@@ -59,10 +79,12 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var sourcePath =
|
var sourcePath = source switch
|
||||||
source == MediaSource.Inbox
|
{
|
||||||
? paths.InboxPath(sourceToken)
|
MediaSource.Inbox => paths.InboxPath(sourceToken),
|
||||||
: paths.UploadPath(sourceToken);
|
MediaSource.ManualInbox => paths.ManualPath(sourceToken),
|
||||||
|
_ => paths.UploadPath(sourceToken),
|
||||||
|
};
|
||||||
|
|
||||||
if (!File.Exists(sourcePath))
|
if (!File.Exists(sourcePath))
|
||||||
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
|
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed class MediaPathResolver
|
|||||||
{
|
{
|
||||||
_root = Path.GetFullPath(options.Value.RootPath);
|
_root = Path.GetFullPath(options.Value.RootPath);
|
||||||
InboxDir = Path.Combine(_root, "inbox");
|
InboxDir = Path.Combine(_root, "inbox");
|
||||||
|
ManualDir = Path.Combine(_root, "manual");
|
||||||
UploadsDir = Path.Combine(_root, "uploads");
|
UploadsDir = Path.Combine(_root, "uploads");
|
||||||
OriginalsDir = Path.Combine(_root, "originals");
|
OriginalsDir = Path.Combine(_root, "originals");
|
||||||
AssetsDir = Path.Combine(_root, "assets");
|
AssetsDir = Path.Combine(_root, "assets");
|
||||||
@@ -22,6 +23,9 @@ public sealed class MediaPathResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
public string InboxDir { get; }
|
public string InboxDir { get; }
|
||||||
|
|
||||||
|
/// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
|
||||||
|
public string ManualDir { get; }
|
||||||
public string UploadsDir { get; }
|
public string UploadsDir { get; }
|
||||||
public string OriginalsDir { get; }
|
public string OriginalsDir { get; }
|
||||||
public string AssetsDir { get; }
|
public string AssetsDir { get; }
|
||||||
@@ -35,6 +39,7 @@ public sealed class MediaPathResolver
|
|||||||
public void EnsureDirectories()
|
public void EnsureDirectories()
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(InboxDir);
|
Directory.CreateDirectory(InboxDir);
|
||||||
|
Directory.CreateDirectory(ManualDir);
|
||||||
Directory.CreateDirectory(UploadsDir);
|
Directory.CreateDirectory(UploadsDir);
|
||||||
Directory.CreateDirectory(OriginalsDir);
|
Directory.CreateDirectory(OriginalsDir);
|
||||||
Directory.CreateDirectory(AssetsDir);
|
Directory.CreateDirectory(AssetsDir);
|
||||||
@@ -76,6 +81,14 @@ public sealed class MediaPathResolver
|
|||||||
public string InboxPath(string fileName) =>
|
public string InboxPath(string fileName) =>
|
||||||
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
|
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
|
||||||
|
/// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
|
||||||
|
/// обязательна: строка приходит из запроса.
|
||||||
|
/// </summary>
|
||||||
|
public string ManualPath(string relativePath) =>
|
||||||
|
EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
|
||||||
|
|
||||||
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
|
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
|
||||||
|
|
||||||
private static string EnsureWithin(string baseDir, string candidate)
|
private static string EnsureWithin(string baseDir, string candidate)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ findmnt /srv/telewave/media
|
|||||||
Создать структуру, ожидаемую приложением:
|
Создать структуру, ожидаемую приложением:
|
||||||
|
|
||||||
```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
|
||||||
@@ -114,17 +114,25 @@ 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/` — разбирается сканером автоматически: файл с допустимым расширением и стабильным
|
||||||
|
размером регистрируется сам и уходит в обработку;
|
||||||
|
- `manual/` — **ручной разбор**: сканер сюда не заглядывает. Файлы видны в админке
|
||||||
|
(Медиа → «Из папки manual»), выбираются галочками и сразу привязываются к шоу. Импортированные
|
||||||
|
файлы уходят из каталога так же, как из `inbox/`.
|
||||||
|
|
||||||
|
Чтобы класть в них файлы вручную (SFTP/rsync) под своим пользователем, откройте на запись именно
|
||||||
|
эти каталоги вашей группе:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo chown root:$(id -gn) /srv/telewave/media/inbox
|
sudo chown root:$(id -gn) /srv/telewave/media/inbox /srv/telewave/media/manual
|
||||||
sudo chmod 775 /srv/telewave/media/inbox
|
sudo chmod 775 /srv/telewave/media/inbox /srv/telewave/media/manual
|
||||||
```
|
```
|
||||||
|
|
||||||
> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile —
|
> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile —
|
||||||
> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid:
|
> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid:
|
||||||
> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox`, оставленного вам).
|
> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox` и `manual`, оставленных вам).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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'
|
||||||
@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
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 { ManualInboxDialog } from './ManualInboxDialog'
|
||||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function MediaPanel() {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const { sort, toggle } = useTableSort('created', true)
|
const { sort, toggle } = useTableSort('created', true)
|
||||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||||
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
const sortColumn = (key: string) => {
|
const sortColumn = (key: string) => {
|
||||||
@@ -160,6 +162,10 @@ export function MediaPanel() {
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
|
||||||
|
<FolderInput className="h-4 w-4" />
|
||||||
|
{t('admin.media.manualButton')}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
||||||
<ListPlus className="h-4 w-4" />
|
<ListPlus className="h-4 w-4" />
|
||||||
{t('admin.media.uploadToShow')}
|
{t('admin.media.uploadToShow')}
|
||||||
@@ -175,6 +181,8 @@ export function MediaPanel() {
|
|||||||
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
|
||||||
|
|
||||||
<div className="crt-panel overflow-x-auto rounded-md">
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||||
import type {
|
import type {
|
||||||
CreatedIdResponse,
|
CreatedIdResponse,
|
||||||
|
ImportManualInboxResultDto,
|
||||||
|
ManualInboxListDto,
|
||||||
MediaAssetDto,
|
MediaAssetDto,
|
||||||
MediaAssetStatus,
|
MediaAssetStatus,
|
||||||
MediaStatsDto,
|
MediaStatsDto,
|
||||||
@@ -59,6 +61,19 @@ export async function listAllMedia(
|
|||||||
return { items, total, truncated: items.length < total }
|
return { items, total, truncated: items.length < total }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Что лежит в ручном inbox (manual/) и ждёт разбора. */
|
||||||
|
export function listManualInbox() {
|
||||||
|
return apiRequest<ManualInboxListDto>('/admin/media/manual')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */
|
||||||
|
export function importManualInbox(relativePaths: string[], showId: string) {
|
||||||
|
return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { relativePaths, showId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteMedia(id: string) {
|
export function deleteMedia(id: string) {
|
||||||
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user