469 lines
17 KiB
C#
469 lines
17 KiB
C#
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);
|
||
}
|