From f22aee4685aae37ab036524e4519f8f6f96bea8e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 08:57:11 +0300 Subject: [PATCH] Enhance CleanupManualLeftoversAsync to remove subtitles from nested folders and empty directories Updated the CleanupManualLeftoversAsync method to improve its functionality by allowing it to delete subtitle files located in nested directories and remove empty directories after the last episode is processed. Introduced a CompanionMatcher class to accurately identify companion files based on naming conventions. Added integration tests to verify the new cleanup behavior, ensuring that only relevant files are removed while preserving necessary content. --- .../Common/Interfaces/IMediaStorage.cs | 7 +- .../Media/FileSystemMediaStorage.cs | 110 ++++++++++++++---- .../ManualInboxStorageTests.cs | 60 ++++++++++ 3 files changed, 149 insertions(+), 28 deletions(-) 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() {