1
0
Fork 0

Add Inspection command

This commit is contained in:
Jöran Malek 2025-10-26 18:10:45 +01:00
parent af2d4d6139
commit 3a48988dfb
12 changed files with 456 additions and 0 deletions

View file

@ -0,0 +1,79 @@
using System.CodeDom.Compiler;
using System.Text;
using ConsoleAppFramework;
using MediaOrganizer.Tasks;
using MediaOrganizer.Tasks.Inspection;
namespace MediaOrganizer.Commands.Source;
[RegisterCommands("source")]
internal class InspectCommand
{
/// <summary>Provides detailed information about a media source.</summary>
/// <param name="sourceFile">Path to DVD image (.iso), BluRay folder (parent of BDMV), FFmpeg bluray-protocol (bluray:), or BluRay Movie Playlist File (mpls).</param>
/// <param name="index">-p|--playlist|-t|--title, Specify Title, or Playlist within a DVD image, or BluRay folder.</param>
[Command("inspect")]
public async Task Execute(
string sourceFile,
int? index = null)
{
IInspector inspector = InspectorFactory.CreateInspector(sourceFile);
if (index is not null)
{
inspector.Index = index;
}
var result = await inspector.RunAsync();
using IndentedTextWriter writer = new(Console.Out);
writer.WriteLine($"Inspection Result for {inspector.Source}:");
foreach (var track in result.Tracks)
{
writer.WriteLine(TrackIndexFormat, track.Index);
writer.Indent += 1;
writer.WriteLine(DurationFormat, track.Duration);
writer.WriteLine(FPSFormat, track.FPS);
foreach (var audio in track.Audio)
{
writer.WriteLine(AudioTrackFormat, audio.Index);
writer.Indent += 1;
writer.WriteLine(AudioLanguageFormat, audio.Language, audio.LangCode);
writer.WriteLine(AudioCodecFormat, audio.Codec);
writer.Indent -= 1;
}
writer.Indent -= 1;
}
}
/// <summary>
/// Track {0}
/// </summary>
private static CompositeFormat TrackIndexFormat => field ??= CompositeFormat.Parse("Track {0}");
/// <summary>
/// Resolution: {0}x{1}
/// </summary>
private static CompositeFormat ResolutionFormat => field ??= CompositeFormat.Parse("Resolution: {0}x{1}");
/// <summary>
/// Duration: {0}
/// </summary>
private static CompositeFormat DurationFormat => field ??= CompositeFormat.Parse("Duration: {0}");
/// <summary>
/// FPSFormat: {0}
/// </summary>
private static CompositeFormat FPSFormat => field ??= CompositeFormat.Parse("FPS: {0}");
/// <summary>
/// Audio Track {0}
/// </summary>
private static CompositeFormat AudioTrackFormat => field ??= CompositeFormat.Parse("Audio Track {0}");
/// <summary>
/// Language: {0} ({1})
/// </summary>
private static CompositeFormat AudioLanguageFormat => field ??= CompositeFormat.Parse("Language: {0} ({1})");
/// <summary>
/// Codec: {0}
/// </summary>
private static CompositeFormat AudioCodecFormat => field ??= CompositeFormat.Parse("Codec: {0}");
}

View file

@ -0,0 +1,26 @@
using System.Text;
namespace System.IO;
public static class TextWriterCompositeFormat
{
public static void WriteLine(this TextWriter writer, CompositeFormat format, params object?[] args)
{
WriteLine(writer, null, format, args);
}
public static void WriteLine(this TextWriter writer, IFormatProvider? provider, CompositeFormat format, params object?[] args)
{
writer.WriteLine(string.Format(provider, format, args));
}
public static Task WriteLineAsync(this TextWriter writer, CompositeFormat format, params object?[] args)
{
return WriteLineAsync(writer, null, format, args);
}
public static Task WriteLineAsync(this TextWriter writer, IFormatProvider? provider, CompositeFormat format, params object?[] args)
{
return writer.WriteLineAsync(string.Format(provider, format, args));
}
}

View file

@ -0,0 +1,11 @@
namespace MediaOrganizer.Tasks.Inspection;
public class BluRayInspector(string path) : IInspector
{
public int? Index { get; set; }
public string Source => path;
public ValueTask<InspectionResult> RunAsync() => ValueTask.FromResult(default(InspectionResult)!);
}

