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$" ],