Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 8 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ All external process execution uses [CliWrap](https://github.com/Tyrrrz/CliWrap)
- See `MediaTool.cs` for base execution patterns
- All tool execution supports cancellation via `Program.CancelToken()`

### Runtime Metrics

`Metrics.cs` owns a single `System.Diagnostics.Metrics.Meter` (`PlexCleaner.Process`) published for the whole process and read externally with `dotnet-counters` (no config flag, and instruments are inert until observed).

- Hooks: `ProcessDriver.ProcessFiles` (the choke point every command and monitor cycle funnels through) drives the file/byte/in-flight instruments and the byte-weighted `progress.ratio`. `Process.ProcessFiles` records the per-`SidecarFile.StatesType` outcomes. `MediaTool` execution paths record `tool.duration`.
- Progress is weighted by input bytes (summed once up front and credited at completion from the same map), not file count.
- Run-scoped gauges (totals, in-flight, progress, ETA) reset per `ProcessFiles` call, while the counters stay cumulative for the process. All writers use `Interlocked`, so the parallel loop needs no lock, and observable-gauge callbacks only read. Tags are bounded (`state`, `tool`) - no filename tags.

### Sidecar File System

Critical performance feature - DO NOT break compatibility:
Expand Down
3 changes: 3 additions & 0 deletions Docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ COPY --chmod=ug=rwx,o=rx ./Docker/InstallDebugTools.sh ./
RUN ./InstallDebugTools.sh \
&& rm -rf ./InstallDebugTools.sh

# Wrapper to read the PlexCleaner.Process metrics: "docker exec <container> counters"
COPY --chmod=ug=rwx,o=rx ./Docker/counters.sh /usr/local/bin/counters

# Copy version script
COPY --chmod=ug=rwx,o=rx ./Docker/Version.sh /PlexCleaner/

Expand Down
17 changes: 17 additions & 0 deletions Docker/counters.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/bash

# Run dotnet-counters against the in-container PlexCleaner process.
# The single-file tool needs a writable extract dir, and the app is PID 1 in the container.
# Usage from the host:
# docker exec <container> counters # live monitor of the PlexCleaner.Process meter
# docker exec <container> counters collect ... # any other dotnet-counters verb/args, passed through

set -euo pipefail

export DOTNET_BUNDLE_EXTRACT_BASE_DIR="${DOTNET_BUNDLE_EXTRACT_BASE_DIR:-/tmp}"

if [[ $# -eq 0 ]]; then
set -- monitor -p 1 --counters PlexCleaner.Process
fi

exec /dotnet-tools/dotnet-counters "$@"
7 changes: 7 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc.

## Release History

- Version 3.22:
- Added always-on runtime metrics published via `System.Diagnostics.Metrics` under the `PlexCleaner.Process` meter, readable with `dotnet-counters` with no extra infrastructure.
- Overall progress is weighted by input bytes rather than file count, so a run mixing tiny and huge files reports the actual work completed. In this first version an in-flight file contributes no partial credit until it finishes.
- Instruments include the run file and byte totals, in-flight and active-thread counts, completed bytes, the weighted `progress.ratio` and `eta.seconds`, cumulative per-outcome counters (completed, modified, errors, verify-failed, and a per-`State`-flag tally), and the `file.duration` and per-tool `tool.duration` histograms. Metrics are aggregate only, with bounded `state` and `tool` tags and no filename tags.
- The run-scoped gauges reset at the start of every processing pass, so monitor mode and back-to-back commands each report their own run, while the counters stay cumulative for rate display.
- Instruments are inert until a listener observes them, so the feature is always on with no configuration flag and negligible idle overhead.
- The Docker image ships a `counters` wrapper, so reading the metrics is `docker exec <container> counters`.
- Version 3.21:
- Repair non-monotonic DTS muxer warnings losslessly instead of failing repair permanently.
- `ffmpeg -f null` can exit `0` yet emit `Application provided invalid, non monotonically increasing dts to muxer` for files that may decode and play correctly.
Expand Down
9 changes: 9 additions & 0 deletions PlexCleaner/FfProbeTool.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
Expand Down Expand Up @@ -79,6 +80,7 @@ public bool GetPackets(
)
{
int processId = -1;
long startTimestamp = Stopwatch.GetTimestamp();
try
{
// Pipe target to deserialize JSON packets
Expand Down Expand Up @@ -194,6 +196,13 @@ public bool GetPackets(
{
return (false, string.Empty);
}
finally
{
Metrics.RecordToolDuration(
GetToolType(),
Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds
);
}
}

public bool GetClosedCaptions(string fileName, out bool hasClosedCaptions)
Expand Down
17 changes: 17 additions & 0 deletions PlexCleaner/MediaTool.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
Expand Down Expand Up @@ -173,6 +174,7 @@ public bool Execute(
{
bufferedCommandResult = null!;
int processId = -1;
long startTimestamp = Stopwatch.GetTimestamp();
try
{
StringBuilder stdOutBuilder = new();
Expand Down Expand Up @@ -223,6 +225,13 @@ public bool Execute(
{
return false;
}
finally
{
Metrics.RecordToolDuration(
GetToolType(),
Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds
);
}
}

public bool ExecuteStreamStdErr(
Expand All @@ -234,6 +243,7 @@ public bool ExecuteStreamStdErr(
{
exitCode = -1;
int processId = -1;
long startTimestamp = Stopwatch.GetTimestamp();
try
{
// Stream stderr line by line to the caller instead of buffering it
Expand Down Expand Up @@ -285,6 +295,13 @@ public bool ExecuteStreamStdErr(
{
return false;
}
finally
{
Metrics.RecordToolDuration(
GetToolType(),
Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds
);
}
}

public static PipeTarget ToStringBuilder(StringBuilder stringBuilder) =>
Expand Down
186 changes: 186 additions & 0 deletions PlexCleaner/Metrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;

namespace PlexCleaner;

internal static class Metrics
{
private static readonly Meter s_meter = new("PlexCleaner.Process");

// Cumulative for the process lifetime, not reset between runs.
private static readonly Counter<long> s_filesCompleted = s_meter.CreateCounter<long>(
"plexcleaner.files.completed",
description: "Files finished, any outcome"
);
private static readonly Counter<long> s_filesModified = s_meter.CreateCounter<long>(
"plexcleaner.files.modified",
description: "Files whose media was changed"
);
private static readonly Counter<long> s_filesErrors = s_meter.CreateCounter<long>(
"plexcleaner.files.errors",
description: "Files that errored"
);
private static readonly Counter<long> s_filesVerifyFailed = s_meter.CreateCounter<long>(
"plexcleaner.files.verifyfailed",
description: "Files that failed verification"
);
private static readonly Counter<long> s_filesProcessed = s_meter.CreateCounter<long>(
"plexcleaner.files.processed",
description: "Per-outcome tally, tagged by each State flag set"
);

private static readonly Histogram<double> s_fileDuration = s_meter.CreateHistogram<double>(
"plexcleaner.file.duration",
unit: "ms",
description: "Per-file wall-clock time"
);
private static readonly Histogram<double> s_toolDuration = s_meter.CreateHistogram<double>(
"plexcleaner.tool.duration",
unit: "ms",
description: "Per media-tool invocation time, tagged by tool"
);

// Run-scoped state, reset by BeginRun, read by the observable gauges.
// All access is via Interlocked so the parallel loop needs no lock.
private static long s_runFilesTotal;
private static long s_runBytesTotal;
private static long s_runBytesCompleted;
private static long s_runInflight;
private static long s_runStartTimestamp;

// Each State flag (minus None) with its tag, pre-built once so RecordStates allocates nothing per file.
private static readonly (
SidecarFile.StatesType Flag,
KeyValuePair<string, object?> Tag
)[] s_stateTags =
[
.. Enum.GetValues<SidecarFile.StatesType>()
.Where(flag => flag != SidecarFile.StatesType.None)
.Select(flag => (flag, new KeyValuePair<string, object?>("state", flag.ToString()))),
];

static Metrics()
{
_ = s_meter.CreateObservableGauge(
"plexcleaner.files.total",
() => Interlocked.Read(ref s_runFilesTotal),
description: "Files in the current run"
);
_ = s_meter.CreateObservableGauge(
"plexcleaner.files.inflight",
() => Interlocked.Read(ref s_runInflight),
description: "Files currently processing"
);
_ = s_meter.CreateObservableGauge(
"plexcleaner.threads.active",
() => (long)(Program.Options?.ThreadCount ?? 0),
description: "Configured worker threads"
);
_ = s_meter.CreateObservableGauge(
"plexcleaner.bytes.total",
() => Interlocked.Read(ref s_runBytesTotal),
unit: "By",
description: "Sum of input sizes in the current run"
);
_ = s_meter.CreateObservableGauge(
"plexcleaner.bytes.completed",
() => Interlocked.Read(ref s_runBytesCompleted),
unit: "By",
description: "Bytes of finished files (no partial credit for in-flight files)"
);
_ = s_meter.CreateObservableGauge(
"plexcleaner.progress.ratio",
ComputeProgress,
description: "Byte-weighted overall progress [0..1]"
);
_ = s_meter.CreateObservableGauge(
"plexcleaner.eta.seconds",
ComputeEtaSeconds,
unit: "s",
description: "Estimated time remaining"
);
}

// Reset the run-scoped gauges and ETA clock, called once per ProcessFiles run.
internal static void BeginRun(long totalFiles, long totalBytes)
{
_ = Interlocked.Exchange(ref s_runFilesTotal, totalFiles);
_ = Interlocked.Exchange(ref s_runBytesTotal, totalBytes);
_ = Interlocked.Exchange(ref s_runBytesCompleted, 0);
_ = Interlocked.Exchange(ref s_runInflight, 0);
_ = Interlocked.Exchange(ref s_runStartTimestamp, Stopwatch.GetTimestamp());
}

internal static void FileStarted() => Interlocked.Increment(ref s_runInflight);

internal static void FileInflightDone() => Interlocked.Decrement(ref s_runInflight);

// A finished file credits its whole size (no partial credit in v1) and its wall-clock time.
internal static void FileCompleted(long sizeBytes, TimeSpan wall)
{
_ = Interlocked.Add(ref s_runBytesCompleted, sizeBytes);
s_filesCompleted.Add(1);
s_fileDuration.Record(wall.TotalMilliseconds);
}

internal static void FileErrored() => s_filesErrors.Add(1);

internal static void RecordModified() => s_filesModified.Add(1);

internal static void RecordVerifyFailed() => s_filesVerifyFailed.Add(1);

internal static void RecordStates(SidecarFile.StatesType state)
{
foreach ((SidecarFile.StatesType flag, KeyValuePair<string, object?> tag) in s_stateTags)
{
if ((state & flag) == flag)
{
s_filesProcessed.Add(1, tag);
}
}
}
Comment thread
ptr727 marked this conversation as resolved.
Resolved

internal static void RecordToolDuration(MediaTool.ToolType tool, double milliseconds) =>
s_toolDuration.Record(
milliseconds,
new KeyValuePair<string, object?>("tool", tool.ToString())
);
Comment thread
ptr727 marked this conversation as resolved.
Resolved

internal static void Dispose() => s_meter.Dispose();

// Byte-weighted progress, guards a zero (or not-yet-started) total.
internal static double ComputeProgress()
{
long total = Interlocked.Read(ref s_runBytesTotal);
return total <= 0 ? 0.0 : (double)Interlocked.Read(ref s_runBytesCompleted) / total;
}

// Linear extrapolation from weighted progress and elapsed time.
// Returns 0 before any progress and never a non-finite value.
internal static double ComputeEtaSeconds()
{
double ratio = ComputeProgress();
if (ratio <= 0.0)
{
return 0.0;
}
double elapsed = Stopwatch
.GetElapsedTime(Interlocked.Read(ref s_runStartTimestamp))
.TotalSeconds;
double eta = elapsed * (1.0 - ratio) / ratio;
return double.IsFinite(eta) ? eta : 0.0;
}

internal static IEnumerable<SidecarFile.StatesType> EnumerateSetStates(
SidecarFile.StatesType state
)
{
foreach ((SidecarFile.StatesType flag, _) in s_stateTags)
{
if ((state & flag) == flag)
{
yield return flag;
}
}
}
Comment thread
ptr727 marked this conversation as resolved.
Resolved
}
11 changes: 11 additions & 0 deletions PlexCleaner/Process.cs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,17 @@ out string? failedOperation
return processResult;
}

// Per-outcome metrics: this is the only place the State flags are known
Metrics.RecordStates(state);
if (modified)
{
Metrics.RecordModified();
}
if ((state & SidecarFile.StatesType.VerifyFailed) != 0)
{
Metrics.RecordVerifyFailed();
}

// Save result
lock (resultLock)
{
Expand Down
Loading