Enhance CleanupManualLeftoversAsync to remove subtitles from nested folders and empty directories
ci / build-backend (push) Successful in 2m50s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 3m1s
ci / sonar (push) Successful in 6m15s

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.
This commit is contained in:
Leonid Pershin
2026-07-27 08:57:11 +03:00
parent 0fd4e762f4
commit f22aee4685
3 changed files with 149 additions and 28 deletions
@@ -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;
}
/// <summary>
/// Кто считается спутником забранной серии. Два признака, и оба буквальные: имя, начинающееся
/// с имени серии и точки («Серия.srt», «Серия.ru.srt»), либо тот же номер сезона и серии —
/// в подпапке субтитров файл обычно назван иначе, чем видео, но номер в нём тот же.
///
/// Отбор своим сравнением, а не маской поиска: в имени файла на Linux законно встречается «*»,
/// и маска захватила бы чужое. Код удаляет — он обязан быть буквальным.
/// </summary>
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()
/// <summary>
/// Убирает опустевшие каталоги: сначала подпапки (пустой subs_ru делает пустым и сам сезон),
/// затем сам каталог и его родителей — вверх до корня manual/, который нужен всегда.
/// </summary>
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(