View file

@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
namespace MediaOrganizer.Tasks.Inspection;
public interface IInspector
{
[DisallowNull]
int? Index { get; set; }
string Source { get; }
ValueTask<InspectionResult> RunAsync();
}

View file

@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace MediaOrganizer.Tasks.Inspection;
public partial record class InspectionResult
{
public required IReadOnlyList<InspectionTrack> Tracks { get; set; }
[JsonSourceGenerationOptions(
GenerationMode = JsonSourceGenerationMode.Metadata
)]
[JsonSerializable(typeof(InspectionResult))]
internal partial class InspectionResultSerializerContext : JsonSerializerContext;
}

View file

@ -0,0 +1,16 @@
namespace MediaOrganizer.Tasks.Inspection;
public sealed record class InspectionTrack
{
public int Index { get; set; }
public TimeSpan Duration { get; set; }
public double? FPS { get; set; }
public required IReadOnlyList<TrackCell> Playlist { get; set; }
public required IReadOnlyList<TrackAudio> Audio { get; set; }
public required IReadOnlyList<TrackSubtitle> Subtitles { get; set; }
}

View file

@ -0,0 +1,83 @@
using MediaOrganizer.Tools;
using MediaOrganizer.Tools.Types.LsDvd;
namespace MediaOrganizer.Tasks.Inspection;
public class LsDvdInspector(string path) : IInspector
{
public int? Index { get; set; }
public string Source => path;
public async ValueTask<InspectionResult> RunAsync()
{
LsDvd lsDvd = new(path)
{
Title = Index
};
return ToResult(await lsDvd.RunAsync());
}
private static List<TrackAudio> ToAudio(List<Audio> audio)
{
List<TrackAudio> result = [];
foreach (var item in audio)
{
result.Add(new()
{
Index = item.Index ?? -1,
Codec = item.Format ?? string.Empty,
LangCode = item.LangCode ?? string.Empty,
Language = item.Language ?? string.Empty,
Channels = item.Channels ?? -1
});
}
return result;
}
private static List<TrackSubtitle> ToSubtitles(List<Subtitle> subtitles)
{
List<TrackSubtitle> result = [];
foreach (var item in subtitles)
{
result.Add(new()
{
LangCode = item.LangCode ?? string.Empty,
Language = item.Language ?? string.Empty
});
}
return result;
}
private static List<InspectionTrack> ToTracks(List<Track> tracks)
{
List<InspectionTrack> result = [];
foreach (var item in tracks)
{
result.Add(new()
{
Index = item.Index ?? -1,
Duration = ToTimespan(item.Length),
FPS = item.FPS,
Audio = ToAudio(item.Audio),
Subtitles = ToSubtitles(item.Subtitles),
Playlist = [],
});
}
return result;
static TimeSpan ToTimespan(double? length) => length.HasValue ? TimeSpan.FromSeconds(length.Value) : TimeSpan.Zero;
}
private InspectionResult ToResult(LsDvdResult result)
{
return new()
{
Tracks = ToTracks(result.Tracks)
};
}
}

View file

@ -0,0 +1,14 @@
namespace MediaOrganizer.Tasks.Inspection;
public sealed record class TrackAudio
{
public int Index { get; set; }
public required string LangCode { get; set; }
public required string Language { get; set; }
public required string Codec { get; set; }
public int Channels { get; set; }
}

View file

@ -0,0 +1,6 @@
namespace MediaOrganizer.Tasks.Inspection;
public sealed record class TrackCell
{
public TimeSpan Length { get; set; }
}

View file

@ -0,0 +1,8 @@
namespace MediaOrganizer.Tasks.Inspection;
public sealed record class TrackSubtitle
{
public required string LangCode { get; set; }
public required string Language { get; set; }
}

View file

