Import existing commands
This commit is contained in:
parent
692c50c377
commit
af2d4d6139
10 changed files with 460 additions and 0 deletions
3
src/Commands/.editorconfig
Executable file
3
src/Commands/.editorconfig
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
[*.{cs,vb}]
|
||||
dotnet_diagnostic.CA1822.severity = none
|
||||
dotnet_diagnostic.IDE0060.severity = none
|
||||
98
src/Commands/Library/ArrangeCommand.cs
Executable file
98
src/Commands/Library/ArrangeCommand.cs
Executable 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;
|
||||
}
|
||||
}
|
||||
119
src/Commands/Prepare/ArchiveCommand.cs
Executable file
119
src/Commands/Prepare/ArchiveCommand.cs
Executable 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Commands/Prepare/EpisodeFormat.cs
Normal file
20
src/Commands/Prepare/EpisodeFormat.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
113
src/Commands/Prepare/MapCommand.cs
Executable file
113
src/Commands/Prepare/MapCommand.cs
Executable 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)];
|
||||
}
|
||||
}
|
||||
22
src/Microsoft/Extensions/Logging/LogValuesFormat.cs
Normal file
22
src/Microsoft/Extensions/Logging/LogValuesFormat.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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(@"\[(?<id>[^\[]+)\](?=\..+$)")]
|
||||
private static partial Regex FileIdMatcherRegex();
|
||||
|
||||
[GeneratedRegex("^(?<parser>.+?) (?<id>.+?)$")]
|
||||
private static partial Regex IdMatcherRegex();
|
||||
}
|
||||
|
|
|
|||
46
src/System/Collections/Generic/PathExtensions.cs
Normal file
46
src/System/Collections/Generic/PathExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
21
src/System/IO/StringExtensions.cs
Normal file
21
src/System/IO/StringExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -19,8 +19,13 @@
|
|||
<TrimMode>partial</TrimMode>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<PublicizerRuntimeStrategies>Unsafe</PublicizerRuntimeStrategies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Properties\appsettings.json" />
|
||||
<Publicize Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -28,6 +33,10 @@
|
|||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</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.Binder" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue