emby-toolbox/EmbyToolbox/Services/SeriesRenamerService.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

469 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.RegularExpressions;
using System.IO;
namespace EmbyToolbox.Services;
public sealed class SeriesRenamerService
{
private static readonly HashSet<string> 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<string> 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<SeriesRenameOperation>();
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<string>(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<SeriesRenameOperation> 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<SeriesRenameOperation> 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<SeriesRenameOperation> { 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<string, string> ResolveTargets(List<SeriesRenameOperation> ops, bool isDirectory)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var sourceSet = ops.Select(o => o.SourcePath).ToHashSet(StringComparer.OrdinalIgnoreCase);
var reserved = new HashSet<string>(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<int> ResolveSeasonNumbers(List<DirectoryInfo> 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<SeriesRenameOperation> 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<SeriesRenameOperation> Operations { get; }
public static SeriesRenamePreview Unsupported(string reason) => new(false, reason, null, null, Array.Empty<SeriesRenameOperation>());
public static SeriesRenamePreview Supported(SeriesNode current, SeriesNode preview, IReadOnlyList<SeriesRenameOperation> operations) => new(true, null, current, preview, operations);
}
public sealed record SeriesNode(string NodeKey, string Name, string Kind)
{
public List<SeriesNode> 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);
}