diff --git a/src/Commands/.editorconfig b/src/Commands/.editorconfig new file mode 100755 index 0000000..a343628 --- /dev/null +++ b/src/Commands/.editorconfig @@ -0,0 +1,3 @@ +[*.{cs,vb}] +dotnet_diagnostic.CA1822.severity = none +dotnet_diagnostic.IDE0060.severity = none diff --git a/src/Commands/Library/ArrangeCommand.cs b/src/Commands/Library/ArrangeCommand.cs new file mode 100755 index 0000000..0566c70 --- /dev/null +++ b/src/Commands/Library/ArrangeCommand.cs @@ -0,0 +1,98 @@ +using ConsoleAppFramework; + +namespace MediaOrganizer.Commands.Library; + +[RegisterCommands("library")] +internal class ArrangeCommand +{ + /// + /// Arranges files next to mapFile into targetDirectory. + /// + /// A file with "="-separated identifiers. + /// Target directory to store value-part of mapFile in. + [Command("arrange")] + public async Task Execute( + ConsoleAppContext context, + string mapFile, + string targetDirectory, + CancellationToken cancellationToken = default) + { + if (new FileInfo(mapFile) is not + { + Exists: true + } mapFileInfo) + { + throw new ArgumentException($"Map File {mapFile} not found", nameof(mapFile)); + } + + DirectoryInfo targetDirectoryInfo = new(targetDirectory); + targetDirectoryInfo.Create(); + + bool error = false; + using var reader = mapFileInfo.OpenText(); + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + if (line.AsMemory().Trim() is not + { + IsEmpty: false + } lineMemory) + { + continue; + } + + var sep = lineMemory.Span.LastIndexOf('='); + if (lineMemory[(sep + 1)..].Trim() is not + { + IsEmpty: false + } targetMemory) + { + continue; + } + + var sourcePrefix = lineMemory[..sep].Trim().ToString(); + var targetFile = targetMemory.ToString(); + var targetFileBaseName = Path.Join(targetDirectoryInfo.FullName, targetFile); + var targetFileDirectory = Path.GetDirectoryName(targetFileBaseName); + try + { + Directory.CreateDirectory(targetFileDirectory!); + } + catch (Exception e) + { + error = true; + ConsoleApp.LogError($"Create target directory for {sourcePrefix} in {targetFileDirectory}: {e.Message}"); + continue; + } + + try + { + foreach (var file in Directory.EnumerateFiles(mapFileInfo.DirectoryName!, $"{sourcePrefix}*")) + { + var relative = Path.GetRelativePath(mapFileInfo.DirectoryName!, file); + var extension = relative[sourcePrefix.Length..]; + var target = targetFileBaseName + extension; + + try + { + if (!File.Exists(target)) + { + File.Copy(file, target); + } + } + catch (Exception e) + { + error = true; + ConsoleApp.LogError($"Copy file {relative} to {target}: {e.Message}"); + } + } + } + catch (Exception e) + { + error = true; + ConsoleApp.LogError($"Listing directory {mapFileInfo.DirectoryName}: {e.Message}"); + } + } + + return error ? 1 : 0; + } +} diff --git a/src/Commands/Prepare/ArchiveCommand.cs b/src/Commands/Prepare/ArchiveCommand.cs new file mode 100755 index 0000000..d838691 --- /dev/null +++ b/src/Commands/Prepare/ArchiveCommand.cs @@ -0,0 +1,119 @@ +using ConsoleAppFramework; + +using Microsoft.Extensions.Logging; + +namespace MediaOrganizer.Commands.Prepare; + +[RegisterCommands("prepare")] +internal class ArchiveCommand +{ + /// + /// Create a map file from a youtube-dl archive file. + /// + /// + /// + /// + /// + [Command("archive")] + public async Task Execute( + ConsoleAppContext context, + string mapFile, + string archiveFile, + int episode = 1, + string episodeNameFormat = "S01E{Episode:00}", + CancellationToken cancellationToken = default) + { + if (new FileInfo(archiveFile) is not + { + Exists: true + } archiveFileInfo) + { + throw new ArgumentException($"Archive file {archiveFile} has to exist", nameof(archiveFile)); + } + + FileInfo mapFileInfo = new(mapFile); + LogValuesFormat format = new(episodeNameFormat); + HashSet skip = []; + using var mapStream = mapFileInfo.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read); + if (mapStream.Length > 0) + { + using StreamReader reader = new(mapStream, leaveOpen: true); + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + if (line.AsMemory().Trim() is not + { + IsEmpty: false + } memory) + { + continue; + } + + int sep = memory.Span.LastIndexOf('='); + if (sep == -1) + { + continue; + } + + var key = memory[..sep]; + var path = key.Span.LastIndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]); + if (path != -1) + { + key = key[(path + 1)..]; + } + + skip.Add(key.Trim().ToString()); + } + } + + var relativePath = Path.GetRelativePath( + mapFileInfo.DirectoryName!, + archiveFileInfo.DirectoryName!); + if (relativePath is ".") + { + relativePath = string.Empty; + } + + if (format.ValueNames.Count > 1) + { + throw new ArgumentException("Episode format has too many unique format values.", nameof(episodeNameFormat)); + } + + var formatArgs = new object[format.ValueNames.Count]; + EpisodeFormat? episodeFormat = null; + for (int i = 0; i < format.ValueNames.Count; i++) + { + string formatKey; + formatArgs[i] = (formatKey = format.ValueNames[i]) switch + { + "Episode" => episodeFormat = new() + { + Episode = episode + }, + _ => throw new ArgumentException($"Format value {formatKey} unrecognized.", nameof(episodeNameFormat)), + }; + } + + using StreamWriter mapWriter = new(mapStream, leaveOpen: true); + using var archiveReader = archiveFileInfo.OpenText(); + while (await archiveReader.ReadLineAsync(cancellationToken) is { } line) + { + if (ID_MATCHER.Match(line) is not { Success: true } match) + { + continue; + } + + var id = match.Groups["id"].Value; + if (skip.Contains(id)) + { + continue; + } + + var written = string.Format(null, format.Format, formatArgs); + mapWriter.WriteLine($"{Path.Join(relativePath, id)}={written}"); + if (episodeFormat is not null) + { + episodeFormat.Episode++; + } + } + } +} diff --git a/src/Commands/Prepare/EpisodeFormat.cs b/src/Commands/Prepare/EpisodeFormat.cs new file mode 100644 index 0000000..e5d7975 --- /dev/null +++ b/src/Commands/Prepare/EpisodeFormat.cs @@ -0,0 +1,20 @@ +namespace MediaOrganizer.Commands.Prepare; + +internal sealed record class EpisodeFormat : ISpanFormattable, IUtf8SpanFormattable +{ + private int _episode; + + public ref int Episode => ref _episode; + + public string ToString(string? format, IFormatProvider? formatProvider) => _episode.ToString(formatProvider); + + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + return _episode.TryFormat(utf8Destination, out bytesWritten, format, provider); + } + + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + return _episode.TryFormat(destination, out charsWritten, format, provider); + } +} diff --git a/src/Commands/Prepare/MapCommand.cs b/src/Commands/Prepare/MapCommand.cs new file mode 100755 index 0000000..8be8b3d --- /dev/null +++ b/src/Commands/Prepare/MapCommand.cs @@ -0,0 +1,113 @@ +using ConsoleAppFramework; + +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; +using Microsoft.Extensions.Logging; + +namespace MediaOrganizer.Commands.Prepare; + +[RegisterCommands("prepare")] +internal class MapCommand +{ + /// + /// Creates a mapFile, and applies a format onto all files. + /// + /// Path to map file. + /// Include pattern to include relative to mapFile directory. + /// Episode number to start with for new entries in mapFile. + /// Format to store mapping in mapFile. Possible options: + /// - Episode: integer + /// + [Command("map")] + public async Task Execute( + ConsoleAppContext context, + string mapFile, + string[] include, + int episode = 1, + string episodeNameFormat = "S01E{Episode:00}", + CancellationToken cancellationToken = default) + { + FileInfo mapFileInfo = new(mapFile); + Matcher matcher = new(StringComparison.OrdinalIgnoreCase); + matcher.AddIncludePatterns(include); + LogValuesFormat format = new(episodeNameFormat); + SortedSet episodes = new(StringComparer.OrdinalIgnoreCase); + foreach (var item in matcher.Execute(new DirectoryInfoWrapper(mapFileInfo.Directory!)).Files) + { + var nameWithoutExtension = StripExtension(item.Path); + if (!episodes.AnyStartsWith(nameWithoutExtension, StringComparison.OrdinalIgnoreCase)) + { + episodes.RemovePrefix(nameWithoutExtension, StringComparison.OrdinalIgnoreCase); + episodes.Add(nameWithoutExtension.ToString()); + } + } + + using var mapStream = mapFileInfo.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read); + if (mapStream.Length > 0) + { + using StreamReader reader = new(mapStream, leaveOpen: true); + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + if (line.AsMemory().Trim() is not + { + IsEmpty: false + } memory) + { + continue; + } + + var sep = memory.Span.LastIndexOf('='); + if (sep == -1) + { + continue; + } + + episodes.Remove(memory[..sep].Trim().NormalizePath()); + } + } + + if (format.ValueNames.Count > 1) + { + throw new ArgumentException("Episode format has too many unique format values.", nameof(episodeNameFormat)); + } + + var formatArgs = new object[format.ValueNames.Count]; + EpisodeFormat? episodeFormat = null; + for (int i = 0; i < format.ValueNames.Count; i++) + { + string formatKey; + formatArgs[i] = (formatKey = format.ValueNames[i]) switch + { + "Episode" => episodeFormat = new() + { + Episode = episode + }, + _ => throw new ArgumentException($"Format value {formatKey} unrecognized.", nameof(episodeNameFormat)), + }; + } + + object[] formatList = [format]; + using StreamWriter mapWriter = new(mapStream, leaveOpen: true); + foreach (var item in episodes) + { + var written = string.Format(null, format.Format, formatArgs); + mapWriter.WriteLine($"{item}={written}"); + if (episodeFormat is not null) + { + episodeFormat.Episode++; + } + } + } + + static ReadOnlySpan StripExtension(in ReadOnlySpan path) + { + var fileName = Path.GetFileName(path); + int i; + if ((i = fileName.LastIndexOf('.')) == -1) + { + return path; + } + + return path[..^(fileName.Length - i)]; + } +} diff --git a/src/Microsoft/Extensions/Logging/LogValuesFormat.cs b/src/Microsoft/Extensions/Logging/LogValuesFormat.cs new file mode 100644 index 0000000..80ea436 --- /dev/null +++ b/src/Microsoft/Extensions/Logging/LogValuesFormat.cs @@ -0,0 +1,22 @@ +using System.Runtime.CompilerServices; +using System.Text; + +namespace Microsoft.Extensions.Logging; + +public readonly struct LogValuesFormat(string format) +{ + private readonly LogValuesFormatter _instance = Create(format); + + public CompositeFormat Format => GetFormat(_instance); + + public List ValueNames => GetValueNames(_instance); + + [UnsafeAccessor(UnsafeAccessorKind.Constructor)] + private static extern LogValuesFormatter Create(string format); + + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = nameof(LogValuesFormatter._format))] + private static extern ref CompositeFormat GetFormat(LogValuesFormatter instance); + + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = nameof(LogValuesFormatter._valueNames))] + private static extern ref List GetValueNames(LogValuesFormatter instance); +} diff --git a/src/Program.cs b/src/Program.cs index c93d2ea..ec885ca 100755 --- a/src/Program.cs +++ b/src/Program.cs @@ -90,4 +90,13 @@ ConsoleApp.Create().Run(args); internal static partial class Program { + public static Regex FILEID_MATCHER => field ??= FileIdMatcherRegex(); + + public static Regex ID_MATCHER => field ??= IdMatcherRegex(); + + [GeneratedRegex(@"\[(?[^\[]+)\](?=\..+$)")] + private static partial Regex FileIdMatcherRegex(); + + [GeneratedRegex("^(?.+?) (?.+?)$")] + private static partial Regex IdMatcherRegex(); } diff --git a/src/System/Collections/Generic/PathExtensions.cs b/src/System/Collections/Generic/PathExtensions.cs new file mode 100644 index 0000000..9da4d81 --- /dev/null +++ b/src/System/Collections/Generic/PathExtensions.cs @@ -0,0 +1,46 @@ +namespace System.Collections.Generic; + +public static class PathExtensions +{ + public static bool AnyStartsWith(this SortedSet files, ReadOnlySpan prefix, StringComparison comparison) + { + using var enumerator = files.GetEnumerator(); + return AnyStartsWith(enumerator, prefix, comparison); + } + + public static int RemovePrefix(this SortedSet files, ReadOnlySpan prefix, StringComparison comparison) + { + List matches = []; + foreach (var file in files) + { + if (file.StartsWith(prefix, comparison)) + { + matches.Add(file); + } + } + + int actuallyRemoved = 0; + for (int i = matches.Count - 1; i >= 0; i--) + { + if (files.Remove(matches[i])) + { + actuallyRemoved++; + } + } + + return actuallyRemoved; + } + + private static bool AnyStartsWith(in T files, ReadOnlySpan prefix, StringComparison comparison) where T : IEnumerator + { + while (files.MoveNext()) + { + if (files.Current.StartsWith(prefix, comparison)) + { + return true; + } + } + + return false; + } +} diff --git a/src/System/IO/StringExtensions.cs b/src/System/IO/StringExtensions.cs new file mode 100644 index 0000000..fbe5db3 --- /dev/null +++ b/src/System/IO/StringExtensions.cs @@ -0,0 +1,21 @@ +namespace System.IO; + +public static partial class StringExtensions +{ + public static string NormalizePath(in this ReadOnlyMemory path) + { + return string.Create(path.Length, path, (c, path) => + { + var writer = c.GetEnumerator(); + foreach (ref readonly var s in path.Span) + { + writer.MoveNext(); + ref var t = ref writer.Current; + if ((t = s) == Path.DirectorySeparatorChar) + { + t = Path.AltDirectorySeparatorChar; + } + } + }); + } +} diff --git a/src/media-organizer.csproj b/src/media-organizer.csproj index 3e8214b..5555ba7 100755 --- a/src/media-organizer.csproj +++ b/src/media-organizer.csproj @@ -19,8 +19,13 @@ partial + + Unsafe + + + @@ -28,6 +33,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + runtime; build; native; contentfiles; analyzers; buildtransitive + all +