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(
new ImportManualInboxCommand(body.RelativePaths, body.ShowId),
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
@@ -222,4 +222,4 @@ public static class MediaEndpoints
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>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
/// не трогает — рядом может лежать следующая серия.
/// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
/// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</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;
/// <summary>
/// Забирает выбранные файлы из <c>manual/</c> в шоу. Файлы уходят из каталога, как и из обычного
/// <c>inbox/</c>. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить файлы
/// по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox.
/// Один файл к разбору. <paramref name="Season"/> и <paramref name="Episode"/> приходят с клиента:
/// там пользователь видит распознанные номера и может поправить их регуляркой или ручным сезоном,
/// и сохраниться должно ровно то, что он видел. Не заданы — сервер разберёт имя сам.
/// </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>>;
/// <summary>Итог разбора: что взяли и что не смогли — по файлам, а не одной ошибкой на всё.</summary>
@@ -25,8 +33,15 @@ public sealed class ImportManualInboxCommandValidator
{
public ImportManualInboxCommandValidator()
{
RuleFor(x => x.RelativePaths).NotEmpty();
RuleFor(x => x.RelativePaths.Count).LessThanOrEqualTo(200);
RuleFor(x => x.Items).NotEmpty();
RuleFor(x => x.Items.Count).LessThanOrEqualTo(500);
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;
}
if (!MediaFormats.IsAllowed(file.Name))
{
failed.Add(
new ImportFailureDto(relativePath, MediaErrors.UnsupportedFormat.Message)
new ImportFailureDto(item.RelativePath, MediaErrors.UnsupportedFormat.Message)
);
continue;
}
@@ -52,7 +54,10 @@ public sealed class ImportManualInboxCommandHandler(
if (!show.CanAddEpisode)
{
failed.Add(
new ImportFailureDto(relativePath, ShowErrors.SingleAlreadyHasEpisode.Message)
new ImportFailureDto(
item.RelativePath,
ShowErrors.SingleAlreadyHasEpisode.Message
)
);
continue;
}
@@ -64,30 +69,54 @@ public sealed class ImportManualInboxCommandHandler(
if (duplicate)
{
failed.Add(
new ImportFailureDto(relativePath, MediaErrors.DuplicateFileName.Message)
new ImportFailureDto(item.RelativePath, MediaErrors.DuplicateFileName.Message)
);
continue;
}
var assetId = await RegisterAsync(file, relativePath, cancellationToken);
var assetId = await RegisterAsync(file, item.RelativePath, cancellationToken);
if (assetId is null)
{
failed.Add(new ImportFailureDto(relativePath, MediaErrors.SourceNotFound.Message));
failed.Add(
new ImportFailureDto(item.RelativePath, MediaErrors.SourceNotFound.Message)
);
continue;
}
// Номера серии — из имени файла, тем же разбором, что и при обычном добавлении серии.
var episode = show.AddEpisode(assetId.Value);
if (EpisodeName.Parse(file.Name) is { } parsed)
episode.SetNumbers(parsed.Season, parsed.Episode);
SetNumbers(episode, item, file.Name);
queue.Enqueue(assetId.Value);
imported++;
// Спутники (субтитры, nfo, обложка) без основного файла — мусор, за которым потом
// никто не вернётся. Удаляем сразу после успешного переноса, а не «когда-нибудь».
await storage.CleanupManualLeftoversAsync(item.RelativePath, cancellationToken);
}
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>
/// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при обычной загрузке:
/// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы
@@ -12,10 +12,10 @@ public sealed record ListManualInboxQuery : IQuery<ManualInboxListDto>;
public sealed record ManualInboxFileDto(
/// <summary>Путь относительно manual/ — он же идентификатор файла в команде импорта.</summary>
string RelativePath,
/// <summary>Каталог внутри manual/ («» — корень): по нему список группируется в UI.</summary>
string Folder,
string Name,
long SizeBytes,
/// <summary>Поддерживается ли контейнер. Неподдерживаемые показываем, чтобы не гадать, куда делся файл.</summary>
bool IsSupported,
/// <summary>Файл с таким именем уже есть в библиотеке — импорт отклонит его как дубликат.</summary>
bool AlreadyImported
);
@@ -16,8 +16,14 @@ public sealed class ListManualInboxQueryHandler(IAppDbContext dbContext, IMediaS
CancellationToken cancellationToken
)
{
// Просим на один больше лимита — так видно, что каталог не поместился целиком.
var files = storage.ListManualInbox(MaxFiles + 1);
// Субтитры, nfo и обложки в списке не нужны: выбрать их всё равно нельзя, а рядом с сериями
// они превращают список в кашу. Спутников по имени импорт подчистит сам.
var files = storage
.ListManualInbox(int.MaxValue)
.Where(f => MediaFormats.IsAllowed(f.Name))
.Take(MaxFiles + 1)
.ToList();
var truncated = files.Count > MaxFiles;
var page = truncated ? files.Take(MaxFiles).ToList() : files;
if (page.Count == 0)
@@ -35,13 +41,20 @@ public sealed class ListManualInboxQueryHandler(IAppDbContext dbContext, IMediaS
return new ManualInboxListDto(
page.Select(f => new ManualInboxFileDto(
f.RelativePath,
Folder(f.RelativePath),
f.Name,
f.SizeBytes,
MediaFormats.IsAllowed(f.Name),
takenSet.Contains(f.Name)
))
.ToList(),
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.Media;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
@@ -71,6 +72,58 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
.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(
MediaSource source,
string sourceToken,
@@ -42,7 +42,10 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand([$"Сериал/{first}", $"Сериал/{second}"], showId),
new ImportManualInboxCommand(
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
showId
),
default
);
@@ -111,7 +114,10 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand([good, "readme.txt", "ушёл.mkv"], showId),
new ImportManualInboxCommand(
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
showId
),
default
);
@@ -123,6 +129,44 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
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)
{
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 { useState } from 'react'
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listShows } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client'
import type { ManualInboxFileDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -14,9 +16,11 @@ import {
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { importManualInbox, listManualInbox } from './api'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
function formatSize(bytes: number): string {
@@ -31,8 +35,12 @@ function formatSize(bytes: number): string {
}
/**
* Ручной inbox (см. `manual/`): каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
* Импортированные файлы уходят из каталога — ровно как из обычного inbox.
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
* nfo) удаляются, чтобы не оставалось мусора.
*
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
* то и сохранится.
*/
export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
@@ -40,6 +48,9 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const [selected, setSelected] = useState<string[]>([])
const [showId, setShowId] = useState('')
const [query, setQuery] = useState('')
const [seasonStr, setSeasonStr] = useState('')
const [regexStr, setRegexStr] = useState('')
const [collapsed, setCollapsed] = useState<string[]>([])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', 'manual'],
@@ -47,8 +58,66 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
})
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({
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) => {
if (result.imported > 0)
toast.success(t('admin.media.manualImported', { count: result.imported }))
@@ -65,88 +134,175 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
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],
)
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl">
<DialogContent className="max-w-4xl">
<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)}
/>
<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
type="number"
min={1}
placeholder={t('admin.media.toShowAuto')}
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
size="sm"
variant="outline"
disabled={importable.length === 0}
disabled={selectable.length === 0}
onClick={() =>
setSelected(
selected.length === importable.length
selected.length === selectable.length
? []
: importable.map((f) => f.relativePath),
: selectable.map((f) => f.relativePath),
)
}
>
{t('admin.media.manualSelectAll')}
</Button>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground">
{t('admin.media.manualSelected', { count: selected.length })}
</span>
<span className="text-muted-foreground">
{t('admin.media.manualRecognized', {
count: recognized,
total: selectable.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>
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
{!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>
)}
{files.map((file) => {
const blocked = !file.isSupported || file.alreadyImported
{folders.map(({ folder, files }) => {
const isCollapsed = collapsed.includes(folder)
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>
<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
type="checkbox"
className="shrink-0"
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)}
onChange={() => toggle(file.relativePath)}
/>
{label ? (
<Badge className="shrink-0">{label}</Badge>
) : (
<Badge variant="muted" className="shrink-0">
{t('admin.media.toShowUnknown')}
</Badge>
)}
<span
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
title={file.name}
>
{file.name}
</span>
{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>
)}
{file.alreadyImported && (
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
)}
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatSize(file.sizeBytes)}
</span>
</li>
</div>
)
})}
</ul>
</div>
{data?.truncated && (
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
@@ -166,7 +322,7 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{t('admin.media.manualOrderHint')}
{t('admin.media.manualCleanupHint')}
</span>
</div>
</div>
+7 -3
View File
@@ -2,6 +2,7 @@ import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
CreatedIdResponse,
ImportManualInboxResultDto,
ManualImportItem,
ManualInboxListDto,
MediaAssetDto,
MediaAssetStatus,
@@ -66,11 +67,14 @@ export function listManualInbox() {
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', {
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/ и ждёт, пока его разложат по шоу. */
export type ManualInboxFileDto = {
relativePath: string
/** Каталог внутри manual/ («» — корень): по нему список группируется. */
folder: string
name: string
sizeBytes: number
isSupported: boolean
alreadyImported: boolean
}
/** Файл к разбору: номера серии уходят такими, какими их показал предпросмотр. */
export type ManualImportItem = {
relativePath: string
season: number | null
episode: number | null
}
export type ManualInboxListDto = {
files: ManualInboxFileDto[]
/** Выдача обрезана лимитом — в каталоге есть ещё. */
+12 -6
View File
@@ -208,11 +208,13 @@ const resources = {
manualSelectAll: 'Выбрать все',
manualSelected: 'Выбрано: {{count}}',
manualEmpty: 'В папке manual пусто',
manualUnsupported: 'формат не поддерживается',
manualAlready: 'уже в библиотеке',
manualRoot: 'корень manual/',
manualRecognized: 'Распознано: {{count}} из {{total}}',
manualCleanupHint:
'Файлы уйдут из папки, спутники (субтитры, nfo) и опустевший каталог будут удалены.',
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
manualPickShow: 'Выберите шоу',
manualOrderHint: 'Номера сезона и серии определяются по именам файлов.',
manualImport: 'Забрать в шоу',
manualImported: 'Импортировано файлов: {{count}}',
uploadToShow: 'Загрузить в шоу',
@@ -317,7 +319,8 @@ const resources = {
number: 'Номер',
numberPlaceholder: 'не задан',
utcOffset: 'Часовой пояс, ч',
utcOffsetHint: 'Смещение от UTC. 3 — московское время; сетка задаётся в нём.',
utcOffsetHint:
'Целыми часами: 3 — Москва, 0 — UTC, −5 — Нью-Йорк. В этом времени задаётся вся сетка и показывается расписание.',
dayStart: 'Начало вещательных суток',
dayStartHint: 'Ночной блок до этого времени относится к предыдущему дню.',
disabled: 'выключен',
@@ -884,11 +887,13 @@ const resources = {
manualSelectAll: 'Select all',
manualSelected: 'Selected: {{count}}',
manualEmpty: 'The manual folder is empty',
manualUnsupported: 'unsupported format',
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.',
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',
@@ -993,7 +998,8 @@ const resources = {
number: 'Number',
numberPlaceholder: 'not set',
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',
dayStartHint: 'The night block before this time belongs to the previous day.',
disabled: 'disabled',