Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 111 additions & 52 deletions HISTORY.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions PlexCleaner/FfMpegBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public GlobalOptions Default() =>

public GlobalOptions NoStats() => Add("-nostats");

public GlobalOptions Progress() => Add("-progress").Add("pipe:1");

public GlobalOptions ExitOnError() => Add("-xerror");

public GlobalOptions AbortOn() => Add("-abort_on");
Expand Down
55 changes: 49 additions & 6 deletions PlexCleaner/FfMpegTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,45 @@ out string outputMap
outputMap = outputMap.Trim();
}

// Parse an ffmpeg -progress line to a fraction, or null. out_time_us and out_time_ms are microseconds.
internal static double? ParseProgressFraction(string line, long durationUs)
{
int separator = line.IndexOf('=');
if (separator <= 0)
{
return null;
}
string value = line[(separator + 1)..];
return line[..separator] switch
{
"progress" when value == "end" => 1.0,
"out_time_us"
or "out_time_ms"
when durationUs > 0
&& long.TryParse(value, CultureInfo.InvariantCulture, out long microseconds)
&& microseconds > 0 => (double)microseconds / durationUs,
_ => null,
};
}

private bool ExecuteEncodeWithProgress(Command command, string inputName)
{
Metrics.FileSink? sink = Metrics.CurrentFileSink;
return ExecuteStreamStdOut(
command,
line =>
{
double? fraction = ParseProgressFraction(line, sink?.DurationUs ?? 0);
if (fraction.HasValue)
{
Metrics.ReportFileFraction(sink, fraction.Value);
}
},
out int exitCode,
out string standardError
) && (exitCode == 0 || LogFailedResult(exitCode, standardError, inputName));
}

public bool ConvertToMkv(
string inputName,
SelectMediaProps? selectMediaProps,
Expand All @@ -312,7 +351,10 @@ string outputName
// Build command line
Command command = GetBuilder()
.GlobalOptions(options =>
options.Default().Add(Program.Config.ConvertOptions.FfMpegOptions.Global)
options
.Default()
.Progress()
.Add(Program.Config.ConvertOptions.FfMpegOptions.Global)
)
.InputOptions(options => options.Default().TestSnippets().InputFile(inputName))
.OutputOptions(options =>
Expand All @@ -326,8 +368,7 @@ string outputName
.Build();

// Execute command
return Execute(command, true, true, out BufferedCommandResult result)
&& (result.ExitCode == 0 || LogFailedResult(result, inputName));
return ExecuteEncodeWithProgress(command, inputName);
}

public bool ConvertToMkv(string inputName, string outputName)
Expand All @@ -338,7 +379,10 @@ public bool ConvertToMkv(string inputName, string outputName)
// Build command line
Command command = GetBuilder()
.GlobalOptions(options =>
options.Default().Add(Program.Config.ConvertOptions.FfMpegOptions.Global)
options
.Default()
.Progress()
.Add(Program.Config.ConvertOptions.FfMpegOptions.Global)
)
.InputOptions(options => options.Default().TestSnippets().InputFile(inputName))
.OutputOptions(options =>
Expand All @@ -354,8 +398,7 @@ public bool ConvertToMkv(string inputName, string outputName)
.Build();

// Execute command
return Execute(command, true, true, out BufferedCommandResult result)
&& (result.ExitCode == 0 || LogFailedResult(result, inputName));
return ExecuteEncodeWithProgress(command, inputName);
}

public bool SetTimestamps(string inputName, string outputName)
Expand Down
2 changes: 2 additions & 0 deletions PlexCleaner/HandBrakeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public class GlobalOptions(ArgumentsBuilder argumentsBuilder)
// TODO: Consolidate
public GlobalOptions Default() => this;

public GlobalOptions Json() => Add("--json");

public GlobalOptions Add(string option) => Add(option, false);

public GlobalOptions Add(string option, bool escape)
Expand Down
36 changes: 33 additions & 3 deletions PlexCleaner/HandBrakeTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,24 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo)
return true;
}

// Parse a HandBrake --json line to a fraction, or null. Progress is 0..1 across scan, work, and mux.
internal static double? ParseProgressFraction(string line)
{
Match match = ProgressRegex().Match(line);
return
match.Success
&& double.TryParse(
match.Groups[1].Value,
System.Globalization.CultureInfo.InvariantCulture,
out double progress
)
? progress
: null;
}

