diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs index 49ab12b..55f8db8 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -35,9 +35,10 @@ public interface IMediaStorage IReadOnlyList ListManualInbox(int max); /// - /// Убирает то, что осталось в manual/ рядом с забранным файлом: спутники с тем же именем - /// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы - /// не трогает — рядом может лежать следующая серия. + /// Убирает то, что осталось в manual/ от забранного файла: спутники с тем же именем + /// и другим расширением (субтитры, nfo, обложка), файлы того же эпизода в соседних подпапках + /// (subs_ru, Subtitles) и опустевшие каталоги вверх до корня manual/. + /// Другие видеофайлы не трогает — рядом может лежать следующая серия или иная версия. /// Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken); diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index e3be26f..743c33f 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.Library; using TeleWave.Application.Media; using TeleWave.Domain.Media; @@ -107,46 +108,105 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor 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 companion = new CompanionMatcher(Path.GetFileName(path)); + + DeleteCompanions(directory, companion); + + // Субтитры кладут не только рядом с серией, но и в подпапку — subs_ru, Subs, Subtitles. + // Смотрим ровно на один уровень вниз: глубже начинается уже чужое дерево, а код удаляет. + foreach (var nested in Directory.EnumerateDirectories(directory)) + DeleteCompanions(nested, companion); + + RemoveEmptyDirectories(directory); + return Task.CompletedTask; + } + + /// + /// Кто считается спутником забранной серии. Два признака, и оба буквальные: имя, начинающееся + /// с имени серии и точки («Серия.srt», «Серия.ru.srt»), либо тот же номер сезона и серии — + /// в подпапке субтитров файл обычно назван иначе, чем видео, но номер в нём тот же. + /// + /// Отбор своим сравнением, а не маской поиска: в имени файла на Linux законно встречается «*», + /// и маска захватила бы чужое. Код удаляет — он обязан быть буквальным. + /// + private sealed class CompanionMatcher(string videoFileName) + { + private readonly string _prefix = Path.GetFileNameWithoutExtension(videoFileName) + "."; + private readonly (int Season, int Episode)? _numbers = EpisodeName.Parse(videoFileName); + + public bool Matches(string fileName) { - var name = Path.GetFileName(sibling); - if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - continue; - if (MediaFormats.IsAllowed(name)) + // Видеофайлы не трогаем никогда: «Серия.Extended.mkv» — не мусор, а другой материал. + if (MediaFormats.IsAllowed(fileName)) + return false; + + if (fileName.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase)) + return true; + + return _numbers is { } mine + && EpisodeName.Parse(fileName) is { } other + && other.Season == mine.Season + && other.Episode == mine.Episode; + } + } + + private static void DeleteCompanions(string directory, CompanionMatcher companion) + { + foreach (var file in Directory.EnumerateFiles(directory)) + { + if (!companion.Matches(Path.GetFileName(file))) continue; try { - File.Delete(sibling); + File.Delete(file); } catch (IOException) { // Файл занят или уже удалён — не повод валить импорт целиком. } } + } - // Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда. - if ( - !string.Equals(directory, paths.ManualDir, StringComparison.Ordinal) - && !Directory.EnumerateFileSystemEntries(directory).Any() + /// + /// Убирает опустевшие каталоги: сначала подпапки (пустой subs_ru делает пустым и сам сезон), + /// затем сам каталог и его родителей — вверх до корня manual/, который нужен всегда. + /// + private void RemoveEmptyDirectories(string directory) + { + foreach (var nested in Directory.EnumerateDirectories(directory)) + TryDeleteEmpty(nested); + + var manualRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(paths.ManualDir)); + var current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + + while ( + !string.Equals(current, manualRoot, StringComparison.Ordinal) + && current.StartsWith( + manualRoot + Path.DirectorySeparatorChar, + StringComparison.Ordinal + ) + && TryDeleteEmpty(current) ) { - try - { - Directory.Delete(directory); - } - catch (IOException) - { - // Каталог занят — оставим как есть. - } + current = Path.GetDirectoryName(current) ?? manualRoot; } + } - return Task.CompletedTask; + private static bool TryDeleteEmpty(string directory) + { + if (!Directory.Exists(directory) || Directory.EnumerateFileSystemEntries(directory).Any()) + return false; + + try + { + Directory.Delete(directory); + return true; + } + catch (IOException) + { + // Каталог занят — оставим как есть. + return false; + } } public Task PromoteToOriginalAsync( diff --git a/backend/tests/TeleWave.Integration.Tests/ManualInboxStorageTests.cs b/backend/tests/TeleWave.Integration.Tests/ManualInboxStorageTests.cs index cd065d8..ae2d052 100644 --- a/backend/tests/TeleWave.Integration.Tests/ManualInboxStorageTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/ManualInboxStorageTests.cs @@ -127,6 +127,66 @@ public sealed class ManualInboxStorageTests : IDisposable Assert.True(Directory.Exists(_paths.ManualDir)); } + [Fact] + public async Task Cleanup_RemovesSubtitlesFromNestedFolders() + { + // Раскладка из жизни: субтитры лежат не рядом с серией, а в подпапках по языкам, и названы + // иначе — совпадает только номер серии. + Write("Season 16/S16E21.mkv"); + Write("Season 16/subs_ru/S16E21 - The Father, the Son.srt"); + Write("Season 16/subs_en/S16E21 - The Father, the Son.srt"); + Write("Season 16/subs_ru/S16E20 - Home Away From Homer.srt"); + Write("Season 16/extras/S16E21 удалённые сцены.mkv"); + + await _storage.PromoteToOriginalAsync( + MediaSource.ManualInbox, + "Season 16/S16E21.mkv", + Guid.NewGuid(), + ".mkv", + default + ); + await _storage.CleanupManualLeftoversAsync("Season 16/S16E21.mkv", default); + + var season = Path.Combine(_paths.ManualDir, "Season 16"); + Assert.False( + File.Exists(Path.Combine(season, "subs_ru", "S16E21 - The Father, the Son.srt")) + ); + Assert.False( + File.Exists(Path.Combine(season, "subs_en", "S16E21 - The Father, the Son.srt")) + ); + // Субтитры соседней серии остаются: её ещё не забирали. + Assert.True( + File.Exists(Path.Combine(season, "subs_ru", "S16E20 - Home Away From Homer.srt")) + ); + // Видео не трогаем нигде, даже с тем же номером серии, — это другой материал. + Assert.True(File.Exists(Path.Combine(season, "extras", "S16E21 удалённые сцены.mkv"))); + } + + [Fact] + public async Task Cleanup_RemovesEmptiedNestedFolders_AfterLastEpisode() + { + Write("Season 16/S16E01.mkv"); + Write("Season 16/S16E02.mkv"); + Write("Season 16/subs_ru/S16E01 - Treehouse.srt"); + Write("Season 16/subs_ru/S16E02 - All's Fair.srt"); + + foreach (var episode in new[] { "S16E01", "S16E02" }) + { + await _storage.PromoteToOriginalAsync( + MediaSource.ManualInbox, + $"Season 16/{episode}.mkv", + Guid.NewGuid(), + ".mkv", + default + ); + await _storage.CleanupManualLeftoversAsync($"Season 16/{episode}.mkv", default); + } + + // Опустевшие подпапки уходят вместе с каталогом сезона — корень manual/ остаётся. + Assert.False(Directory.Exists(Path.Combine(_paths.ManualDir, "Season 16"))); + Assert.True(Directory.Exists(_paths.ManualDir)); + } + [Fact] public async Task Cleanup_KeepsFolderThatStillHasFiles() {