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
@@ -35,9 +35,10 @@ public interface IMediaStorage
IReadOnlyList<ManualInboxFile> ListManualInbox(int max); IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary> /// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем /// Убирает то, что осталось в <c>manual/</c> от забранного файла: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы /// и другим расширением (субтитры, nfo, обложка), файлы того же эпизода в соседних подпапках
/// не трогает — рядом может лежать следующая серия. /// (<c>subs_ru</c>, <c>Subtitles</c>) и опустевшие каталоги вверх до корня <c>manual/</c>.
/// Другие видеофайлы не трогает — рядом может лежать следующая серия или иная версия.
/// </summary> /// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken); Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
@@ -1,4 +1,5 @@
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library;
using TeleWave.Application.Media; using TeleWave.Application.Media;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
@@ -107,48 +108,107 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
if (directory is null || !Directory.Exists(directory)) if (directory is null || !Directory.Exists(directory))
return Task.CompletedTask; return Task.CompletedTask;
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки: var companion = new CompanionMatcher(Path.GetFileName(path));
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
// «Серия.Extended.mkv» это не мусор, а другой материал. DeleteCompanions(directory, companion);
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным. // Субтитры кладут не только рядом с серией, но и в подпапку — subs_ru, Subs, Subtitles.
var prefix = Path.GetFileNameWithoutExtension(path) + "."; // Смотрим ровно на один уровень вниз: глубже начинается уже чужое дерево, а код удаляет.
foreach (var sibling in Directory.EnumerateFiles(directory)) 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)
{ {
var name = Path.GetFileName(sibling); private readonly string _prefix = Path.GetFileNameWithoutExtension(videoFileName) + ".";
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) private readonly (int Season, int Episode)? _numbers = EpisodeName.Parse(videoFileName);
continue;
if (MediaFormats.IsAllowed(name)) public bool Matches(string fileName)
{
// Видеофайлы не трогаем никогда: «Серия.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; continue;
try try
{ {
File.Delete(sibling); File.Delete(file);
} }
catch (IOException) catch (IOException)
{ {
// Файл занят или уже удалён — не повод валить импорт целиком. // Файл занят или уже удалён — не повод валить импорт целиком.
} }
} }
}
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда. /// <summary>
if ( /// Убирает опустевшие каталоги: сначала подпапки (пустой subs_ru делает пустым и сам сезон),
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal) /// затем сам каталог и его родителей — вверх до корня manual/, который нужен всегда.
&& !Directory.EnumerateFileSystemEntries(directory).Any() /// </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)
) )
{ {
current = Path.GetDirectoryName(current) ?? manualRoot;
}
}
private static bool TryDeleteEmpty(string directory)
{
if (!Directory.Exists(directory) || Directory.EnumerateFileSystemEntries(directory).Any())
return false;
try try
{ {
Directory.Delete(directory); Directory.Delete(directory);
return true;
} }
catch (IOException) catch (IOException)
{ {
// Каталог занят — оставим как есть. // Каталог занят — оставим как есть.
return false;
} }
} }
return Task.CompletedTask;
}
public Task PromoteToOriginalAsync( public Task PromoteToOriginalAsync(
MediaSource source, MediaSource source,
string sourceToken, string sourceToken,
@@ -127,6 +127,66 @@ public sealed class ManualInboxStorageTests : IDisposable
Assert.True(Directory.Exists(_paths.ManualDir)); 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] [Fact]
public async Task Cleanup_KeepsFolderThatStillHasFiles() public async Task Cleanup_KeepsFolderThatStillHasFiles()
{ {