diff --git a/HISTORY.md b/HISTORY.md
index 6f50665d..5a9feb97 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -4,6 +4,19 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc.
## Release History
+- 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.
+ - The previous "any stderr means failure" rule promoted this muxer-interleaving artifact to a hard `VerifyFailed`/`RepairFailed`, and a re-encode could not fix it because Matroska stores no DTS and ffmpeg re-derives a non-monotonic timeline on read.
+ - Verify now classifies the decode diagnostics deterministically as clean, a benign timestamp-only failure, or a decode error; the timestamp-only failure is correctable rather than permanent, and everything else fails (fail-closed, so an unrecognized diagnostic fails as a decode error).
+ - The classification streams the output line by line, so memory stays bounded even when a file emits a warning per packet ([#827](https://github.com/ptr727/PlexCleaner/issues/827)).
+ - Added a lossless timestamp repair as the first repair tier.
+ - When verification detects a demux-visible non-monotonic DTS, the audio packet timestamps are rewritten to be strictly monotonic using the `setts` bitstream filter with a stream copy (no re-encode), then re-verified.
+ - A regression gate compares the per-stream coded payload hash before and after and discards the result unless every stream is byte-identical, so the repair can never alter the media. The full re-encode repair remains for genuine decode corruption.
+ - Consolidated the bitrate and DTS packet analyses into a single `ffprobe -show_packets` pass, computing the per-second bitrate and the per-stream DTS monotonicity together instead of reading packets twice.
+ - Switched closed caption detection to `ffprobe -analyze_frames -show_entries stream=closed_captions`, replacing the `movie=...[out0+subcc]` lavfi filter and its QuickScan snippet-remux workaround; QuickScan now bounds the scan with `-read_intervals`.
+ - Added the `DtsTimestampRepair` example plugin.
+ - It revisits files that a previous version marked `RepairFailed`, re-verifies them, clears the flag when the only problem was timestamps, and losslessly repairs the timestamps when the DTS is demux-visible. Not available in AOT builds.
- Version 3.20:
- Switched tool downloads and the application version check to the resilient HTTP client in `ptr727.Utilities` (retry with backoff and a circuit breaker via `Microsoft.Extensions.Http.Resilience`), replacing the plain `HttpClient`.
- Enabled closed caption removal for H.265/HEVC video: the SEI NAL unit lookup keyed on `h265` never matched FFprobe's `hevc` codec name, so HEVC files were incorrectly reported as an "Unsupported video format for Closed Captions removal". HEVC video (excluding HDR10 and HDR10+ content, which remains guarded) is now cleaned using the `filter_units=remove_types=39` bitstream filter, same as H.264 and MPEG-2.
diff --git a/PlexCleaner.slnx b/PlexCleaner.slnx
index 223749ed..05df2d7c 100644
--- a/PlexCleaner.slnx
+++ b/PlexCleaner.slnx
@@ -77,6 +77,7 @@
+
diff --git a/PlexCleaner/DtsInfo.cs b/PlexCleaner/DtsInfo.cs
new file mode 100644
index 00000000..705a0e5e
--- /dev/null
+++ b/PlexCleaner/DtsInfo.cs
@@ -0,0 +1,34 @@
+namespace PlexCleaner;
+
+public sealed class DtsInfo
+{
+ // Last DTS seen per stream index
+ private readonly Dictionary _lastDts = [];
+
+ // Count of non-monotonic packets per stream index
+ private readonly Dictionary _nonMonotonicByStream = [];
+
+ // Stream indexes carrying a non-monotonic DTS, with the per-stream count
+ public IReadOnlyDictionary NonMonotonicByStream => _nonMonotonicByStream;
+
+ // True if any stream carries a non-monotonic DTS
+ public bool HasNonMonotonicDts => _nonMonotonicByStream.Count > 0;
+
+ public void Add(FfMpegToolJsonSchema.Packet packet)
+ {
+ // Fall back to PTS when DTS is absent, matching how the muxer derives DTS
+ double dts = !double.IsNaN(packet.DtsTime) ? packet.DtsTime : packet.PtsTime;
+ if (double.IsNaN(dts))
+ {
+ return;
+ }
+
+ // Flag a non-increasing DTS relative to the previous packet in the same stream
+ if (_lastDts.TryGetValue(packet.StreamIndex, out double previous) && dts <= previous)
+ {
+ _nonMonotonicByStream[packet.StreamIndex] =
+ _nonMonotonicByStream.GetValueOrDefault(packet.StreamIndex) + 1;
+ }
+ _lastDts[packet.StreamIndex] = dts;
+ }
+}
diff --git a/PlexCleaner/FfMpegBuilder.cs b/PlexCleaner/FfMpegBuilder.cs
index 1a42c134..b60c3f8b 100644
--- a/PlexCleaner/FfMpegBuilder.cs
+++ b/PlexCleaner/FfMpegBuilder.cs
@@ -182,6 +182,11 @@ public class OutputOptions(ArgumentsBuilder argumentsBuilder)
public OutputOptions BitstreamFilterVideo(string option) =>
BitstreamFilterVideo().Add(option);
+ public OutputOptions BitstreamFilterAudio() => Add("-bsf:a");
+
+ public OutputOptions BitstreamFilterAudio(string option) =>
+ BitstreamFilterAudio().Add(option);
+
public OutputOptions SeekStartStop(TimeSpan timeStart, TimeSpan timeStop) =>
timeStart == TimeSpan.Zero || timeStop == TimeSpan.Zero
? this
diff --git a/PlexCleaner/FfMpegTool.cs b/PlexCleaner/FfMpegTool.cs
index f14444e9..f0776e93 100644
--- a/PlexCleaner/FfMpegTool.cs
+++ b/PlexCleaner/FfMpegTool.cs
@@ -152,7 +152,7 @@ public override bool Update(string updateFile)
return true;
}
- public bool VerifyMedia(string fileName)
+ public VerifyResult VerifyMedia(string fileName)
{
// Build command line
Command command = GetBuilder()
@@ -162,12 +162,44 @@ public bool VerifyMedia(string fileName)
.OutputOptions(options => options.Default().NullOutput())
.Build();
- // Execute command; ffmpeg can exit 0 yet still report stream errors on stderr, treat any stderr as failure
- return Execute(command, true, true, out BufferedCommandResult result)
- && (
- (result.ExitCode == 0 && result.StandardError.Trim().Length == 0)
- || LogFailedResult(result)
- );
+ // Execute command: ffmpeg can exit 0 yet report stream errors on stderr
+ // Classify stderr line by line as it streams to keep memory bounded, e.g. non-monotonic-DTS file emits a warning per packet
+ VerifyClassifier.Accumulator classifier = new();
+ if (!ExecuteStreamStdErr(command, classifier.Add, out int exitCode))
+ {
+ // Process could not run
+ return VerifyResult.DecodeError;
+ }
+
+ // A non-zero exit is always a failure, fail closed even if stderr shows only the timestamp warning
+ VerifyResult verifyResult = classifier.Result;
+ if (exitCode != 0)
+ {
+ verifyResult = VerifyResult.DecodeError;
+ }
+ if (verifyResult == VerifyResult.DecodeError)
+ {
+ // A silent non-zero exit has no error line, omit the empty field rather than logging blank
+ string error = CleanForLog(classifier.FirstError ?? string.Empty);
+ if (string.IsNullOrEmpty(error))
+ {
+ Log.Error(
+ "Failed execution of {ToolType} : ExitCode: {ExitCode}",
+ GetToolType(),
+ exitCode
+ );
+ }
+ else
+ {
+ Log.Error(
+ "Failed execution of {ToolType} : ExitCode: {ExitCode} : {Error}",
+ GetToolType(),
+ exitCode,
+ error
+ );
+ }
+ }
+ return verifyResult;
}
public bool ReMuxToMkv(string inputName, string outputName) =>
@@ -320,6 +352,83 @@ public bool ConvertToMkv(string inputName, string outputName)
&& (result.ExitCode == 0 || LogFailedResult(result));
}
+ public bool SetTimestamps(string inputName, string outputName)
+ {
+ // Losslessly rewrite audio packet timestamps to be strictly monotonic using the setts bitstream filter
+ // https://ffmpeg.org/ffmpeg-bitstream-filters.html#setts
+ // Audio only, the expression forces PTS monotonic which is safe where PTS equals DTS, applying
+ // it to video would reorder B-frames, a video-only DTS break instead fails the caller's re-verify
+
+ // Delete output file
+ File.Delete(outputName);
+
+ // Build command line, the escaped comma separates the setts option arguments
+ Command command = GetBuilder()
+ .GlobalOptions(options => options.Default())
+ .InputOptions(options => options.Default().TestSnippets().InputFile(inputName))
+ .OutputOptions(options =>
+ options
+ .MapAllCodecCopy()
+ .BitstreamFilterAudio(
+ "\"setts=pts=max(PTS\\,PREV_OUTPTS+1):dts=max(DTS\\,PREV_OUTDTS+1)\""
+ )
+ .Default()
+ .FormatMatroska()
+ .OutputFile(outputName)
+ )
+ .Build();
+
+ // Execute command
+ return Execute(command, true, true, out BufferedCommandResult result)
+ && (result.ExitCode == 0 || LogFailedResult(result));
+ }
+
+ public bool GetStreamHashes(string fileName, out Dictionary streamHashes)
+ {
+ // Streamhash muxer hashes payload only, not timestamps, so an identical hash proves setts changed timestamps and nothing else
+ streamHashes = [];
+
+ // Build command line, stream copy to the streamhash muxer written to stdout
+ Command command = GetBuilder()
+ .GlobalOptions(options => options.Default())
+ .InputOptions(options => options.Default().InputFile(fileName))
+ .OutputOptions(options =>
+ options.MapAllCodecCopy().Format("streamhash").Add("-hash").Add("md5").Add("-")
+ )
+ .Build();
+
+ // Execute command, capturing full stdout
+ if (!Execute(command, false, true, out BufferedCommandResult result))
+ {
+ return false;
+ }
+ if (result.ExitCode != 0)
+ {
+ return LogFailedResult(result);
+ }
+
+ // Parse lines of the form "index,type,md5=value"
+ foreach (
+ string line in result.StandardOutput.Split(
+ '\n',
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
+ )
+ )
+ {
+ string[] parts = line.Split(',', 3);
+ if (
+ parts.Length == 3
+ && int.TryParse(parts[0], out int index)
+ && !streamHashes.ContainsKey(index)
+ )
+ {
+ // Keep type and hash so a stream reorder or payload change is detected
+ streamHashes[index] = $"{parts[1]},{parts[2]}";
+ }
+ }
+ return streamHashes.Count > 0;
+ }
+
public bool RemoveNalUnits(string inputName, int nalUnit, string outputName)
{
// Remove SEI NAL units e.g. EIA-608 and CTA-708 content
diff --git a/PlexCleaner/FfMpegToolJsonSchema.cs b/PlexCleaner/FfMpegToolJsonSchema.cs
index 1aeb8903..473685fb 100644
--- a/PlexCleaner/FfMpegToolJsonSchema.cs
+++ b/PlexCleaner/FfMpegToolJsonSchema.cs
@@ -25,6 +25,22 @@ public static FfProbe FromJson(string json) =>
?? throw new JsonException("Failed to deserialize FfProbe");
}
+ public class ClosedCaptionsProbe
+ {
+ [JsonPropertyName("streams")]
+ public List Streams { get; } = [];
+
+ public static ClosedCaptionsProbe FromJson(string json) =>
+ JsonSerializer.Deserialize(json, FfMpegToolJsonContext.Default.ClosedCaptionsProbe)
+ ?? throw new JsonException("Failed to deserialize ClosedCaptionsProbe");
+ }
+
+ public class ClosedCaptionsTrack
+ {
+ [JsonPropertyName("closed_captions")]
+ public int ClosedCaptions { get; set; }
+ }
+
public class FormatInfo
{
[JsonPropertyName("format_name")]
@@ -155,4 +171,5 @@ public class Packet
)]
[JsonSerializable(typeof(FfMpegToolJsonSchema.FfProbe))]
[JsonSerializable(typeof(FfMpegToolJsonSchema.Packet))]
+[JsonSerializable(typeof(FfMpegToolJsonSchema.ClosedCaptionsProbe))]
internal partial class FfMpegToolJsonContext : JsonSerializerContext;
diff --git a/PlexCleaner/FfProbeBuilder.cs b/PlexCleaner/FfProbeBuilder.cs
index b33ee588..64d67778 100644
--- a/PlexCleaner/FfProbeBuilder.cs
+++ b/PlexCleaner/FfProbeBuilder.cs
@@ -8,19 +8,6 @@ namespace PlexCleaner;
public partial class FfProbe
{
- public static string EscapeMovieFileName(string fileName) =>
- // Escape the file name so that it does not interfere with building the filter graph
- // https://superuser.com/questions/1893137/how-to-quote-a-file-name-containing-single-quotes-in-ffmpeg-ffprobe-movie-filena
- // See av_get_token() in https://github.com/FFmpeg/FFmpeg/blob/master/libavutil/avstring.c
- fileName
- .Replace(@"\", @"/")
- .Replace(@":", @"\\:")
- .Replace(@"'", @"\\\'")
- .Replace(@",", @"\\\,")
- .Replace(@";", @"\\\;")
- .Replace(@"[", @"\\\[")
- .Replace(@"]", @"\\\]");
-
public class GlobalOptions(ArgumentsBuilder argumentsBuilder)
{
public GlobalOptions Default() => AnalyzeDuration("2G").ProbeSize("2G");
@@ -110,6 +97,9 @@ public FfProbeOptions SeekStop(TimeSpan timeSpan) =>
public FfProbeOptions QuickScan() =>
Program.Options.QuickScan ? SeekStop(Program.QuickScanTimeSpan) : this;
+ public FfProbeOptions ReadIntervalFrames(int frames) =>
+ frames <= 0 ? this : Add("-read_intervals").Add($"%+#{frames}");
+
public FfProbeOptions InputFile(string option) => Add($"\"{option}\"");
public FfProbeOptions Add(string option) => Add(option, false);
diff --git a/PlexCleaner/FfProbeTool.cs b/PlexCleaner/FfProbeTool.cs
index a3a745c1..e89b1414 100644
--- a/PlexCleaner/FfProbeTool.cs
+++ b/PlexCleaner/FfProbeTool.cs
@@ -1,4 +1,3 @@
-using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -192,107 +191,75 @@ out string error
}
}
- public bool GetSubCcPackets(
- string fileName,
- Func packetFunc
- )
+ public bool GetClosedCaptions(string fileName, out bool hasClosedCaptions)
{
- // TODO: Switch to ffprobe and analyze_frames (when available in the shipping version).
- // `ffprobe -i FILE -show_entries stream=closed_captions -select_streams v:0 -analyze_frames -read_intervals %X`
-
- // Quickscan is not supported with subcc filter
- // -t and read_intervals do not work with the subcc filter
- // https://superuser.com/questions/1893673/how-to-time-limit-the-input-stream-duration-when-using-movie-filenameout0subcc
- // ReMux using FFmpeg to a snippet file then scan the snippet file
- Command command;
- if (Program.Options.QuickScan)
- {
- // Keep in sync with FfMpegTool.ReMuxToFormat()
-
- // Create a temp filename based on the input name
- string tempName = Path.ChangeExtension(fileName, ".tmp13");
- Debug.Assert(fileName != tempName);
- File.Delete(tempName);
-
- // Use Matroska for snippet format as it supports more stream formats
- // E.g. DVCPRO video streams can be muxed into MKV but not into TS
- // [mpegts @ 000001543cf744c0] Stream 0, codec dvvideo, is muxed as a private data stream and may not be recognized upon reading.
-
- // Build command line
- command = Tools
- .FfMpeg.GetBuilder()
- .GlobalOptions(options => options.Default())
- .InputOptions(options =>
- options.Default().SeekStop(Program.QuickScanTimeSpan).InputFile(fileName)
- )
- .OutputOptions(options =>
- options.MapAllCodecCopy().Default().FormatMatroska().OutputFile(tempName)
- )
- .Build();
-
- // Execute command
- Log.Debug("Creating temp media file : {TempFileName}", tempName);
- if (!Tools.FfMpeg.Execute(command, true, true, out BufferedCommandResult result))
- {
- Log.Error("Failed to create temp media file : {TempFileName}", tempName);
- LogErrorOutput(result.StandardError.Trim());
- File.Delete(tempName);
- return false;
- }
-
- // Use the temp file as the input file
- fileName = tempName;
- }
-
- // Build command line
- // Get packet info using subcc filter
- // https://www.ffmpeg.org/ffmpeg-devices.html#Options-10
- command = GetBuilder()
+ // Detect EIA-608/CTA-708 closed captions embedded in the video stream
+ // analyze_frames decodes frames to surface the stream-level closed_captions flag
+ hasClosedCaptions = false;
+ Command command = GetBuilder()
.GlobalOptions(options => options.Default().HideBanner().LogLevelError())
.FfProbeOptions(options =>
options
- .SelectStreams("s:0")
- .Format("lavfi")
- .Input($"\"movie={EscapeMovieFileName(fileName)}[out0+subcc]\"")
- .ShowPackets()
+ .SelectStreams("v:0")
+ .AnalyzeFrames()
+ .ReadIntervalFrames(
+ Program.Options.QuickScan ? Program.QuickScanFrameCount : 0
+ )
+ .ShowEntries("stream=closed_captions")
.OutputFormatJson()
+ .InputFile(fileName)
)
.Build();
- // Get packet list
- Log.Debug("Getting subcc packet info : {FileName}", fileName);
- bool ret = GetPackets(command, packetFunc, out string error);
- if (!ret)
+ // Execute command
+ Log.Debug("Getting closed caption info : {FileName}", fileName);
+ if (!Execute(command, false, true, out BufferedCommandResult result))
{
- Log.Error("Failed to get subcc packet info : {FileName}", fileName);
- LogErrorOutput(error);
+ return false;
}
- if (Program.Options.QuickScan)
+ if (result.ExitCode != 0)
{
- // Delete the temp file
- File.Delete(fileName);
+ Log.Error("Failed to get closed caption info : {FileName}", fileName);
+ return LogFailedResult(result);
}
- return ret;
+
+ // Any video stream reporting closed captions, FromJson throws on malformed output
+ try
+ {
+ FfMpegToolJsonSchema.ClosedCaptionsProbe probe =
+ FfMpegToolJsonSchema.ClosedCaptionsProbe.FromJson(result.StandardOutput);
+ hasClosedCaptions = probe.Streams.Any(stream => stream.ClosedCaptions != 0);
+ }
+ catch (Exception e) when (Log.Logger.LogAndHandle(e))
+ {
+ return false;
+ }
+ return true;
}
- public bool GetBitratePackets(
+ public bool GetAnalysisPackets(
string fileName,
- Func packetFunc
+ Func packetFunc,
+ bool quickScan
)
{
// Build command line
Command command = GetBuilder()
.GlobalOptions(options => options.Default().HideBanner().LogLevelError())
.FfProbeOptions(options =>
- options.QuickScan().ShowPackets().OutputFormatJson().InputFile(fileName)
+ options
+ .SeekStop(quickScan ? Program.QuickScanTimeSpan : TimeSpan.Zero)
+ .ShowPackets()
+ .OutputFormatJson()
+ .InputFile(fileName)
)
.Build();
// Get packet list
- Log.Debug("Getting bitrate packets : {FileName}", fileName);
+ Log.Debug("Getting analysis packets : {FileName}", fileName);
if (!GetPackets(command, packetFunc, out string error))
{
- Log.Error("Failed to get bitrate packets : {FileName}", fileName);
+ Log.Error("Failed to get analysis packets : {FileName}", fileName);
LogErrorOutput(error);
return false;
}
@@ -308,8 +275,7 @@ public bool GetMediaProps(string fileName, out MediaProps mediaProps)
public bool GetMediaPropsJson(string fileName, out string json)
{
- // TODO: Add analyze_frames when available in all FFmpeg builds
- // https://github.com/FFmpeg/FFmpeg/commit/90af8e07b02e690a9fe60aab02a8bccd2cbf3f01
+ // Do not use analyze_frames, it would add closed_captions, film_grain, nb_read_frames but forces full decode of every stream
// Build command line
json = string.Empty;
diff --git a/PlexCleaner/MediaTool.cs b/PlexCleaner/MediaTool.cs
index 91862241..d75df70e 100644
--- a/PlexCleaner/MediaTool.cs
+++ b/PlexCleaner/MediaTool.cs
@@ -248,6 +248,61 @@ out BufferedCommandResult bufferedCommandResult
}
}
+ public bool ExecuteStreamStdErr(Command command, Action lineAction, out int exitCode)
+ {
+ exitCode = -1;
+ int processId = -1;
+ try
+ {
+ // Stream stderr line by line to the caller instead of buffering it
+ PipeTarget stdErrTarget = 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);
+ }
+ }
+ );
+
+ CommandTask task = command
+ .WithStandardOutputPipe(PipeTarget.Null)
+ .WithStandardErrorPipe(stdErrTarget)
+ .WithValidation(CommandResultValidation.None)
+ .ExecuteAsync(CancellationToken.None, Program.CancelToken());
+ processId = task.ProcessId;
+ Log.Debug(
+ "Executing {ToolType} : ProcessId: {ProcessId}, Arguments: {Arguments}",
+ GetToolType(),
+ processId,
+ command.Arguments
+ );
+
+ CommandResult commandResult = task.Task.GetAwaiter().GetResult();
+ exitCode = commandResult.ExitCode;
+ return task.Task.IsCompletedSuccessfully;
+ }
+ catch (OperationCanceledException)
+ {
+ Log.Error(
+ "Cancelled execution of {ToolType} : ProcessId: {ProcessId}, Arguments: {Arguments}",
+ GetToolType(),
+ processId,
+ command.Arguments
+ );
+ return false;
+ }
+ catch (Exception e) when (Log.Logger.LogAndHandle(e))
+ {
+ return false;
+ }
+ }
+
public static PipeTarget ToStringBuilder(StringBuilder stringBuilder) =>
PipeTarget.Create(
async (stream, cancellationToken) =>
diff --git a/PlexCleaner/ProcessFile.cs b/PlexCleaner/ProcessFile.cs
index 3acbde2c..0f286739 100644
--- a/PlexCleaner/ProcessFile.cs
+++ b/PlexCleaner/ProcessFile.cs
@@ -23,6 +23,9 @@ public class ProcessFile
private SidecarFile _sidecarFile;
+ // Classification of the most recent stream verify, used to choose the repair strategy
+ private VerifyResult _lastVerifyResult;
+
public ProcessFile(string mediaFile)
{
FileInfo = new FileInfo(mediaFile);
@@ -1224,7 +1227,7 @@ private bool FindClosedCaptionTracks(bool conditional, out VideoProps? videoProp
return true;
}
- // Running lavfi is expensive, skip if already verified or closed captions already removed
+ // Running analyze_frames is expensive, skip if already verified or closed captions already removed
if (
conditional
&& (
@@ -1238,20 +1241,9 @@ private bool FindClosedCaptionTracks(bool conditional, out VideoProps? videoProp
return true;
}
- // Get packet info using ccsub filter
- bool packetsFound = false;
+ // Detect closed captions embedded in the video stream
Log.Information("Finding Closed Captions in video stream : {FileName}", FileInfo.FullName);
- if (
- !Tools.FfProbe.GetSubCcPackets(
- FileInfo.FullName,
- _ =>
- {
- // Stop processing more packets
- packetsFound = true;
- return false;
- }
- )
- )
+ if (!Tools.FfProbe.GetClosedCaptions(FileInfo.FullName, out bool hasClosedCaptions))
{
// Error
Log.Error(
@@ -1261,8 +1253,8 @@ private bool FindClosedCaptionTracks(bool conditional, out VideoProps? videoProp
return false;
}
- // Any packets means there are subtitles present in the video stream
- if (packetsFound)
+ // Mark the first video track when captions are present
+ if (hasClosedCaptions)
{
// Use the first video track from FfProbe
videoProps = FfProbeProps.Video.First();
@@ -1757,32 +1749,37 @@ public bool Verify(bool conditional, out bool canRepair)
// Will update sidecar state if bitrate exceeded
_ = VerifyBitrate();
- // Verify media streams, repair is possible
+ // Verify media streams, both failure kinds are repairable
canRepair = true;
- return VerifyMediaStreams(FileInfo);
+ _lastVerifyResult = VerifyMediaStreams(FileInfo);
+
+ // Deterministic: pass only when clean, a timestamp-only or decode result is a repairable failure
+ return _lastVerifyResult == VerifyResult.Clean;
}
- public static bool VerifyMediaStreams(FileInfo fileInfo)
+ public static VerifyResult VerifyMediaStreams(FileInfo fileInfo)
{
// Verify
Log.Debug("Verifying media streams : {FileName}", fileInfo.FullName);
- if (!Tools.FfMpeg.VerifyMedia(fileInfo.FullName))
+ VerifyResult verifyResult = Tools.FfMpeg.VerifyMedia(fileInfo.FullName);
+
+ // Log the classified outcome so a failure is diagnosable, unless it was a cancellation
+ if (!Program.IsCancelledError())
{
- // Cancel requested
- if (Program.IsCancelledError())
+ if (verifyResult == VerifyResult.DecodeError)
{
- return false;
+ Log.Error("Failed to verify media streams : {FileName}", fileInfo.FullName);
+ }
+ else if (verifyResult == VerifyResult.TimestampOnly)
+ {
+ // Correctable failure, the decision Warning is emitted later if the repair runs
+ Log.Information(
+ "Verify detected non-monotonic DTS timestamps : {FileName}",
+ fileInfo.FullName
+ );
}
-
- // Failed stream validation
- Log.Error("Failed to verify media streams : {FileName}", fileInfo.FullName);
-
- // Caller should update the state
- return false;
}
-
- // Verified
- return true;
+ return verifyResult;
}
public bool DeleteFailedFile()
@@ -1861,6 +1858,13 @@ public bool VerifyAndRepair(ref bool modified)
Debug.Assert(!_sidecarFile.State.HasFlag(SidecarFile.StatesType.Verified));
Debug.Assert(!_sidecarFile.State.HasFlag(SidecarFile.StatesType.Repaired));
+ // Non-monotonic DTS is a repairable failure, fix it losslessly with setts
+ // A re-encode cannot fix timestamps so it is not a fallback here
+ if (_lastVerifyResult == VerifyResult.TimestampOnly)
+ {
+ return RepairTimestampsAndSetState(ref modified);
+ }
+
// Attempt repair, if repair fails the original file will not be modified
bool repaired = RepairAndReVerify();
@@ -2056,22 +2060,12 @@ private bool RepairAndReVerify()
// [h264 @ 000002a21166bd00] Invalid NAL unit size (-1148261185 > 8772).
// [matroska,webm @ 0000029a256d9280] Length 7 indicated by an EBML number's first byte 0x02 at pos 1601277 (0x186efd) exceeds max length 4.
- // TODO: Can we ignore the monotonically increasing display time stamp issue?
- // Lots of similar reports, can't find a CLI option to disable or ignore this as an error
- // Also see FFmpeg AVFMT_TS_NONSTRICT option
- // [null @ 0000018cd6bf1800] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 8 >= 8
- // [null @ 0000018cd6bf1800] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 12 >= 12
- // [null @ 0000018cd6bf1800] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 16 >= 16
- // [null @ 0000018cd6bf1800] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 20 >= 20
- // [null @ 0000018cd6bf1800] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 348 >= 348
-
// TODO: HandBrake sometimes fails with what looks like a remux error
// ERROR: avformatMux: track 1, av_interleaved_write_frame failed with error 'Invalid argument'
// M[15:35:43] libhb: work result = 4
// TODO: FfMpeg fails to decode some files
// https://trac.ffmpeg.org/search?q=%22Invalid+NAL+unit+size%22&noquickjump=1&milestone=on&ticket=on&wiki=on
- // https://trac.ffmpeg.org/search?q=%22non+monotonically+increasing+dts+to+muxer%22&noquickjump=1&milestone=on&ticket=on&wiki=on
// TODO: FfMpeg x265 requires input resolution to be multiple of chroma subsampling
// https://stackoverflow.com/questions/50371919/ffmpeg-cannot-open-libx265-encoder-error-initializing-output-stream-00-err
@@ -2135,8 +2129,9 @@ private bool RepairAndReVerify()
return false;
}
- // Re-encoding succeeded, re-verify the temp file
- if (!VerifyMediaStreams(new FileInfo(tempName)))
+ // Require a clean re-verify, accepting a timestamp-only result would mark a file Verified that
+ // still fails verification, and a future run would skip it as already verified
+ if (VerifyMediaStreams(new FileInfo(tempName)) != VerifyResult.Clean)
{
// Failed
File.Delete(tempName);
@@ -2153,6 +2148,139 @@ private bool RepairAndReVerify()
return true;
}
+ public bool RepairTimestamps(ref bool modified)
+ {
+ // Only process Matroska files, the audio timestamp repair does not require a video stream
+ if (!SidecarFile.IsMkvFile(FileInfo.FullName))
+ {
+ return true;
+ }
+
+ // Classify the current verify state
+ _lastVerifyResult = VerifyMediaStreams(FileInfo);
+
+ // Cancel requested
+ if (Program.IsCancelled())
+ {
+ return false;
+ }
+
+ switch (_lastVerifyResult)
+ {
+ case VerifyResult.Clean:
+ // Nothing to repair, clear any stale failure flags and mark verified
+ _sidecarFile.State |= SidecarFile.StatesType.Verified;
+ _sidecarFile.State &= ~SidecarFile.StatesType.VerifyFailed;
+ _sidecarFile.State &= ~SidecarFile.StatesType.RepairFailed;
+ return Refresh(false);
+ case VerifyResult.TimestampOnly:
+ // Benign non-monotonic DTS, clean losslessly when demux-visible
+ return RepairTimestampsAndSetState(ref modified);
+ case VerifyResult.DecodeError:
+ // Genuine decode corruption, not repairable here, leave the state unchanged
+ return true;
+ default:
+ throw new NotImplementedException();
+ }
+ }
+
+ private enum TimestampRepair
+ {
+ // No demux-visible break, the muxer warning is a post-decode artifact with no defect to repair
+ NotApplicable,
+
+ // Timestamps rewritten losslessly
+ Repaired,
+
+ // A demux-visible break, but the lossless repair could not complete
+ Failed,
+ }
+
+ private bool RepairTimestampsAndSetState(ref bool modified)
+ {
+ switch (TryLosslessTimestampRepair())
+ {
+ case TimestampRepair.Repaired:
+ _sidecarFile.State |= SidecarFile.StatesType.Verified;
+ _sidecarFile.State &= ~SidecarFile.StatesType.VerifyFailed;
+ _sidecarFile.State |= SidecarFile.StatesType.Repaired;
+ _sidecarFile.State &= ~SidecarFile.StatesType.RepairFailed;
+ modified = true;
+ return Refresh(true);
+ case TimestampRepair.NotApplicable:
+ // No demux-visible defect, the media is benign, mark verified without a rewrite
+ _sidecarFile.State |= SidecarFile.StatesType.Verified;
+ _sidecarFile.State &= ~SidecarFile.StatesType.VerifyFailed;
+ _sidecarFile.State &= ~SidecarFile.StatesType.RepairFailed;
+ return Refresh(false);
+ case TimestampRepair.Failed:
+ // Do not touch state on cancellation, the caller retries next run
+ if (Program.IsCancelled())
+ {
+ return false;
+ }
+ // The lossless repair failed, leave it as a repair failure
+ _sidecarFile.State |= SidecarFile.StatesType.VerifyFailed;
+ _sidecarFile.State &= ~SidecarFile.StatesType.Verified;
+ _sidecarFile.State |= SidecarFile.StatesType.RepairFailed;
+ _sidecarFile.State &= ~SidecarFile.StatesType.Repaired;
+ _ = Refresh(false);
+ return false;
+ default:
+ throw new NotImplementedException();
+ }
+ }
+
+ private TimestampRepair TryLosslessTimestampRepair()
+ {
+ // Could not analyze packets, treat as a repair failure rather than assume benign
+ if (!GetPacketAnalysis(false, out _, out DtsInfo? dtsInfo) || dtsInfo == null)
+ {
+ return TimestampRepair.Failed;
+ }
+
+ // No demux-visible break, a post-decode-only DTS is left to verify reclassification
+ if (!dtsInfo.HasNonMonotonicDts)
+ {
+ return TimestampRepair.NotApplicable;
+ }
+
+ // Rewrite timestamps losslessly to a temp file
+ string tempName = Path.ChangeExtension(FileInfo.FullName, ".tmp14");
+ Debug.Assert(FileInfo.FullName != tempName);
+
+ // Decision to modify the media, log once at Warning before the rewrite so it shows at Warning level
+ Log.Warning("Repairing non-monotonic DTS timestamps : {FileName}", FileInfo.FullName);
+ if (!Tools.FfMpeg.SetTimestamps(FileInfo.FullName, tempName))
+ {
+ File.Delete(tempName);
+ return TimestampRepair.Failed;
+ }
+
+ // Reject unless the payload is byte-identical and the result verifies clean
+ if (
+ !TimestampRepairRegressionGate(FileInfo.FullName, tempName)
+ || VerifyMediaStreams(new FileInfo(tempName)) != VerifyResult.Clean
+ )
+ {
+ File.Delete(tempName);
+ return TimestampRepair.Failed;
+ }
+
+ // Replace the original with the repaired file
+ File.Move(tempName, FileInfo.FullName, true);
+ Log.Information("Timestamp repair succeeded : {FileName}", FileInfo.FullName);
+ return TimestampRepair.Repaired;
+ }
+
+ private static bool TimestampRepairRegressionGate(string original, string repaired) =>
+ // Lossless requires every stream's coded payload to be byte-identical before and after
+ // The streamhash muxer hashes packet data only, so a matching hash proves only timestamps changed
+ Tools.FfMpeg.GetStreamHashes(original, out Dictionary before)
+ && Tools.FfMpeg.GetStreamHashes(repaired, out Dictionary after)
+ && before.Count == after.Count
+ && before.All(kvp => after.TryGetValue(kvp.Key, out string? hash) && hash == kvp.Value);
+
public bool SetLastWriteTimeUtc(DateTime lastWriteTimeUtc)
{
// Conditional
@@ -2333,7 +2461,14 @@ public bool TestMediaProps()
return true;
}
- public bool GetBitrateInfo(out BitrateInfo? bitrateInfo)
+ public bool GetBitrateInfo(out BitrateInfo? bitrateInfo) =>
+ GetPacketAnalysis(Program.Options.QuickScan, out bitrateInfo, out _);
+
+ public bool GetPacketAnalysis(
+ bool quickScan,
+ out BitrateInfo? bitrateInfo,
+ out DtsInfo? dtsInfo
+ )
{
// Use the default track, else the first track
VideoProps? videoProps = FfProbeProps.Video.Find(item =>
@@ -2345,22 +2480,25 @@ public bool GetBitrateInfo(out BitrateInfo? bitrateInfo)
);
audioProps ??= FfProbeProps.Audio.FirstOrDefault();
- // Add all packets
+ // Read all packets once, computing the bitrate and the DTS monotonicity in a single pass
bitrateInfo = null;
+ dtsInfo = null;
BitrateInfo packetBitrate = new(
videoProps?.Id ?? -1,
audioProps?.Id ?? -1,
Program.Config.VerifyOptions.MaximumBitrate / 8
);
+ DtsInfo packetDts = new();
if (
- !Tools.FfProbe.GetBitratePackets(
+ !Tools.FfProbe.GetAnalysisPackets(
FileInfo.FullName,
packet =>
{
- // Convert from void to bool return
packetBitrate.Add(packet);
+ packetDts.Add(packet);
return true;
- }
+ },
+ quickScan
)
)
{
@@ -2370,6 +2508,7 @@ public bool GetBitrateInfo(out BitrateInfo? bitrateInfo)
// Calculate bitrate
packetBitrate.Calculate();
bitrateInfo = packetBitrate;
+ dtsInfo = packetDts;
return true;
}
diff --git a/PlexCleaner/Program.cs b/PlexCleaner/Program.cs
index e2ef29c8..ede7475b 100644
--- a/PlexCleaner/Program.cs
+++ b/PlexCleaner/Program.cs
@@ -13,12 +13,13 @@ public static class Program
// Exit code for an OS-signal interruption (128 + signal number), else 0; set only by PosixSignalHandler
private static volatile int s_signalExitCode;
- // Serilog to Microsoft.Extensions.Logging bridge shared with library loggers; lives for the
- // process lifetime and is disposed at shutdown alongside the logger
+ // Serilog to Microsoft.Extensions.Logging bridge shared with library loggers
private static Microsoft.Extensions.Logging.ILoggerFactory? s_libraryLoggerFactory;
public static readonly TimeSpan SnippetTimeSpan = TimeSpan.FromSeconds(30);
public static readonly TimeSpan QuickScanTimeSpan = TimeSpan.FromMinutes(3);
+ public const int QuickScanFrameCount = 1000;
+
public static CommandLineOptions Options { get; set; } = null!;
public static ConfigFileJsonSchema Config { get; set; } = null!;
diff --git a/PlexCleaner/VerifyClassifier.cs b/PlexCleaner/VerifyClassifier.cs
new file mode 100644
index 00000000..19c28a3d
--- /dev/null
+++ b/PlexCleaner/VerifyClassifier.cs
@@ -0,0 +1,60 @@
+namespace PlexCleaner;
+
+internal static class VerifyClassifier
+{
+ // Non-monotonic-DTS muxer warnings emitted by the -f null muxer at error loglevel
+ private static readonly List s_timestampSignatures =
+ [
+ "non monotonically increasing dts to muxer",
+ ];
+
+ public static VerifyResult Classify(string stderr)
+ {
+ Accumulator accumulator = new();
+ if (!string.IsNullOrEmpty(stderr))
+ {
+ foreach (string line in stderr.Split('\n'))
+ {
+ accumulator.Add(line);
+ }
+ }
+ return accumulator.Result;
+ }
+
+ public sealed class Accumulator
+ {
+ private bool _decodeError;
+ private bool _timestamp;
+
+ public string? FirstError { get; private set; }
+
+ public VerifyResult Result =>
+ _decodeError ? VerifyResult.DecodeError
+ : _timestamp ? VerifyResult.TimestampOnly
+ : VerifyResult.Clean;
+
+ public void Add(string line)
+ {
+ line = line.Trim();
+ if (line.Length == 0)
+ {
+ return;
+ }
+
+ // A benign muxer timestamp warning does not by itself fail verify
+ if (
+ s_timestampSignatures.Any(sig =>
+ line.Contains(sig, StringComparison.OrdinalIgnoreCase)
+ )
+ )
+ {
+ _timestamp = true;
+ return;
+ }
+
+ // Anything else at error loglevel is treated as decode corruption, fail closed
+ _decodeError = true;
+ FirstError ??= line;
+ }
+ }
+}
diff --git a/PlexCleaner/VerifyResult.cs b/PlexCleaner/VerifyResult.cs
new file mode 100644
index 00000000..efa193fe
--- /dev/null
+++ b/PlexCleaner/VerifyResult.cs
@@ -0,0 +1,13 @@
+namespace PlexCleaner;
+
+public enum VerifyResult
+{
+ // Decode succeeded with no diagnostic output
+ Clean,
+
+ // The only diagnostics are muxer timestamp warnings (non-monotonic DTS)
+ TimestampOnly,
+
+ // A genuine decode or demux corruption signature, or any unrecognized diagnostic, verify fails
+ DecodeError,
+}
diff --git a/PlexCleanerTests/ClosedCaptionsProbeTests.cs b/PlexCleanerTests/ClosedCaptionsProbeTests.cs
new file mode 100644
index 00000000..c2a8b09e
--- /dev/null
+++ b/PlexCleanerTests/ClosedCaptionsProbeTests.cs
@@ -0,0 +1,49 @@
+using AwesomeAssertions;
+using PlexCleaner;
+using Xunit;
+
+namespace PlexCleanerTests;
+
+public class ClosedCaptionsProbeTests
+{
+ [Fact]
+ public void FromJson_ClosedCaptionsPresent_Parsed()
+ {
+ // lang=json
+ const string json = """
+ { "programs": [], "stream_groups": [], "streams": [ { "closed_captions": 1 } ] }
+ """;
+ FfMpegToolJsonSchema.ClosedCaptionsProbe probe =
+ FfMpegToolJsonSchema.ClosedCaptionsProbe.FromJson(json);
+
+ _ = probe.Streams.Should().ContainSingle();
+ _ = probe.Streams[0].ClosedCaptions.Should().Be(1);
+ }
+
+ [Fact]
+ public void FromJson_NoClosedCaptions_Parsed()
+ {
+ // lang=json
+ const string json = """
+ { "streams": [ { "closed_captions": 0 } ] }
+ """;
+ FfMpegToolJsonSchema.ClosedCaptionsProbe probe =
+ FfMpegToolJsonSchema.ClosedCaptionsProbe.FromJson(json);
+
+ _ = probe.Streams.Should().ContainSingle();
+ _ = probe.Streams[0].ClosedCaptions.Should().Be(0);
+ }
+
+ [Fact]
+ public void FromJson_NoStreams_Empty()
+ {
+ // lang=json
+ const string json = """
+ { "streams": [] }
+ """;
+ FfMpegToolJsonSchema.ClosedCaptionsProbe probe =
+ FfMpegToolJsonSchema.ClosedCaptionsProbe.FromJson(json);
+
+ _ = probe.Streams.Should().BeEmpty();
+ }
+}
diff --git a/PlexCleanerTests/DtsInfoTests.cs b/PlexCleanerTests/DtsInfoTests.cs
new file mode 100644
index 00000000..650aaae5
--- /dev/null
+++ b/PlexCleanerTests/DtsInfoTests.cs
@@ -0,0 +1,61 @@
+using AwesomeAssertions;
+using PlexCleaner;
+using Xunit;
+
+namespace PlexCleanerTests;
+
+public class DtsInfoTests
+{
+ private static FfMpegToolJsonSchema.Packet Packet(long streamIndex, double dtsTime) =>
+ new() { StreamIndex = streamIndex, DtsTime = dtsTime };
+
+ [Fact]
+ public void Add_MonotonicDts_NoDetection()
+ {
+ DtsInfo dtsInfo = new();
+ foreach (double dts in new[] { 0.0, 0.04, 0.08, 0.12 })
+ {
+ dtsInfo.Add(Packet(1, dts));
+ }
+
+ _ = dtsInfo.HasNonMonotonicDts.Should().BeFalse();
+ _ = dtsInfo.NonMonotonicByStream.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Add_DuplicateDts_Detected()
+ {
+ // A repeated DTS (X >= X) is the common benign case
+ DtsInfo dtsInfo = new();
+ dtsInfo.Add(Packet(1, 0.08));
+ dtsInfo.Add(Packet(1, 0.08));
+
+ _ = dtsInfo.HasNonMonotonicDts.Should().BeTrue();
+ _ = dtsInfo.NonMonotonicByStream[1].Should().Be(1);
+ }
+
+ [Fact]
+ public void Add_BackwardDts_DetectedPerStream()
+ {
+ DtsInfo dtsInfo = new();
+ dtsInfo.Add(Packet(1, 0.10));
+ dtsInfo.Add(Packet(1, 0.05));
+ dtsInfo.Add(Packet(2, 0.00));
+ dtsInfo.Add(Packet(2, 0.04));
+
+ _ = dtsInfo.HasNonMonotonicDts.Should().BeTrue();
+ _ = dtsInfo.NonMonotonicByStream.Should().ContainKey(1);
+ _ = dtsInfo.NonMonotonicByStream.Should().NotContainKey(2);
+ }
+
+ [Fact]
+ public void Add_NanDts_Ignored()
+ {
+ // Packets with no DTS or PTS are skipped, not counted as breaks
+ DtsInfo dtsInfo = new();
+ dtsInfo.Add(Packet(1, double.NaN));
+ dtsInfo.Add(Packet(1, double.NaN));
+
+ _ = dtsInfo.HasNonMonotonicDts.Should().BeFalse();
+ }
+}
diff --git a/PlexCleanerTests/FileNameEscapingTests.cs b/PlexCleanerTests/FileNameEscapingTests.cs
deleted file mode 100644
index 26c3eee3..00000000
--- a/PlexCleanerTests/FileNameEscapingTests.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using PlexCleaner;
-using Xunit;
-
-namespace PlexCleanerTests;
-
-public class FileNameEscapingTests
-{
- [Theory]
- [InlineData(@"\", @"/")]
- [InlineData(@":", @"\\:")]
- [InlineData(@"'", @"\\\'")]
- [InlineData(@",", @"\\\,")]
- [InlineData(@";", @"\\\;")]
- [InlineData(@"[", @"\\\[")]
- [InlineData(@"]", @"\\\]")]
- [InlineData(
- @"D:\Test\Naming - movie=,.;{}[out0+subcc] (1234) {abc-123} [aaa][bbb][ccc]-def.mkv",
- @"D\\:/Test/Naming - movie=\\\,.\\\;{}\\\[out0+subcc\\\] (1234) {abc-123} \\\[aaa\\\]\\\[bbb\\\]\\\[ccc\\\]-def.mkv"
- )]
- public void Escape_Movie_fileName(string fileName, string escapedName)
- {
- string escapedFileName = FfProbe.EscapeMovieFileName(fileName);
- Assert.Equal(escapedName, escapedFileName);
- }
-}
diff --git a/PlexCleanerTests/PlexCleanerTests.csproj b/PlexCleanerTests/PlexCleanerTests.csproj
index 0c986898..47f77ca5 100644
--- a/PlexCleanerTests/PlexCleanerTests.csproj
+++ b/PlexCleanerTests/PlexCleanerTests.csproj
@@ -18,7 +18,8 @@
-
+
+
diff --git a/PlexCleanerTests/PluginLoaderTests.cs b/PlexCleanerTests/PluginLoaderTests.cs
index 6b1fd3a1..b19201d8 100644
--- a/PlexCleanerTests/PluginLoaderTests.cs
+++ b/PlexCleanerTests/PluginLoaderTests.cs
@@ -22,6 +22,19 @@ public void Load_ExamplePlugin_ReturnsInitializedPlugin()
_ = loaded.GetType().Assembly.GetName().Name.Should().Be("MatroskaHeaderCleanup");
}
+ [Fact]
+ public void Load_DtsTimestampRepairPlugin_ReturnsInitializedPlugin()
+ {
+ IProcessPlugin? plugin = PluginLoader.Load(
+ new FileInfo(Path.Combine(AppContext.BaseDirectory, "DtsTimestampRepair.dll"))
+ );
+
+ _ = plugin.Should().NotBeNull();
+ IProcessPlugin loaded = plugin;
+ _ = loaded.Name.Should().Be("DtsTimestampRepair");
+ _ = loaded.GetType().Assembly.GetName().Name.Should().Be("DtsTimestampRepair");
+ }
+
[Fact]
public void Load_MissingAssembly_ReturnsNull()
{
diff --git a/PlexCleanerTests/VerifyClassifierTests.cs b/PlexCleanerTests/VerifyClassifierTests.cs
new file mode 100644
index 00000000..6b00e7d2
--- /dev/null
+++ b/PlexCleanerTests/VerifyClassifierTests.cs
@@ -0,0 +1,69 @@
+using AwesomeAssertions;
+using PlexCleaner;
+using Xunit;
+
+namespace PlexCleanerTests;
+
+public class VerifyClassifierTests
+{
+ [Fact]
+ public void Classify_EmptyStderr_ReturnsClean()
+ {
+ _ = VerifyClassifier.Classify(string.Empty).Should().Be(VerifyResult.Clean);
+ _ = VerifyClassifier.Classify(" \n ").Should().Be(VerifyResult.Clean);
+ }
+
+ [Fact]
+ public void Classify_OnlyDtsMuxerWarnings_ReturnsTimestampOnly()
+ {
+ string stderr =
+ "[null @ 0x1] Application provided invalid, non monotonically increasing dts to muxer in stream 1: 8 >= 8\n"
+ + "[null @ 0x1] Application provided invalid, non monotonically increasing dts to muxer in stream 1: 12 >= 12\n";
+ _ = VerifyClassifier.Classify(stderr).Should().Be(VerifyResult.TimestampOnly);
+ }
+
+ [Theory]
+ [InlineData("[matroska @ 0x1] Invalid data found when processing input")]
+ [InlineData("[h264 @ 0x1] error while decoding MB 10 20")]
+ [InlineData("[NULL @ 0x1] Invalid NAL unit size (-1148261185 > 8772).")]
+ [InlineData("[h264 @ 0x1] mmco: unref short failure")]
+ [InlineData("[aac @ 0x1] env_facs_q 255 is invalid")]
+ [InlineData("[matroska,webm @ 0x1] Length 7 indicated by an EBML number exceeds max length 4.")]
+ public void Classify_DecodeSignature_ReturnsDecodeError(string stderr) =>
+ VerifyClassifier.Classify(stderr).Should().Be(VerifyResult.DecodeError);
+
+ [Fact]
+ public void Classify_DecodeErrorMixedWithDtsWarning_ReturnsDecodeError()
+ {
+ // A decode error co-occurring with benign DTS warnings must still fail
+ string stderr =
+ "[null @ 0x1] Application provided invalid, non monotonically increasing dts to muxer in stream 1: 8 >= 8\n"
+ + "[h264 @ 0x1] error while decoding MB 3 4\n";
+ _ = VerifyClassifier.Classify(stderr).Should().Be(VerifyResult.DecodeError);
+ }
+
+ [Fact]
+ public void Classify_UnrecognizedStderr_ReturnsDecodeError() =>
+ // Fail closed on anything not explicitly benign
+ VerifyClassifier
+ .Classify("[mystery @ 0x1] some unexpected diagnostic line")
+ .Should()
+ .Be(VerifyResult.DecodeError);
+
+ [Fact]
+ public void Accumulator_StreamedLines_ClassifiesAndKeepsFirstError()
+ {
+ // The accumulator sees lines one at a time without buffering the whole stderr
+ VerifyClassifier.Accumulator accumulator = new();
+ accumulator.Add(
+ "[null @ 0x1] Application provided invalid, non monotonically increasing dts to muxer in stream 1: 8 >= 8"
+ );
+ _ = accumulator.Result.Should().Be(VerifyResult.TimestampOnly);
+
+ accumulator.Add("[h264 @ 0x1] error while decoding MB 3 4");
+ accumulator.Add("[h264 @ 0x1] Invalid data found");
+
+ _ = accumulator.Result.Should().Be(VerifyResult.DecodeError);
+ _ = accumulator.FirstError.Should().Be("[h264 @ 0x1] error while decoding MB 3 4");
+ }
+}
diff --git a/Plugins/DtsTimestampRepair/DtsTimestampRepair.csproj b/Plugins/DtsTimestampRepair/DtsTimestampRepair.csproj
new file mode 100644
index 00000000..9ea4b34e
--- /dev/null
+++ b/Plugins/DtsTimestampRepair/DtsTimestampRepair.csproj
@@ -0,0 +1,10 @@
+
+
+ PlexCleaner.Plugins.DtsTimestampRepair
+ DtsTimestampRepair
+
+
+
+
+
+
diff --git a/Plugins/DtsTimestampRepair/DtsTimestampRepairPlugin.cs b/Plugins/DtsTimestampRepair/DtsTimestampRepairPlugin.cs
new file mode 100644
index 00000000..068cba57
--- /dev/null
+++ b/Plugins/DtsTimestampRepair/DtsTimestampRepairPlugin.cs
@@ -0,0 +1,90 @@
+using Serilog;
+
+namespace PlexCleaner.Plugins.DtsTimestampRepair;
+
+// Retroactively repair files that an older PlexCleaner version marked RepairFailed because verify
+// wrongly rejected a benign non-monotonic-DTS muxer warning. Re-verifies each RepairFailed file, clears
+// the flag when the only problem is timestamps, and losslessly rewrites the timestamps (setts) when the
+// DTS is demux-visible. Reuses PlexCleaner.ProcessFile.RepairTimestamps.
+public sealed class DtsTimestampRepairPlugin : IProcessPlugin
+{
+ private ILogger _logger = Log.Logger;
+
+ // The PlexCleaner version whose public API this plugin was built and tested against. PluginApi.Version
+ // only guards the plugin contract; the ProcessFile methods this plugin calls can change in any release,
+ // so pin against the tested application version as well.
+ private const string TestedApplicationVersion = "3.21";
+
+ public string Name => "DtsTimestampRepair";
+
+ public bool Initialize(IPluginHost host)
+ {
+ _logger = host.Logger;
+
+ // Refuse to run against an incompatible plugin contract
+ if (host.PluginApiVersion != PluginApi.Version)
+ {
+ _logger.Error(
+ "Incompatible plugin API version : host {HostVersion} != plugin {PluginVersion}",
+ host.PluginApiVersion,
+ PluginApi.Version
+ );
+ return false;
+ }
+
+ // Warn when the running PlexCleaner version differs from the tested version, since the public API
+ // this plugin calls could have changed
+ if (
+ !MajorMinor(host.ApplicationVersion)
+ .Equals(TestedApplicationVersion, StringComparison.Ordinal)
+ )
+ {
+ _logger.Warning(
+ "Plugin tested against PlexCleaner {TestedVersion} but host is {HostVersion}, internal APIs may differ",
+ TestedApplicationVersion,
+ host.ApplicationVersion
+ );
+ }
+
+ _logger.Information(
+ "{Name} initialized : {AppVersion} : {Os}",
+ Name,
+ host.ApplicationVersion,
+ host.OperatingSystem
+ );
+ return true;
+ }
+
+ // Reduce a version string like "3.21.1.0" to "3.21" to compare against the tested major.minor
+ private static string MajorMinor(string version)
+ {
+ string[] parts = version.Split('.');
+ return parts.Length >= 2 ? $"{parts[0]}.{parts[1]}" : version;
+ }
+
+ public bool ProcessFile(string fileName)
+ {
+ // The driver passes every file, skip anything that is not a Matroska file
+ if (!SidecarFile.IsMkvFile(fileName))
+ {
+ return true;
+ }
+
+ ProcessFile processFile = new(fileName);
+ if (!processFile.GetMediaProps())
+ {
+ return false;
+ }
+
+ // Only revisit files a previous run gave up on
+ if (!processFile.State.HasFlag(SidecarFile.StatesType.RepairFailed))
+ {
+ return true;
+ }
+
+ // Re-verify and clear the RepairFailed flag when the failure was a benign timestamp issue,
+ // losslessly repairing the timestamps when possible, RepairTimestamps keeps the sidecar in sync
+ bool modified = false;
+ return processFile.RepairTimestamps(ref modified);
+ }
+}
diff --git a/README.md b/README.md
index ed231bef..eedc6bdc 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,14 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc.
### Release Notes
+**Version: 3.21**:
+
+**Summary:**
+
+- Repair non-monotonic DTS losslessly with the `setts` bitstream filter instead of failing repair permanently; a non-monotonic DTS is a correctable verify failure, not decode corruption.
+- Switched closed caption detection to `ffprobe -analyze_frames`, and consolidated the bitrate and DTS packet analyses into a single packet pass.
+- Added the `DtsTimestampRepair` example plugin that revisits files a previous version marked `RepairFailed` and clears or losslessly repairs benign timestamp failures.
+
**Version: 3.20**:
**Summary:**
@@ -149,6 +157,7 @@ Common examples of issues resolved by the `process` command:
**Performance & Integrity:**
- Corrupt media streams → Verify integrity and attempt automatic repair.
+- Non-monotonic DTS timestamps → Losslessly rewrite the packet timestamps using `setts`.
- Matroska files that fail player Direct Play despite passing tool checks → Detect an unusable seek index (SeekHead/Cues) and re-multiplex.
- High bitrate content → Warn when exceeding network capacity (WiFi/100Mbps Ethernet).
@@ -851,7 +860,10 @@ public interface IProcessPlugin
}
```
-`Initialize` receives an `IPluginHost` with the deterministic `PluginApiVersion`, the application and OS versions, and a `Serilog.ILogger` to log through. `ProcessFile` reuses the public processing API, for example `new ProcessFile(fileName)` then `RepairMatroskaStructure(...)`. See the [`MatroskaHeaderCleanup`](./Plugins/MatroskaHeaderCleanup/) example, which re-checks and repairs the Matroska seek-index structure on already-verified files.
+`Initialize` receives an `IPluginHost` with the deterministic `PluginApiVersion`, the application and OS versions, and a `Serilog.ILogger` to log through. `ProcessFile` reuses the public processing API, for example `new ProcessFile(fileName)` then `RepairMatroskaStructure(...)`. Two examples are included:
+
+- [`MatroskaHeaderCleanup`](./Plugins/MatroskaHeaderCleanup/) re-checks and repairs the Matroska seek-index structure on already-verified files.
+- [`DtsTimestampRepair`](./Plugins/DtsTimestampRepair/) revisits files that an older version marked `RepairFailed`, re-verifies them, clears the flag when the only problem was a benign non-monotonic DTS, and losslessly repairs the timestamps (`setts`) when the DTS is demux-visible.
Notes:
diff --git a/cspell.json b/cspell.json
index b74647c8..f6a0aa13 100644
--- a/cspell.json
+++ b/cspell.json
@@ -43,6 +43,7 @@
"deinterlace",
"deinterlaced",
"deinterlacing",
+ "demux",
"derbend",
"devel",
"dockerhub",
@@ -152,6 +153,8 @@
"numfmt",
"NVENC",
"Oughta",
+ "OUTDTS",
+ "OUTPTS",
"parameterless",
"partitioner",
"piete",
@@ -192,9 +195,11 @@
"Serilog",
"setparams",
"settingsfile",
+ "setts",
"SMPTE",
"snupkg",
"softprops",
+ "streamhash",
"stylecop",
"subcc",
"subdir",
diff --git a/version.json b/version.json
index 1947a534..8c723482 100644
--- a/version.json
+++ b/version.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
- "version": "3.20",
+ "version": "3.21",
"publicReleaseRefSpec": [
"^refs/heads/main$"
],