@ -0,0 +1,54 @@
using MediaOrganizer.Tasks.Inspection;
namespace MediaOrganizer.Tasks;
public class InspectorFactory
{
private static ReadOnlySpan<char> BluRayProtocol => "bluray:";
private static ReadOnlySpan<char> IsoExtension => ".iso";
private static ReadOnlySpan<char> MplsExtension => ".mpls";
public static IInspector CreateInspector(string path)
{
if (path.StartsWith(BluRayProtocol, StringComparison.OrdinalIgnoreCase))
{
return FindBluRayInspector(path[BluRayProtocol.Length..]);
}
if (!Path.Exists(path))
{
throw new FileNotFoundException(null, path);
}
if ((File.GetAttributes(path) & FileAttributes.Directory) != 0)
{
return FindBluRayInspector(path[BluRayProtocol.Length..]);
}
if (path.EndsWith(IsoExtension, StringComparison.OrdinalIgnoreCase))
{
return new LsDvdInspector(path);
}
else if (path.EndsWith(MplsExtension, StringComparison.OrdinalIgnoreCase))
{
throw new NotImplementedException();
}
throw new ArgumentException(null, nameof(path));
static IInspector FindBluRayInspector(string path)
{
if (!Directory.Exists(path))
{
throw new FileNotFoundException(path);
}
if (!Directory.Exists(Path.Join(path, "BDMV")))
{
throw new FileNotFoundException("Specified directory doesn't contain BDMV folder", path);
}
throw new NotImplementedException();
}
}
}

132
src/Tools/LsDvd.cs Executable file
View file

@ -0,0 +1,132 @@
using System.Buffers;
using System.Diagnostics;
using System.IO.Pipelines;
using System.Text;
using System.Xml;
using System.Xml.Serialization;
using MediaOrganizer.Tools.Types.LsDvd;
using Microsoft.Xml.Serialization.GeneratedAssembly;
namespace MediaOrganizer.Tools;
public sealed class LsDvd(string path)
{
private static ReadOnlySpan<byte> XmlDeclaration => "<?xml"u8;
public int? Title { get; init; }
public async ValueTask<LsDvdResult> RunAsync()
{
StringBuilder builder = new("-q -Ox -x ");
if (Title is not null)
{
builder.Append("-t ");
builder.Append(Title);
builder.Append(' ');
}
builder.Append('"');
builder.Append(path);
builder.Append('"');
ProcessStartInfo processStartInfo = new("lsdvd")
{
Arguments = builder.ToString(),
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
using Process process = new()
{
StartInfo = processStartInfo
};
StringBuilder errorText = new();
process.ErrorDataReceived += (s, e) => errorText.AppendLine(e.Data);
LsDvdResult result;
bool error;
try
{
process.Start();
PipeReader streamReader = PipeReader.Create(process.StandardOutput.BaseStream);
process.BeginErrorReadLine();
if (!await ReadToXml(streamReader))
{
throw new XmlException();
}
await using (var serializerStream = streamReader.AsStream())
{
#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
XmlSerializer serializer = new LsDvdResultSerializer();
result = (LsDvdResult)serializer.Deserialize(serializerStream)!;
#pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
}
await process.WaitForExitAsync().ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
finally
{
if (error = !process.HasExited)
{
try
{
process.Kill(true);
}
catch { }
}
}
return error ? throw new Exception(errorText.ToString()) : result;
static async ValueTask<bool> ReadToXml(PipeReader streamReader)
{
bool xmlDeclaration = false;
for (ReadResult read = default; !(xmlDeclaration || read.IsCompleted);)
{
read = await streamReader.ReadAsync();
SequencePosition
consumed = read.Buffer.Start,
examined = read.Buffer.Start;
SequenceReader<byte> reader = new(read.Buffer);
while (!(xmlDeclaration = IsXmlDeclaration(reader, consumed, examined)) && reader.TryRead(out var symbol))
{
if (symbol == '\r' || symbol == '\n')
{
consumed = reader.Position;
}
examined = reader.Position;
}
streamReader.AdvanceTo(consumed, examined);
}
return xmlDeclaration;
static bool IsXmlDeclaration(in SequenceReader<byte> reader, SequencePosition left, SequencePosition right)
{
if (XmlDeclaration.Length > reader.Remaining)
{
goto exit;
}
if (!left.Equals(right))
{
goto exit;
}
return reader.IsNext(XmlDeclaration, false);
exit:
return false;
}
}
}
}