Refactor manual inbox handling: update ImportManualInboxCommand to accept ManualImportItem objects, enhancing file import functionality with explicit season and episode numbers. Implement CleanupManualLeftoversAsync in IMediaStorage to remove unnecessary files after import. Update frontend components to support new import structure and improve user experience in manual media management.
build / backend (push) Successful in 1m11s
build / frontend (push) Successful in 34s
tests / backend-tests (push) Successful in 1m38s

This commit is contained in:
Leonid Pershin
2026-07-26 15:41:31 +03:00
parent 74bca20a3c
commit a95c0540d9
13 changed files with 585 additions and 90 deletions
@@ -163,7 +163,7 @@ public static class MediaEndpoints
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ImportManualInboxCommand(body.RelativePaths, body.ShowId), new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
@@ -222,4 +222,4 @@ 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); public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
@@ -34,6 +34,13 @@ public interface IMediaStorage
/// </summary> /// </summary>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max); IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
/// не трогает — рядом может лежать следующая серия.
/// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется /// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно /// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
@@ -5,11 +5,19 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.ManualInbox; namespace TeleWave.Application.Media.ManualInbox;
/// <summary> /// <summary>
/// Забирает выбранные файлы из <c>manual/</c> в шоу. Файлы уходят из каталога, как и из обычного /// Один файл к разбору. <paramref name="Season"/> и <paramref name="Episode"/> приходят с клиента:
/// <c>inbox/</c>. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить файлы /// там пользователь видит распознанные номера и может поправить их регуляркой или ручным сезоном,
/// по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox. /// и сохраниться должно ровно то, что он видел. Не заданы — сервер разберёт имя сам.
/// </summary> /// </summary>
public sealed record ImportManualInboxCommand(IReadOnlyList<string> RelativePaths, Guid ShowId) public sealed record ManualImportItem(string RelativePath, int? Season, int? Episode);
/// <summary>
/// Забирает выбранные файлы из <c>manual/</c> в шоу. Файлы уходят из каталога, как и из обычного
/// <c>inbox/</c>, а рядом лежавшие спутники (субтитры, nfo, обложки) удаляются, чтобы каталог
/// не зарастал мусором. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить
/// файлы по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox.
/// </summary>
public sealed record ImportManualInboxCommand(IReadOnlyList<ManualImportItem> Items, Guid ShowId)
: ICommand<Result<ImportManualInboxResultDto>>; : ICommand<Result<ImportManualInboxResultDto>>;
/// <summary>Итог разбора: что взяли и что не смогли — по файлам, а не одной ошибкой на всё.</summary> /// <summary>Итог разбора: что взяли и что не смогли — по файлам, а не одной ошибкой на всё.</summary>
@@ -25,8 +33,15 @@ public sealed class ImportManualInboxCommandValidator
{ {
public ImportManualInboxCommandValidator() public ImportManualInboxCommandValidator()
{ {
RuleFor(x => x.RelativePaths).NotEmpty(); RuleFor(x => x.Items).NotEmpty();
RuleFor(x => x.RelativePaths.Count).LessThanOrEqualTo(200); RuleFor(x => x.Items.Count).LessThanOrEqualTo(500);
RuleFor(x => x.ShowId).NotEmpty(); RuleFor(x => x.ShowId).NotEmpty();
RuleForEach(x => x.Items)
.Must(i => i.Season is null or (> 0 and <= 99))
.WithMessage("Сезон вне допустимого диапазона.");
RuleForEach(x => x.Items)
.Must(i => i.Episode is null or (> 0 and <= 999))
.WithMessage("Номер серии вне допустимого диапазона.");
} }
} }
@@ -33,18 +33,20 @@ public sealed class ImportManualInboxCommandHandler(
// Файлы разбираются по одному и независимо: один неподдерживаемый контейнер не должен // Файлы разбираются по одному и независимо: один неподдерживаемый контейнер не должен
// отменять весь пакет — в ручном разборе это как раз частый случай. // отменять весь пакет — в ручном разборе это как раз частый случай.
foreach (var relativePath in command.RelativePaths.Distinct(StringComparer.Ordinal)) foreach (var item in command.Items.DistinctBy(i => i.RelativePath, StringComparer.Ordinal))
{ {
if (!available.TryGetValue(relativePath, out var file)) if (!available.TryGetValue(item.RelativePath, out var file))
{ {
failed.Add(new ImportFailureDto(relativePath, MediaErrors.SourceNotFound.Message)); failed.Add(
new ImportFailureDto(item.RelativePath, MediaErrors.SourceNotFound.Message)
);
continue; continue;
} }
if (!MediaFormats.IsAllowed(file.Name)) if (!MediaFormats.IsAllowed(file.Name))
{ {
failed.Add( failed.Add(
new ImportFailureDto(relativePath, MediaErrors.UnsupportedFormat.Message) new ImportFailureDto(item.RelativePath, MediaErrors.UnsupportedFormat.Message)
); );
continue; continue;
} }
@@ -52,7 +54,10 @@ public sealed class ImportManualInboxCommandHandler(
if (!show.CanAddEpisode) if (!show.CanAddEpisode)
{ {
failed.Add( failed.Add(
new ImportFailureDto(relativePath, ShowErrors.SingleAlreadyHasEpisode.Message) new ImportFailureDto(
item.RelativePath,
ShowErrors.SingleAlreadyHasEpisode.Message
)
); );
continue; continue;
} }
@@ -64,30 +69,54 @@ public sealed class ImportManualInboxCommandHandler(
if (duplicate) if (duplicate)
{ {
failed.Add( failed.Add(
new ImportFailureDto(relativePath, MediaErrors.DuplicateFileName.Message) new ImportFailureDto(item.RelativePath, MediaErrors.DuplicateFileName.Message)
); );
continue; continue;
} }
var assetId = await RegisterAsync(file, relativePath, cancellationToken); var assetId = await RegisterAsync(file, item.RelativePath, cancellationToken);
if (assetId is null) if (assetId is null)
{ {
failed.Add(new ImportFailureDto(relativePath, MediaErrors.SourceNotFound.Message)); failed.Add(
new ImportFailureDto(item.RelativePath, MediaErrors.SourceNotFound.Message)
);
continue; continue;
} }
// Номера серии — из имени файла, тем же разбором, что и при обычном добавлении серии.
var episode = show.AddEpisode(assetId.Value); var episode = show.AddEpisode(assetId.Value);
if (EpisodeName.Parse(file.Name) is { } parsed) SetNumbers(episode, item, file.Name);
episode.SetNumbers(parsed.Season, parsed.Episode);
queue.Enqueue(assetId.Value); queue.Enqueue(assetId.Value);
imported++; imported++;
// Спутники (субтитры, nfo, обложка) без основного файла — мусор, за которым потом
// никто не вернётся. Удаляем сразу после успешного переноса, а не «когда-нибудь».
await storage.CleanupManualLeftoversAsync(item.RelativePath, cancellationToken);
} }
return Result.Success(new ImportManualInboxResultDto(imported, failed)); return Result.Success(new ImportManualInboxResultDto(imported, failed));
} }
/// <summary>
/// Номера серии: сначала то, что пришло с клиента (там пользователь видел разбор и мог поправить
/// его регуляркой или ручным сезоном), иначе — разбор имени на сервере.
/// </summary>
private static void SetNumbers(
Domain.Library.ShowEpisode episode,
ManualImportItem item,
string fileName
)
{
if (item.Episode is { } episodeNumber)
{
episode.SetNumbers(item.Season ?? 1, episodeNumber);
return;
}
if (EpisodeName.Parse(fileName) is { } parsed)
episode.SetNumbers(item.Season ?? parsed.Season, parsed.Episode);
}
/// <summary> /// <summary>
/// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при обычной загрузке: /// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при обычной загрузке:
/// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы /// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы
@@ -12,10 +12,10 @@ public sealed record ListManualInboxQuery : IQuery<ManualInboxListDto>;
public sealed record ManualInboxFileDto( public sealed record ManualInboxFileDto(
/// <summary>Путь относительно manual/ — он же идентификатор файла в команде импорта.</summary> /// <summary>Путь относительно manual/ — он же идентификатор файла в команде импорта.</summary>
string RelativePath, string RelativePath,
/// <summary>Каталог внутри manual/ («» — корень): по нему список группируется в UI.</summary>
string Folder,
string Name, string Name,
long SizeBytes, long SizeBytes,
/// <summary>Поддерживается ли контейнер. Неподдерживаемые показываем, чтобы не гадать, куда делся файл.</summary>
bool IsSupported,
/// <summary>Файл с таким именем уже есть в библиотеке — импорт отклонит его как дубликат.</summary> /// <summary>Файл с таким именем уже есть в библиотеке — импорт отклонит его как дубликат.</summary>
bool AlreadyImported bool AlreadyImported
); );
@@ -16,8 +16,14 @@ public sealed class ListManualInboxQueryHandler(IAppDbContext dbContext, IMediaS
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
// Просим на один больше лимита — так видно, что каталог не поместился целиком. // Субтитры, nfo и обложки в списке не нужны: выбрать их всё равно нельзя, а рядом с сериями
var files = storage.ListManualInbox(MaxFiles + 1); // они превращают список в кашу. Спутников по имени импорт подчистит сам.
var files = storage
.ListManualInbox(int.MaxValue)
.Where(f => MediaFormats.IsAllowed(f.Name))
.Take(MaxFiles + 1)
.ToList();
var truncated = files.Count > MaxFiles; var truncated = files.Count > MaxFiles;
var page = truncated ? files.Take(MaxFiles).ToList() : files; var page = truncated ? files.Take(MaxFiles).ToList() : files;
if (page.Count == 0) if (page.Count == 0)
@@ -35,13 +41,20 @@ public sealed class ListManualInboxQueryHandler(IAppDbContext dbContext, IMediaS
return new ManualInboxListDto( return new ManualInboxListDto(
page.Select(f => new ManualInboxFileDto( page.Select(f => new ManualInboxFileDto(
f.RelativePath, f.RelativePath,
Folder(f.RelativePath),
f.Name, f.Name,
f.SizeBytes, f.SizeBytes,
MediaFormats.IsAllowed(f.Name),
takenSet.Contains(f.Name) takenSet.Contains(f.Name)
)) ))
.ToList(), .ToList(),
truncated truncated
); );
} }
/// <summary>Каталог файла внутри manual/ — пустая строка для файлов в корне.</summary>
private static string Folder(string relativePath)
{
var slash = relativePath.LastIndexOf('/');
return slash < 0 ? string.Empty : relativePath[..slash];
}
} }
@@ -1,4 +1,5 @@
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
@@ -71,6 +72,58 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
.ToList(); .ToList();
} }
public Task CleanupManualLeftoversAsync(
string relativePath,
CancellationToken cancellationToken
)
{
var path = paths.ManualPath(relativePath);
var directory = Path.GetDirectoryName(path);
if (directory is null || !Directory.Exists(directory))
return Task.CompletedTask;
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
// «Серия.Extended.mkv» это не мусор, а другой материал.
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
var prefix = Path.GetFileNameWithoutExtension(path) + ".";
foreach (var sibling in Directory.EnumerateFiles(directory))
{
var name = Path.GetFileName(sibling);
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
continue;
if (MediaFormats.IsAllowed(name))
continue;
try
{
File.Delete(sibling);
}
catch (IOException)
{
// Файл занят или уже удалён — не повод валить импорт целиком.
}
}
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
if (
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
&& !Directory.EnumerateFileSystemEntries(directory).Any()
)
{
try
{
Directory.Delete(directory);
}
catch (IOException)
{
// Каталог занят — оставим как есть.
}
}
return Task.CompletedTask;
}
public Task PromoteToOriginalAsync( public Task PromoteToOriginalAsync(
MediaSource source, MediaSource source,
string sourceToken, string sourceToken,
@@ -42,7 +42,10 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
storage, storage,
Substitute.For<IMediaProcessingQueue>() Substitute.For<IMediaProcessingQueue>()
).Handle( ).Handle(
new ImportManualInboxCommand([$"Сериал/{first}", $"Сериал/{second}"], showId), new ImportManualInboxCommand(
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
showId
),
default default
); );
@@ -111,7 +114,10 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
storage, storage,
Substitute.For<IMediaProcessingQueue>() Substitute.For<IMediaProcessingQueue>()
).Handle( ).Handle(
new ImportManualInboxCommand([good, "readme.txt", "ушёл.mkv"], showId), new ImportManualInboxCommand(
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
showId
),
default default
); );
@@ -123,6 +129,44 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv"); Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
} }
[SkippableFact]
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId), default);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
await using var verify = fixture.CreateContext();
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
Assert.Equal(4, episode.Season);
Assert.Equal(12, episode.Episode);
// И спутники рядом с забранным файлом убираются.
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
}
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
private static async Task<Guid> SeedShowAsync(AppDbContext db) private static async Task<Guid> SeedShowAsync(AppDbContext db)
{ {
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series); var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
@@ -0,0 +1,160 @@
using Microsoft.Extensions.Options;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Media;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Файловая часть ручного разбора: перечисление, перенос и подчистка спутников. Работает с реальной
/// ФС во временном каталоге — код удаляет файлы, и проверять его на моках бессмысленно.
/// </summary>
public sealed class ManualInboxStorageTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"telewave-tests-" + Guid.NewGuid().ToString("N")[..8]
);
private readonly FileSystemMediaStorage _storage;
private readonly MediaPathResolver _paths;
public ManualInboxStorageTests()
{
_paths = new MediaPathResolver(Options.Create(new StorageOptions { RootPath = _root }));
_paths.EnsureDirectories();
_storage = new FileSystemMediaStorage(_paths);
}
[Fact]
public void ListManualInbox_WalksSubdirectories_AndReturnsRelativePaths()
{
Write("Сериал/s01e01.mkv");
Write("Сериал/s01e02.mkv");
Write("одиночный.mp4");
var files = _storage.ListManualInbox(int.MaxValue);
Assert.Equal(3, files.Count);
// Разделитель нормализован: путь уезжает в URL и возвращается строкой запроса.
Assert.Contains(files, f => f.RelativePath == "Сериал/s01e01.mkv");
Assert.Contains(files, f => f.RelativePath == "одиночный.mp4");
Assert.All(files, f => Assert.True(f.SizeBytes > 0));
}
[Fact]
public async Task PromoteToOriginal_MovesFileOutOfManual()
{
Write("Сериал/s01e01.mkv");
var assetId = Guid.NewGuid();
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"Сериал/s01e01.mkv",
assetId,
".mkv",
default
);
Assert.False(File.Exists(Path.Combine(_paths.ManualDir, "Сериал", "s01e01.mkv")));
Assert.True(File.Exists(_paths.OriginalPath(assetId, ".mkv")));
}
[Fact]
public async Task Cleanup_RemovesSiblings_ButKeepsOtherVideos()
{
Write("Сериал/s01e01.mkv");
Write("Сериал/s01e01.srt");
Write("Сериал/s01e01.ru.srt");
Write("Сериал/s01e01.nfo");
Write("Сериал/s01e01.Extended.mkv");
Write("Сериал/s01e02.mkv");
Write("Сериал/s01e02.srt");
// Так делает импорт: сначала файл уходит в originals/, потом подчищаются спутники.
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"Сериал/s01e01.mkv",
Guid.NewGuid(),
".mkv",
default
);
await _storage.CleanupManualLeftoversAsync("Сериал/s01e01.mkv", default);
var left = Directory
.EnumerateFiles(Path.Combine(_paths.ManualDir, "Сериал"))
.Select(Path.GetFileName)
.ToList();
// Субтитры и nfo забранной серии ушли…
Assert.DoesNotContain("s01e01.srt", left);
Assert.DoesNotContain("s01e01.ru.srt", left);
Assert.DoesNotContain("s01e01.nfo", left);
// …а другой материал и спутники соседних серий остались.
Assert.Contains("s01e01.Extended.mkv", left);
Assert.Contains("s01e02.mkv", left);
Assert.Contains("s01e02.srt", left);
}
[Fact]
public async Task Cleanup_RemovesEmptiedFolder_ButNotManualRoot()
{
Write("Сериал/один.mkv");
Write("Сериал/один.srt");
Write("в-корне.mkv");
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"Сериал/один.mkv",
Guid.NewGuid(),
".mkv",
default
);
await _storage.CleanupManualLeftoversAsync("Сериал/один.mkv", default);
Assert.False(Directory.Exists(Path.Combine(_paths.ManualDir, "Сериал")));
// Файл в корне забираем — сам корень manual/ остаться обязан.
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"в-корне.mkv",
Guid.NewGuid(),
".mkv",
default
);
await _storage.CleanupManualLeftoversAsync("в-корне.mkv", default);
Assert.True(Directory.Exists(_paths.ManualDir));
}
[Fact]
public async Task Cleanup_KeepsFolderThatStillHasFiles()
{
Write("Сериал/s01e01.mkv");
Write("Сериал/s01e02.mkv");
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"Сериал/s01e01.mkv",
Guid.NewGuid(),
".mkv",
default
);
await _storage.CleanupManualLeftoversAsync("Сериал/s01e01.mkv", default);
Assert.True(File.Exists(Path.Combine(_paths.ManualDir, "Сериал", "s01e02.mkv")));
}
private void Write(string relativePath)
{
var full = Path.Combine(_paths.ManualDir, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, "x");
}
public void Dispose()
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
}
@@ -1,8 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { listShows } from '@/features/admin/shows/api' import { listShows } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
import type { ManualInboxFileDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { import {
@@ -14,9 +16,11 @@ import {
DialogTitle, DialogTitle,
} from '@/shared/ui/dialog' } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { importManualInbox, listManualInbox } from './api' import { importManualInbox, listManualInbox } from './api'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */ /** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
@@ -31,8 +35,12 @@ function formatSize(bytes: number): string {
} }
/** /**
* Ручной inbox (см. `manual/`): каталог не сканируется, файлы выбирает админ и сразу указывает шоу. * Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
* Импортированные файлы уходят из каталога — ровно как из обычного inbox. * Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
* nfo) удаляются, чтобы не оставалось мусора.
*
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
* то и сохранится.
*/ */
export function ManualInboxDialog({ onClose }: { onClose: () => void }) { export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -40,6 +48,9 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const [selected, setSelected] = useState<string[]>([]) const [selected, setSelected] = useState<string[]>([])
const [showId, setShowId] = useState('') const [showId, setShowId] = useState('')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [seasonStr, setSeasonStr] = useState('')
const [regexStr, setRegexStr] = useState('')
const [collapsed, setCollapsed] = useState<string[]>([])
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', 'manual'], queryKey: ['admin', 'media', 'manual'],
@@ -47,8 +58,66 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
}) })
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() }) const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
const parsedByPath = useMemo(() => {
const options = {
seasonOverride:
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
episodeRegex: regexOk ? regexStr : null,
}
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
for (const file of data?.files ?? []) map.set(file.relativePath, parseEpisodeName(file.name, options))
return map
}, [data, seasonOverride, regexStr, regexOk])
const folders = useMemo(() => {
const q = query.trim().toLowerCase()
const matched = (data?.files ?? []).filter((file) =>
q ? file.relativePath.toLowerCase().includes(q) : true,
)
const grouped = new Map<string, ManualInboxFileDto[]>()
for (const file of matched) {
const list = grouped.get(file.folder) ?? []
list.push(file)
grouped.set(file.folder, list)
}
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
return [...grouped.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([folder, files]) => ({
folder,
files: [...files].sort((a, b) =>
compareParsed(
{ name: a.name, parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null } },
{ name: b.name, parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null } },
),
),
}))
}, [data, query, parsedByPath])
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
const recognized = selectable.filter(
(f) => parsedByPath.get(f.relativePath)?.episode != null,
).length
const importMutation = useMutation({ const importMutation = useMutation({
mutationFn: () => importManualInbox(selected, showId), mutationFn: () =>
importManualInbox(
selected.map((relativePath) => {
const parsed = parsedByPath.get(relativePath)
return {
relativePath,
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
episode: parsed?.episode ?? null,
}
}),
showId,
),
onSuccess: (result) => { onSuccess: (result) => {
if (result.imported > 0) if (result.imported > 0)
toast.success(t('admin.media.manualImported', { count: result.imported })) toast.success(t('admin.media.manualImported', { count: result.imported }))
@@ -65,78 +134,160 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
toast.error(error instanceof HttpError ? error.detail : t('common.error')), 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) => const toggle = (path: string) =>
setSelected((current) => setSelected((current) =>
current.includes(path) ? current.filter((p) => p !== path) : [...current, path], current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
) )
const toggleFolder = (files: ManualInboxFileDto[]) => {
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
const allSelected = paths.every((p) => selected.includes(p))
setSelected((current) =>
allSelected
? current.filter((p) => !paths.includes(p))
: [...new Set([...current, ...paths])],
)
}
return ( return (
<Dialog open onOpenChange={(open) => !open && onClose()}> <Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle> <DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription> <DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('common.search')}</Label>
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowSeason')}</Label>
<Input <Input
className="max-w-xs" type="number"
placeholder={t('common.search')} min={1}
value={query} placeholder={t('admin.media.toShowAuto')}
onChange={(e) => setQuery(e.target.value)} value={seasonStr}
onChange={(e) => setSeasonStr(e.target.value)}
/> />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowRegex')}</Label>
<Input
placeholder="^(\d+)"
value={regexStr}
onChange={(e) => setRegexStr(e.target.value)}
className={!regexOk ? 'border-red-500' : undefined}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('admin.media.toShowHint')}
{!regexOk && (
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
)}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={importable.length === 0} disabled={selectable.length === 0}
onClick={() => onClick={() =>
setSelected( setSelected(
selected.length === importable.length selected.length === selectable.length
? [] ? []
: importable.map((f) => f.relativePath), : selectable.map((f) => f.relativePath),
) )
} }
> >
{t('admin.media.manualSelectAll')} {t('admin.media.manualSelectAll')}
</Button> </Button>
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground">
{t('admin.media.manualSelected', { count: selected.length })} {t('admin.media.manualSelected', { count: selected.length })}
</span> </span>
<span className="text-muted-foreground">
{t('admin.media.manualRecognized', {
count: recognized,
total: selectable.length,
})}
</span>
</div> </div>
<ul className="crt-panel max-h-80 divide-y divide-border overflow-y-auto rounded-md text-sm"> <div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
{isLoading && ( {isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
<li className="px-3 py-2 text-muted-foreground">{t('common.loading')}</li> {!isLoading && folders.length === 0 && (
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
)} )}
{!isLoading && files.length === 0 && (
<li className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</li> {folders.map(({ folder, files }) => {
)} const isCollapsed = collapsed.includes(folder)
{files.map((file) => {
const blocked = !file.isSupported || file.alreadyImported
return ( return (
<li key={file.relativePath} className="flex items-center gap-2 px-3 py-1.5"> <div key={folder || '/'} className="border-b border-border last:border-0">
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setCollapsed((c) =>
c.includes(folder) ? c.filter((f) => f !== folder) : [...c, folder],
)
}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
<input <input
type="checkbox" type="checkbox"
className="shrink-0" className="shrink-0"
disabled={blocked} checked={files
.filter((f) => !f.alreadyImported)
.every((f) => selected.includes(f.relativePath))}
onChange={() => toggleFolder(files)}
/>
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
{folder || t('admin.media.manualRoot')}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
</div>
{!isCollapsed && (
<ul className="divide-y divide-border">
{files.map((file) => {
const label = formatSeasonEpisode(
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
)
return (
<li
key={file.relativePath}
className="flex items-center gap-2 px-3 py-1.5 pl-9"
>
<input
type="checkbox"
className="shrink-0"
disabled={file.alreadyImported}
checked={selected.includes(file.relativePath)} checked={selected.includes(file.relativePath)}
onChange={() => toggle(file.relativePath)} onChange={() => toggle(file.relativePath)}
/> />
<span {label ? (
className={`min-w-0 flex-1 truncate ${blocked ? 'text-muted-foreground' : ''}`} <Badge className="shrink-0">{label}</Badge>
title={file.relativePath} ) : (
> <Badge variant="muted" className="shrink-0">
{file.relativePath} {t('admin.media.toShowUnknown')}
</span> </Badge>
{!file.isSupported && (
<Badge variant="muted">{t('admin.media.manualUnsupported')}</Badge>
)} )}
<span
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
title={file.name}
>
{file.name}
</span>
{file.alreadyImported && ( {file.alreadyImported && (
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge> <Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
)} )}
@@ -147,6 +298,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
) )
})} })}
</ul> </ul>
)}
</div>
)
})}
</div>
{data?.truncated && ( {data?.truncated && (
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p> <p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
@@ -166,7 +322,7 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t('admin.media.manualOrderHint')} {t('admin.media.manualCleanupHint')}
</span> </span>
</div> </div>
</div> </div>
+7 -3
View File
@@ -2,6 +2,7 @@ import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type { import type {
CreatedIdResponse, CreatedIdResponse,
ImportManualInboxResultDto, ImportManualInboxResultDto,
ManualImportItem,
ManualInboxListDto, ManualInboxListDto,
MediaAssetDto, MediaAssetDto,
MediaAssetStatus, MediaAssetStatus,
@@ -66,11 +67,14 @@ export function listManualInbox() {
return apiRequest<ManualInboxListDto>('/admin/media/manual') return apiRequest<ManualInboxListDto>('/admin/media/manual')
} }
/** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */ /**
export function importManualInbox(relativePaths: string[], showId: string) { * Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox, а спутники
* (субтитры, nfo) удаляются. Номера серий передаются явно сохранится ровно то, что было показано.
*/
export function importManualInbox(items: ManualImportItem[], showId: string) {
return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', { return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
method: 'POST', method: 'POST',
body: { relativePaths, showId }, body: { items, showId },
}) })
} }
+9 -1
View File
@@ -67,12 +67,20 @@ export type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated'
/** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */ /** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */
export type ManualInboxFileDto = { export type ManualInboxFileDto = {
relativePath: string relativePath: string
/** Каталог внутри manual/ («» — корень): по нему список группируется. */
folder: string
name: string name: string
sizeBytes: number sizeBytes: number
isSupported: boolean
alreadyImported: boolean alreadyImported: boolean
} }
/** Файл к разбору: номера серии уходят такими, какими их показал предпросмотр. */
export type ManualImportItem = {
relativePath: string
season: number | null
episode: number | null
}
export type ManualInboxListDto = { export type ManualInboxListDto = {
files: ManualInboxFileDto[] files: ManualInboxFileDto[]
/** Выдача обрезана лимитом — в каталоге есть ещё. */ /** Выдача обрезана лимитом — в каталоге есть ещё. */
+12 -6
View File
@@ -208,11 +208,13 @@ const resources = {
manualSelectAll: 'Выбрать все', manualSelectAll: 'Выбрать все',
manualSelected: 'Выбрано: {{count}}', manualSelected: 'Выбрано: {{count}}',
manualEmpty: 'В папке manual пусто', manualEmpty: 'В папке manual пусто',
manualUnsupported: 'формат не поддерживается',
manualAlready: 'уже в библиотеке', manualAlready: 'уже в библиотеке',
manualRoot: 'корень manual/',
manualRecognized: 'Распознано: {{count}} из {{total}}',
manualCleanupHint:
'Файлы уйдут из папки, спутники (субтитры, nfo) и опустевший каталог будут удалены.',
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.', manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
manualPickShow: 'Выберите шоу', manualPickShow: 'Выберите шоу',
manualOrderHint: 'Номера сезона и серии определяются по именам файлов.',
manualImport: 'Забрать в шоу', manualImport: 'Забрать в шоу',
manualImported: 'Импортировано файлов: {{count}}', manualImported: 'Импортировано файлов: {{count}}',
uploadToShow: 'Загрузить в шоу', uploadToShow: 'Загрузить в шоу',
@@ -317,7 +319,8 @@ const resources = {
number: 'Номер', number: 'Номер',
numberPlaceholder: 'не задан', numberPlaceholder: 'не задан',
utcOffset: 'Часовой пояс, ч', utcOffset: 'Часовой пояс, ч',
utcOffsetHint: 'Смещение от UTC. 3 — московское время; сетка задаётся в нём.', utcOffsetHint:
'Целыми часами: 3 — Москва, 0 — UTC, −5 — Нью-Йорк. В этом времени задаётся вся сетка и показывается расписание.',
dayStart: 'Начало вещательных суток', dayStart: 'Начало вещательных суток',
dayStartHint: 'Ночной блок до этого времени относится к предыдущему дню.', dayStartHint: 'Ночной блок до этого времени относится к предыдущему дню.',
disabled: 'выключен', disabled: 'выключен',
@@ -884,11 +887,13 @@ const resources = {
manualSelectAll: 'Select all', manualSelectAll: 'Select all',
manualSelected: 'Selected: {{count}}', manualSelected: 'Selected: {{count}}',
manualEmpty: 'The manual folder is empty', manualEmpty: 'The manual folder is empty',
manualUnsupported: 'unsupported format',
manualAlready: 'already in the library', manualAlready: 'already in the library',
manualRoot: 'manual/ root',
manualRecognized: 'Recognized: {{count}} of {{total}}',
manualCleanupHint:
'Files leave the folder; siblings (subtitles, nfo) and the emptied folder are removed.',
manualTruncated: 'Showing the first 500 files — there are more in the folder.', manualTruncated: 'Showing the first 500 files — there are more in the folder.',
manualPickShow: 'Pick a show', manualPickShow: 'Pick a show',
manualOrderHint: 'Season and episode numbers are taken from the file names.',
manualImport: 'Import into show', manualImport: 'Import into show',
manualImported: 'Files imported: {{count}}', manualImported: 'Files imported: {{count}}',
uploadToShow: 'Upload to show', uploadToShow: 'Upload to show',
@@ -993,7 +998,8 @@ const resources = {
number: 'Number', number: 'Number',
numberPlaceholder: 'not set', numberPlaceholder: 'not set',
utcOffset: 'Time zone, h', utcOffset: 'Time zone, h',
utcOffsetHint: 'Offset from UTC. 3 is Moscow time; the grid is defined in it.', utcOffsetHint:
'Whole hours: 3 is Moscow, 0 is UTC, 5 is New York. The whole grid and the schedule are expressed in this time.',
dayStart: 'Broadcast day starts', dayStart: 'Broadcast day starts',
dayStartHint: 'The night block before this time belongs to the previous day.', dayStartHint: 'The night block before this time belongs to the previous day.',
disabled: 'disabled', disabled: 'disabled',