diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 3aeae9f..b4f395f 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -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 RelativePaths, Guid ShowId); +public sealed record ImportManualInboxBody(IReadOnlyList Items, Guid ShowId); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs index 931492e..3bd44f9 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -34,6 +34,13 @@ public interface IMediaStorage /// IReadOnlyList ListManualInbox(int max); + /// + /// Убирает то, что осталось в manual/ рядом с забранным файлом: спутники с тем же именем + /// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы + /// не трогает — рядом может лежать следующая серия. + /// + Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken); + /// /// Переносит исходник в originals/{assetId}{ext}. Каталог-источник определяется /// : uploads/, inbox/ либо manual/. Именно diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommand.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommand.cs index 026ceff..308ea3c 100644 --- a/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommand.cs +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommand.cs @@ -5,11 +5,19 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Media.ManualInbox; /// -/// Забирает выбранные файлы из manual/ в шоу. Файлы уходят из каталога, как и из обычного -/// inbox/. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить файлы -/// по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox. +/// Один файл к разбору. и приходят с клиента: +/// там пользователь видит распознанные номера и может поправить их регуляркой или ручным сезоном, +/// и сохраниться должно ровно то, что он видел. Не заданы — сервер разберёт имя сам. /// -public sealed record ImportManualInboxCommand(IReadOnlyList RelativePaths, Guid ShowId) +public sealed record ManualImportItem(string RelativePath, int? Season, int? Episode); + +/// +/// Забирает выбранные файлы из manual/ в шоу. Файлы уходят из каталога, как и из обычного +/// inbox/, а рядом лежавшие спутники (субтитры, nfo, обложки) удаляются, чтобы каталог +/// не зарастал мусором. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить +/// файлы по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox. +/// +public sealed record ImportManualInboxCommand(IReadOnlyList Items, Guid ShowId) : ICommand>; /// Итог разбора: что взяли и что не смогли — по файлам, а не одной ошибкой на всё. @@ -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("Номер серии вне допустимого диапазона."); } } diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs index db849a0..cf68d0e 100644 --- a/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs @@ -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)); } + /// + /// Номера серии: сначала то, что пришло с клиента (там пользователь видел разбор и мог поправить + /// его регуляркой или ручным сезоном), иначе — разбор имени на сервере. + /// + 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); + } + /// /// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при обычной загрузке: /// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQuery.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQuery.cs index 3a99644..7a6185b 100644 --- a/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQuery.cs +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQuery.cs @@ -12,10 +12,10 @@ public sealed record ListManualInboxQuery : IQuery; public sealed record ManualInboxFileDto( /// Путь относительно manual/ — он же идентификатор файла в команде импорта. string RelativePath, + /// Каталог внутри manual/ («» — корень): по нему список группируется в UI. + string Folder, string Name, long SizeBytes, - /// Поддерживается ли контейнер. Неподдерживаемые показываем, чтобы не гадать, куда делся файл. - bool IsSupported, /// Файл с таким именем уже есть в библиотеке — импорт отклонит его как дубликат. bool AlreadyImported ); diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQueryHandler.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQueryHandler.cs index 474f246..9857c66 100644 --- a/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQueryHandler.cs +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ListManualInboxQueryHandler.cs @@ -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 ); } + + /// Каталог файла внутри manual/ — пустая строка для файлов в корне. + private static string Folder(string relativePath) + { + var slash = relativePath.LastIndexOf('/'); + return slash < 0 ? string.Empty : relativePath[..slash]; + } } diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index fb2f883..e074fbb 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -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, diff --git a/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs index b5f8297..7751495 100644 --- a/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs @@ -42,7 +42,10 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture) storage, Substitute.For() ).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() ).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(); + storage + .ListManualInbox(Arg.Any()) + .Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]); + + await using var db = fixture.CreateContext(); + var result = await new ImportManualInboxCommandHandler( + db, + storage, + Substitute.For() + ).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()); + } + + /// Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении. + private static ManualImportItem Item(string relativePath) => new(relativePath, null, null); + private static async Task SeedShowAsync(AppDbContext db) { var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series); diff --git a/backend/tests/TeleWave.Integration.Tests/ManualInboxStorageTests.cs b/backend/tests/TeleWave.Integration.Tests/ManualInboxStorageTests.cs new file mode 100644 index 0000000..3eb0537 --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/ManualInboxStorageTests.cs @@ -0,0 +1,160 @@ +using Microsoft.Extensions.Options; +using TeleWave.Domain.Media; +using TeleWave.Infrastructure.Media; +using Xunit; + +namespace TeleWave.Integration.Tests; + +/// +/// Файловая часть ручного разбора: перечисление, перенос и подчистка спутников. Работает с реальной +/// ФС во временном каталоге — код удаляет файлы, и проверять его на моках бессмысленно. +/// +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); + } +} diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxDialog.tsx index 5f9f43e..3e2119d 100644 --- a/frontend/src/features/admin/media/ManualInboxDialog.tsx +++ b/frontend/src/features/admin/media/ManualInboxDialog.tsx @@ -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([]) const [showId, setShowId] = useState('') const [query, setQuery] = useState('') + const [seasonStr, setSeasonStr] = useState('') + const [regexStr, setRegexStr] = useState('') + const [collapsed, setCollapsed] = useState([]) 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>() + 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() + 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 ( !open && onClose()}> - + {t('admin.media.manualTitle')} {t('admin.media.manualHint')}
-
- setQuery(e.target.value)} - /> +
+
+ + setQuery(e.target.value)} /> +
+
+ + setSeasonStr(e.target.value)} + /> +
+
+ + setRegexStr(e.target.value)} + className={!regexOk ? 'border-red-500' : undefined} + /> +
+
+

