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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions PlexCleaner.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
</Folder>
<Project Path="PlexCleaner/PlexCleaner.csproj" />
<Project Path="PlexCleanerTests/PlexCleanerTests.csproj" />
<Project Path="Plugins/DtsTimestampRepair/DtsTimestampRepair.csproj" />
<Project Path="Plugins/MatroskaHeaderCleanup/MatroskaHeaderCleanup.csproj" />
<Project Path="Sandbox/Sandbox.csproj" />
</Solution>
34 changes: 34 additions & 0 deletions PlexCleaner/DtsInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace PlexCleaner;

public sealed class DtsInfo
{
// Last DTS seen per stream index
private readonly Dictionary<long, double> _lastDts = [];

// Count of non-monotonic packets per stream index
private readonly Dictionary<long, int> _nonMonotonicByStream = [];

// Stream indexes carrying a non-monotonic DTS, with the per-stream count
public IReadOnlyDictionary<long, int> 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;
}
}
5 changes: 5 additions & 0 deletions PlexCleaner/FfMpegBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 116 additions & 7 deletions PlexCleaner/FfMpegTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) =>
Expand Down Expand Up @@ -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();
Comment thread
ptr727 marked this conversation as resolved.

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

public bool GetStreamHashes(string fileName, out Dictionary<int, string> 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
Expand Down
17 changes: 17 additions & 0 deletions PlexCleaner/FfMpegToolJsonSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ public static FfProbe FromJson(string json) =>
?? throw new JsonException("Failed to deserialize FfProbe");
}

public class ClosedCaptionsProbe
{
[JsonPropertyName("streams")]
public List<ClosedCaptionsTrack> 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")]
Expand Down Expand Up @@ -155,4 +171,5 @@ public class Packet
)]
[JsonSerializable(typeof(FfMpegToolJsonSchema.FfProbe))]
[JsonSerializable(typeof(FfMpegToolJsonSchema.Packet))]
[JsonSerializable(typeof(FfMpegToolJsonSchema.ClosedCaptionsProbe))]
internal partial class FfMpegToolJsonContext : JsonSerializerContext;
16 changes: 3 additions & 13 deletions PlexCleaner/FfProbeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
Loading