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
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
193 changes: 193 additions & 0 deletions PlexCleaner/Metrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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()))),
];

// Each tool with its pre-built tag, so RecordToolDuration allocates nothing per invocation.
private static readonly Dictionary<
MediaTool.ToolType,
KeyValuePair<string, object?>
> s_toolTags = Enum.GetValues<MediaTool.ToolType>()
.ToDictionary(
tool => tool,
tool => new KeyValuePair<string, object?>("tool", tool.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.

internal static void RecordToolDuration(MediaTool.ToolType tool, double milliseconds) =>
s_toolDuration.Record(milliseconds, s_toolTags[tool]);

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;
}
}
}
}
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