+ {t('admin.media.toShowHint')} + {!regexOk && ( + {t('admin.media.toShowRegexInvalid')} + )} +

+ +
- + {t('admin.media.manualSelected', { count: selected.length })} + + {t('admin.media.manualRecognized', { + count: recognized, + total: selectable.length, + })} +
-
    - {isLoading && ( -
  • {t('common.loading')}
  • +
    + {isLoading &&

    {t('common.loading')}

    } + {!isLoading && folders.length === 0 && ( +

    {t('admin.media.manualEmpty')}

    )} - {!isLoading && files.length === 0 && ( -
  • {t('admin.media.manualEmpty')}
  • - )} - {files.map((file) => { - const blocked = !file.isSupported || file.alreadyImported + + {folders.map(({ folder, files }) => { + const isCollapsed = collapsed.includes(folder) return ( -
  • - toggle(file.relativePath)} - /> - - {file.relativePath} - - {!file.isSupported && ( - {t('admin.media.manualUnsupported')} +
    +
    + + !f.alreadyImported) + .every((f) => selected.includes(f.relativePath))} + onChange={() => toggleFolder(files)} + /> + + + {folder || t('admin.media.manualRoot')} + + {files.length} +
    + + {!isCollapsed && ( +
      + {files.map((file) => { + const label = formatSeasonEpisode( + parsedByPath.get(file.relativePath) ?? { season: null, episode: null }, + ) + return ( +
    • + toggle(file.relativePath)} + /> + {label ? ( + {label} + ) : ( + + {t('admin.media.toShowUnknown')} + + )} + + {file.name} + + {file.alreadyImported && ( + {t('admin.media.manualAlready')} + )} + + {formatSize(file.sizeBytes)} + +
    • + ) + })} +
    )} - {file.alreadyImported && ( - {t('admin.media.manualAlready')} - )} - - {formatSize(file.sizeBytes)} - -
  • +
    ) })} -
+
{data?.truncated && (

{t('admin.media.manualTruncated')}

@@ -166,7 +322,7 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) { - {t('admin.media.manualOrderHint')} + {t('admin.media.manualCleanupHint')}
diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts index 2c1230d..afa8ae6 100644 --- a/frontend/src/features/admin/media/api.ts +++ b/frontend/src/features/admin/media/api.ts @@ -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('/admin/media/manual') } -/** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */ -export function importManualInbox(relativePaths: string[], showId: string) { +/** + * Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox, а спутники + * (субтитры, nfo) удаляются. Номера серий передаются явно — сохранится ровно то, что было показано. + */ +export function importManualInbox(items: ManualImportItem[], showId: string) { return apiRequest('/admin/media/manual/import', { method: 'POST', - body: { relativePaths, showId }, + body: { items, showId }, }) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index ae2d5bc..e29e6f5 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -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[] /** Выдача обрезана лимитом — в каталоге есть ещё. */ diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 5e45c15..0fa978e 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -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',