[GeneratedRegex("\"Progress\":\\s*([0-9.]+)")]
private static partial Regex ProgressRegex();

public bool ConvertToMkv(
string inputName,
string outputName,
Expand All @@ -103,7 +121,7 @@ bool deInterlace

// Build command line
Command command = GetBuilder()
.GlobalOptions(options => options.Default())
.GlobalOptions(options => options.Default().Json())
.InputOptions(options => options.InputFile(inputName).TestSnippets())
.OutputOptions(options =>
options
Expand All @@ -122,8 +140,20 @@ bool deInterlace
.Build();

// Execute command
return Execute(command, true, true, out BufferedCommandResult result)
&& (result.ExitCode == 0 || LogFailedResult(result, inputName));
Metrics.FileSink? sink = Metrics.CurrentFileSink;
return ExecuteStreamStdOut(
command,
line =>
{
double? fraction = ParseProgressFraction(line);
if (fraction.HasValue)
{
Metrics.ReportFileFraction(sink, fraction.Value);
}
},
out int exitCode,
out string standardError
) && (exitCode == 0 || LogFailedResult(exitCode, standardError, inputName));
}

[GeneratedRegex(
Expand Down
108 changes: 108 additions & 0 deletions PlexCleaner/MediaTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,39 @@ protected bool LogFailedResult(
return false;
}

// Overload for the streaming exec paths, which return the captured stderr directly.
protected bool LogFailedResult(
int exitCode,
string errorOutput,
string fileName,
[CallerMemberName] string operation = ""
)
{
string summary = CleanForLog(Summarize(errorOutput.Trim()));
if (string.IsNullOrEmpty(summary))
{
Log.Error(
"Failed execution of {ToolType} : {Operation:l} : ExitCode: {ExitCode} : {FileName}",
GetToolType(),
operation,
exitCode,
fileName
);
}
else
{
Log.Error(
"Failed execution of {ToolType} : {Operation:l} : ExitCode: {ExitCode} : {Error} : {FileName}",
GetToolType(),
operation,
exitCode,
summary,
fileName
);
}
return false;
}

// Join lines with " | " and drop other control characters so multi-line tool output stays a single structured log value; printable Unicode (e.g. media titles) is preserved
protected static string CleanForLog(string text)
{
Expand Down Expand Up @@ -304,6 +337,81 @@ public bool ExecuteStreamStdErr(
}
}

public bool ExecuteStreamStdOut(
Command command,
Action<string> lineAction,
out int exitCode,
out string standardError,
[CallerMemberName] string operation = ""
)
{
exitCode = -1;
standardError = string.Empty;
int processId = -1;
long startTimestamp = Stopwatch.GetTimestamp();
try
{
// Stream stdout line by line to the caller (progress output), summarize stderr for logging
PipeTarget stdOutTarget = PipeTarget.Create(
async (stream, cancellationToken) =>
{
using StreamReader reader = new(stream, Encoding.Default, false, 1024, true);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
lineAction(line);
}
}
);
StringBuilder stdErrBuilder = new();
PipeTarget stdErrTarget = ToStringSummary(stdErrBuilder);

CommandTask<CommandResult> task = command
.WithStandardOutputPipe(stdOutTarget)
.WithStandardErrorPipe(stdErrTarget)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(CancellationToken.None, Program.CancelToken());
processId = task.ProcessId;
Log.Debug(
"Executing {ToolType} : {Operation:l} : ProcessId: {ProcessId}, Arguments: {Arguments}",
GetToolType(),
operation,
processId,
command.Arguments
);

CommandResult commandResult = task.Task.GetAwaiter().GetResult();
exitCode = commandResult.ExitCode;
standardError = stdErrBuilder.ToString();
return task.Task.IsCompletedSuccessfully;
}
catch (OperationCanceledException)
{
Log.Error(
"Cancelled execution of {ToolType} : {Operation:l} : ProcessId: {ProcessId}, Arguments: {Arguments}",
GetToolType(),
operation,
processId,
command.Arguments
);
return false;
}
catch (Exception e) when (Log.Logger.LogAndHandle(e))
{
return false;
}
finally
{
Metrics.RecordToolDuration(
GetToolType(),
Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds
);
}
}

public static PipeTarget ToStringBuilder(StringBuilder stringBuilder) =>
PipeTarget.Create(
async (stream, cancellationToken) =>
Expand Down
Loading