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
+