using System.Text.RegularExpressions; using System.IO; namespace EmbyToolbox.Services; public sealed class SeriesRenamerService { private static readonly HashSet VideoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".mkv",".mp4",".avi",".mov",".wmv",".flv",".ts",".m2ts",".webm",".mpeg",".mpg",".m4v",".3gp",".ogv",".vob",".rmvb",".asf",".divx",".f4v",".mts",".m2v",".mp2",".mpv",".qt",".hevc",".h265",".h264" }; private static readonly HashSet SidecarExtensions = new(StringComparer.OrdinalIgnoreCase) { ".srt",".ass",".ssa",".sub",".idx",".sup",".vtt",".txt",".smi",".rt", ".aac",".ac3",".eac3",".dts",".flac",".m4a",".mp3",".opus",".wav",".oga", ".jpg",".jpeg",".png",".webp",".nfo",".xml",".cue",".log",".url" }; public SeriesRenamePreview BuildPreview(string? rootPath, string? newSeriesName) { if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath)) { return SeriesRenamePreview.Unsupported("Папка сериала не выбрана."); } var seriesName = (newSeriesName ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(seriesName)) { return SeriesRenamePreview.Unsupported("Пустое имя сериала."); } if (seriesName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) { return SeriesRenamePreview.Unsupported("Имя сериала содержит недопустимые символы."); } var rootInfo = new DirectoryInfo(rootPath); var seasonDirs = rootInfo.GetDirectories().OrderBy(d => d.Name, NaturalStringComparer.Instance).ToList(); if (seasonDirs.Count == 0) { return SeriesRenamePreview.Unsupported("Нет папок сезонов."); } foreach (var season in seasonDirs) { if (season.GetDirectories().Length > 0) { return SeriesRenamePreview.Unsupported($"В сезоне '{season.Name}' обнаружены вложенные папки."); } } var seasonNumbers = ResolveSeasonNumbers(seasonDirs); var operations = new List(); var rootTargetPath = Path.Combine(rootInfo.Parent?.FullName ?? rootInfo.FullName, seriesName); var currentRoot = new SeriesNode("root", rootInfo.Name, "Folder"); var previewRoot = new SeriesNode("root", seriesName, "Folder"); if (!PathsEqual(rootInfo.FullName, rootTargetPath)) { operations.Add(new SeriesRenameOperation(OperationKind.RootDirectory, rootInfo.FullName, rootTargetPath)); } for (var i = 0; i < seasonDirs.Count; i++) { var season = seasonDirs[i]; var seasonNo = seasonNumbers[i]; var seasonTargetName = $"Season {seasonNo:00}"; var seasonTargetPath = Path.Combine(rootInfo.FullName, seasonTargetName); var seasonKey = $"season:{i}:{season.Name}"; var currentSeasonNode = new SeriesNode(seasonKey, season.Name, "Folder"); var previewSeasonNode = new SeriesNode(seasonKey, seasonTargetName, "Folder"); currentRoot.Children.Add(currentSeasonNode); previewRoot.Children.Add(previewSeasonNode); if (!PathsEqual(season.FullName, seasonTargetPath)) { operations.Add(new SeriesRenameOperation(OperationKind.SeasonDirectory, season.FullName, seasonTargetPath)); } var allFiles = season.GetFiles().OrderBy(f => f.Name, NaturalStringComparer.Instance).ToList(); var videoFiles = allFiles.Where(IsVideoFile).OrderBy(f => f.Name, NaturalStringComparer.Instance).ToList(); var plannedSidecars = new HashSet(StringComparer.OrdinalIgnoreCase); for (var e = 0; e < videoFiles.Count; e++) { var video = videoFiles[e]; var newStem = $"{seriesName} S{seasonNo:00}E{e + 1:00}"; var newVideoName = $"{newStem}{video.Extension}"; var newVideoPath = Path.Combine(video.DirectoryName!, newVideoName); var videoKey = $"{seasonKey}/video:{e}:{video.Name}"; currentSeasonNode.Children.Add(new SeriesNode(videoKey, video.Name, "Video")); previewSeasonNode.Children.Add(new SeriesNode(videoKey, newVideoName, "Video")); if (!PathsEqual(video.FullName, newVideoPath)) { operations.Add(new SeriesRenameOperation(OperationKind.File, video.FullName, newVideoPath)); } var stem = Path.GetFileNameWithoutExtension(video.Name); var linked = allFiles .Where(f => !PathsEqual(f.FullName, video.FullName)) .Where(f => f.Name.StartsWith(stem + ".", StringComparison.OrdinalIgnoreCase)) .Where(IsSidecarFile) .ToList(); foreach (var sidecar in linked) { if (!plannedSidecars.Add(sidecar.FullName)) { continue; } var suffix = sidecar.Name[stem.Length..]; var newSidecarName = $"{newStem}{suffix}"; var newSidecarPath = Path.Combine(sidecar.DirectoryName!, newSidecarName); var sidecarKey = $"{seasonKey}/sidecar:{sidecar.Name}"; currentSeasonNode.Children.Add(new SeriesNode(sidecarKey, sidecar.Name, "Sidecar")); previewSeasonNode.Children.Add(new SeriesNode(sidecarKey, newSidecarName, "Sidecar")); if (!PathsEqual(sidecar.FullName, newSidecarPath)) { operations.Add(new SeriesRenameOperation(OperationKind.File, sidecar.FullName, newSidecarPath)); } } } } var deduped = operations .GroupBy(o => o.SourcePath, StringComparer.OrdinalIgnoreCase) .Select(g => g.Last()) .ToList(); return SeriesRenamePreview.Supported(currentRoot, previewRoot, deduped); } public SeriesRenameExecutionResult ExecutePreview(SeriesRenamePreview preview, string currentRootPath) { if (!preview.IsSupported) { return SeriesRenameExecutionResult.Fail(preview.UnsupportedReason ?? "Предпросмотр недоступен.", currentRootPath, currentRootPath, false); } var fileOps = preview.Operations.Where(o => o.Kind == OperationKind.File && !PathsEqual(o.SourcePath, o.TargetPath)).ToList(); var seasonOps = preview.Operations.Where(o => o.Kind == OperationKind.SeasonDirectory && !PathsEqual(o.SourcePath, o.TargetPath)).ToList(); var rootOp = preview.Operations.FirstOrDefault(o => o.Kind == OperationKind.RootDirectory && !PathsEqual(o.SourcePath, o.TargetPath)); var oldRootPath = currentRootPath; var newRootPath = currentRootPath; var rootWasRenamed = false; var rollbackActions = new Stack<(string from, string to, bool isDir)>(); try { ExecuteFileOps(fileOps, rollbackActions); ExecuteSeasonOps(seasonOps, rollbackActions); if (rootOp is not null) { newRootPath = ExecuteRootOp(rootOp, rollbackActions); rootWasRenamed = !PathsEqual(oldRootPath, newRootPath); } return SeriesRenameExecutionResult.Ok(oldRootPath, newRootPath, rootWasRenamed); } catch (Exception ex) { while (rollbackActions.Count > 0) { var action = rollbackActions.Pop(); try { MovePath(action.from, action.to, action.isDir); } catch { // Best effort rollback. } } return SeriesRenameExecutionResult.Fail(ex.Message, oldRootPath, newRootPath, rootWasRenamed); } } private static void ExecuteFileOps(List fileOps, Stack<(string from, string to, bool isDir)> rollbackActions) { if (fileOps.Count == 0) { return; } var resolvedTargets = ResolveTargets(fileOps, isDirectory: false); var staged = new List<(string source, string temp, string final)>(); var finalized = new List<(string source, string temp, string final)>(); try { foreach (var op in fileOps) { var temp = GetTempPath(op.SourcePath); MovePath(op.SourcePath, temp, isDirectory: false); staged.Add((op.SourcePath, temp, resolvedTargets[op.SourcePath])); } foreach (var item in staged) { MovePath(item.temp, item.final, isDirectory: false); finalized.Add(item); } } catch { for (var i = finalized.Count - 1; i >= 0; i--) { var item = finalized[i]; if (File.Exists(item.final)) { MovePath(item.final, item.temp, isDirectory: false); } } for (var i = staged.Count - 1; i >= 0; i--) { var item = staged[i]; if (File.Exists(item.temp)) { MovePath(item.temp, item.source, isDirectory: false); } } throw; } foreach (var item in finalized) { rollbackActions.Push((item.final, item.source, false)); } } private static void ExecuteSeasonOps(List seasonOps, Stack<(string from, string to, bool isDir)> rollbackActions) { if (seasonOps.Count == 0) { return; } var resolved = ResolveTargets(seasonOps, isDirectory: true); var staged = new List<(string source, string temp, string final)>(); var finalized = new List<(string source, string temp, string final)>(); try { foreach (var op in seasonOps.OrderByDescending(o => o.SourcePath.Length)) { var temp = GetTempPath(op.SourcePath); MovePath(op.SourcePath, temp, isDirectory: true); staged.Add((op.SourcePath, temp, resolved[op.SourcePath])); } foreach (var item in staged) { MovePath(item.temp, item.final, isDirectory: true); finalized.Add(item); } } catch { for (var i = finalized.Count - 1; i >= 0; i--) { var item = finalized[i]; if (Directory.Exists(item.final)) { MovePath(item.final, item.temp, isDirectory: true); } } for (var i = staged.Count - 1; i >= 0; i--) { var item = staged[i]; if (Directory.Exists(item.temp)) { MovePath(item.temp, item.source, isDirectory: true); } } throw; } foreach (var item in finalized) { rollbackActions.Push((item.final, item.source, true)); } } private static string ExecuteRootOp(SeriesRenameOperation rootOp, Stack<(string from, string to, bool isDir)> rollbackActions) { var resolved = ResolveTargets(new List { rootOp }, isDirectory: true); var final = resolved[rootOp.SourcePath]; MovePath(rootOp.SourcePath, final, isDirectory: true); rollbackActions.Push((final, rootOp.SourcePath, true)); return final; } private static Dictionary ResolveTargets(List ops, bool isDirectory) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); var sourceSet = ops.Select(o => o.SourcePath).ToHashSet(StringComparer.OrdinalIgnoreCase); var reserved = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var op in ops) { var candidate = op.TargetPath; var duplicate = 1; while (reserved.Contains(candidate) || (PathExists(candidate, isDirectory) && !sourceSet.Contains(candidate))) { candidate = AppendDuplicate(op.TargetPath, duplicate++, isDirectory); } reserved.Add(candidate); result[op.SourcePath] = candidate; } return result; } private static bool PathExists(string path, bool isDirectory) { return isDirectory ? Directory.Exists(path) : File.Exists(path); } private static string AppendDuplicate(string path, int duplicate, bool isDirectory) { var dir = Path.GetDirectoryName(path) ?? string.Empty; var name = Path.GetFileNameWithoutExtension(path); var ext = isDirectory ? string.Empty : Path.GetExtension(path); return Path.Combine(dir, $"{name}_duplicate_{duplicate}{ext}"); } private static void MovePath(string from, string to, bool isDirectory) { var parent = Path.GetDirectoryName(to); if (!string.IsNullOrWhiteSpace(parent)) { Directory.CreateDirectory(parent); } if (isDirectory) { Directory.Move(from, to); } else { File.Move(from, to); } } private static string GetTempPath(string sourcePath) { var dir = Path.GetDirectoryName(sourcePath) ?? string.Empty; var name = Path.GetFileName(sourcePath); return Path.Combine(dir, $"{name}.__emby_tmp_{Guid.NewGuid():N}"); } private static bool IsVideoFile(FileInfo file) { return VideoExtensions.Contains(file.Extension); } private static bool IsSidecarFile(FileInfo file) { return SidecarExtensions.Contains(file.Extension); } private static List ResolveSeasonNumbers(List seasonDirs) { var numeric = seasonDirs .Select(d => TryParseSeasonNumber(d.Name)) .ToList(); if (numeric.All(v => v is not null)) { return numeric.Select(v => v!.Value).ToList(); } return Enumerable.Range(1, seasonDirs.Count).ToList(); } private static int? TryParseSeasonNumber(string name) { var trimmed = name.Trim(); if (Regex.IsMatch(trimmed, @"^\d+$") && int.TryParse(trimmed, out var n)) { return n; } return null; } private static bool PathsEqual(string a, string b) { return string.Equals( Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar), Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase); } } public sealed class SeriesRenamePreview { private SeriesRenamePreview(bool isSupported, string? unsupportedReason, SeriesNode? currentTree, SeriesNode? previewTree, IReadOnlyList operations) { IsSupported = isSupported; UnsupportedReason = unsupportedReason; CurrentTree = currentTree; PreviewTree = previewTree; Operations = operations; } public bool IsSupported { get; } public string? UnsupportedReason { get; } public SeriesNode? CurrentTree { get; } public SeriesNode? PreviewTree { get; } public IReadOnlyList Operations { get; } public static SeriesRenamePreview Unsupported(string reason) => new(false, reason, null, null, Array.Empty()); public static SeriesRenamePreview Supported(SeriesNode current, SeriesNode preview, IReadOnlyList operations) => new(true, null, current, preview, operations); } public sealed record SeriesNode(string NodeKey, string Name, string Kind) { public List Children { get; } = new(); } public sealed record SeriesRenameOperation(OperationKind Kind, string SourcePath, string TargetPath); public enum OperationKind { File, SeasonDirectory, RootDirectory } public sealed class SeriesRenameExecutionResult { private SeriesRenameExecutionResult(bool isSuccess, string error, string oldRootPath, string newRootPath, bool rootWasRenamed) { IsSuccess = isSuccess; Error = error; OldRootPath = oldRootPath; NewRootPath = newRootPath; RootWasRenamed = rootWasRenamed; } public bool IsSuccess { get; } public string Error { get; } public string OldRootPath { get; } public string NewRootPath { get; } public bool RootWasRenamed { get; } public static SeriesRenameExecutionResult Ok(string oldRootPath, string newRootPath, bool rootWasRenamed) => new(true, string.Empty, oldRootPath, newRootPath, rootWasRenamed); public static SeriesRenameExecutionResult Fail(string error, string oldRootPath, string newRootPath, bool rootWasRenamed) => new(false, error, oldRootPath, newRootPath, rootWasRenamed); }