1
0
Fork 0

Import existing commands

This commit is contained in:
Jöran Malek 2025-10-26 18:08:52 +01:00
parent 692c50c377
commit af2d4d6139
10 changed files with 460 additions and 0 deletions

3
src/Commands/.editorconfig Executable file
View file

@ -0,0 +1,3 @@
[*.{cs,vb}]
dotnet_diagnostic.CA1822.severity = none
dotnet_diagnostic.IDE0060.severity = none

View file

@ -0,0 +1,98 @@
using ConsoleAppFramework;
namespace MediaOrganizer.Commands.Library;
[RegisterCommands("library")]
internal class ArrangeCommand
{
/// <summary>
/// Arranges files next to mapFile into targetDirectory.
/// </summary>
/// <param name="mapFile">A file with "="-separated identifiers.</param>
/// <param name="targetDirectory">Target directory to store value-part of mapFile in.</param>
[Command("arrange")]
public async Task<int> 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;
}
}

View file

@ -0,0 +1,119 @@
using ConsoleAppFramework;
using Microsoft.Extensions.Logging;
namespace MediaOrganizer.Commands.Prepare;
[RegisterCommands("prepare")]
internal class ArchiveCommand
{
/// <summary>
/// Create a map file from a youtube-dl archive file.
/// </summary>
/// <param name="mapFile"></param>
/// <param name="archiveFile"></param>
/// <param name="episode"></param>
/// <param name="episodeNameFormat"></param>
[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<string> 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++;
}
}
}
}

View file

@ -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<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
return _episode.TryFormat(utf8Destination, out bytesWritten, format, provider);
}
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
return _episode.TryFormat(destination, out charsWritten, format, provider);
}
}

View file

@ -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
{
/// <summary>
/// Creates a mapFile, and applies a format onto all files.
/// </summary>
/// <param name="mapFile">Path to map file.</param>
/// <param name="include">Include pattern to include relative to mapFile directory.</param>
/// <param name="episode">Episode number to start with for new entries in mapFile.</param>
/// <param name="episodeNameFormat">Format to store mapping in mapFile. Possible options:
/// - Episode: integer
/// </param>
[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<string> 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<char> StripExtension(in ReadOnlySpan<char> path)
{
var fileName = Path.GetFileName(path);
int i;
if ((i = fileName.LastIndexOf('.')) == -1)
{
return path;
}
return path[..^(fileName.Length - i)];
}
}

View file

@ -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<string> 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<string> GetValueNames(LogValuesFormatter instance);
}

View file

@ -90,4 +90,13 @@ ConsoleApp.Create().Run(args);
internal static partial class Program internal static partial class Program
{ {
public static Regex FILEID_MATCHER => field ??= FileIdMatcherRegex();
public static Regex ID_MATCHER => field ??= IdMatcherRegex();
[GeneratedRegex(@"\[(?<id>[^\[]+)\](?=\..+$)")]
private static partial Regex FileIdMatcherRegex();
[GeneratedRegex("^(?<parser>.+?) (?<id>.+?)$")]
private static partial Regex IdMatcherRegex();
} }

View file

@ -0,0 +1,46 @@
namespace System.Collections.Generic;
public static class PathExtensions
{
public static bool AnyStartsWith(this SortedSet<string> files, ReadOnlySpan<char> prefix, StringComparison comparison)
{
using var enumerator = files.GetEnumerator();
return AnyStartsWith(enumerator, prefix, comparison);
}
public static int RemovePrefix(this SortedSet<string> files, ReadOnlySpan<char> prefix, StringComparison comparison)
{
List<string> 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<T>(in T files, ReadOnlySpan<char> prefix, StringComparison comparison) where T : IEnumerator<string>
{
while (files.MoveNext())
{
if (files.Current.StartsWith(prefix, comparison))
{
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,21 @@
namespace System.IO;
public static partial class StringExtensions
{
public static string NormalizePath(in this ReadOnlyMemory<char> 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;
}
}
});
}
}

View file

@ -19,8 +19,13 @@
<TrimMode>partial</TrimMode> <TrimMode>partial</TrimMode>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<PublicizerRuntimeStrategies>Unsafe</PublicizerRuntimeStrategies>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Properties\appsettings.json" /> <EmbeddedResource Include="Properties\appsettings.json" />
<Publicize Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -28,6 +33,10 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Krafs.Publicizer" Version="2.3.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />