Skip to content
35 changes: 19 additions & 16 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ Critical performance feature - DO NOT break compatibility:

- Only MkvMerge reports IETF tags, and a missing one is derived from the ISO 639-2B tag
- MkvMerge normalizes to IETF tags when `SetIetfLanguageTags` enabled
- FfMpeg and HandBrake drop the IETF tags when they write a file. Each of their rewrites restores the tags with MkvPropEdit before the output replaces the original, from the track list read before the rewrite. `Convert.RestoreLanguageTags()` first checks that the output keeps each track's number, type, and ISO 639-2B tag. Otherwise the rewrite fails, and the original stays. A HandBrake output holds the video, then the audio, then the subtitle tracks, so `GetHandBrakeTrackOrder()` numbers copies of the tags that way. The FfMpeg remux fallback restores them only for a Matroska input, since another container carries no IETF tags.
- FFprobe uses tag metadata which may differ from track metadata
- Track validation: Checks ISO/IETF consistency, sets error states for mismatches

Expand Down Expand Up @@ -441,8 +442,10 @@ flowchart TD
X3 -->|fail| XF
X3 --> X4["ReMux(): MkvMerge pass over the FfMpeg output"]
X4 -->|fail| XF
X4 --> X9["RestoreLanguageTags() when the input is Matroska"]
X9 -->|fail| XF
X1 --> X5{"The .mkv name is the input file itself?"}
X4 --> X5
X9 --> X5
X5 -->|yes| X6["Move over the input"]
X5 -->|no| X7["Move to the .mkv name"]
X6 -->|"move fails"| XT(["Throw after deleting the temp file, the input stays"])
Expand Down Expand Up @@ -611,7 +614,7 @@ flowchart TD
C2 -->|yes| FAIL
C2 -->|no| C3["FfMpeg removes the NAL units to .tmp10"]
C3 -->|fail| FAIL
C3 --> C4["Convert.ReMux()"]
C3 --> C4["Convert.ReMux(), then RestoreLanguageTags()"]
C4 -->|fail| FAIL
C4 --> C5["Move over the original, sets ClearedCaptions and ReMuxed, Refresh()"]
```
Expand Down Expand Up @@ -641,25 +644,25 @@ flowchart TD
FOUND -->|"yes, deinterlace command"| AGAIN["Warn, deinterlace again"]
AGAIN --> D0
FOUND -->|no| D0["Adds the DeInterlace action to the video track"]
D0 --> D1["HandBrake deinterlaces to .tmp8, subtitles excluded"]
D0 --> D1["ConvertUsingHandBrake(): HandBrake deinterlaces to .tmp8, subtitles excluded"]
D1 -->|fail| FAIL
D1 --> D2{"Original has subtitles?"}
D2 -->|no| D3["MkvMerge remux to .tmp9"]
D2 -->|yes| D4["MkvMerge merges the original subtitles back to .tmp9"]
D3 -->|fail| FAIL
D4 -->|fail| FAIL
D3 --> D5["MkvPropEdit restores the track languages in .tmp9"]
D3 --> D5["RestoreLanguageTags(): tracks match, MkvPropEdit restores the track languages in .tmp9"]
D4 --> D5
D5 -->|fail| FAIL
D5 --> D6["Move .tmp9 over the original"]
D6 --> D7["Sets DeInterlaced and ReMuxed, Refresh()"]
D7 --> D8{"Track order matches?"}
D8 -->|no| FAIL
D8 -->|yes| AOK
D7 --> AOK
```

A metadata interlace flag can survive deinterlacing, so the `process` command does not deinterlace a file already `DeInterlaced` again.

Every HandBrake run goes through `ConvertUsingHandBrake()`, which runs nodes D1 to D5 for its caller. HandBrake converts subtitles to ASS, and adds embedded closed captions as one more subtitle track. So it encodes without subtitles, and MkvMerge merges the original subtitle tracks back unchanged. The repair by re-encode uses it for its HandBrake fallback.

### Track Selection

All of these steps read the MkvMerge track list, since only MkvMerge reports IETF language tags. `FindPreferredAudio()` picks the first track whose format comes earliest in `PreferredAudioFormats`, else the first track.
Expand Down Expand Up @@ -743,13 +746,11 @@ flowchart TD
C1 -->|fail| FAIL(["Fail"])
C1 --> C2["ConvertToMkv(): Convert.ReMux() of .tmp1"]
C2 -->|fail| FAIL
C2 --> C3["ConvertToMkv(): MkvPropEdit restores the track languages in .tmp1"]
C2 --> C3["ConvertToMkv(): RestoreLanguageTags(), tracks match, MkvPropEdit restores the track languages in .tmp1"]
C3 -->|fail| FAIL
C3 --> C6["ConvertToMkv(): move .tmp1 over the original"]
C6 --> C4["Sets ReEncoded and ReMuxed, Refresh()"]
C4 --> C5{"Track order matches?"}
C5 -->|no| FAIL
C5 -->|yes| AOK
C4 --> AOK
```

The encoder output can still match the selection, so the `process` command does not re-encode a file already `ReEncoded` again. The `ConvertOptions` settings shape the FfMpeg and HandBrake command lines, and they decide no branch.
Expand Down Expand Up @@ -794,7 +795,7 @@ flowchart TD
T1 --> T2["FfMpeg rewrites timestamps to .tmp14, then Convert.ReMux()"]
T2 --> T3["TimestampRepairRegressionGate(): stream hashes equal, start and duration within 40 ms"]
T3 --> T4{"Re-verify Clean?"}
T4 -->|yes| T5["Move over the original"]
T4 -->|yes| T5["RestoreLanguageTags(), then move over the original"]
T5 --> RS
T1 -->|"not audio only, or any step fails"| T6["Remux only when the original or the rewritten file shows a DTS issue only after decode"]
T4 -->|no| T6
Expand All @@ -805,13 +806,15 @@ flowchart TD
R1 -->|fail| R2{"Cancelled, or remux only?"}
R2 -->|yes| RF
R2 -->|no| R3["FfMpeg re-encode to .tmp12"]
R3 -->|"fail, not cancelled"| R4["HandBrake re-encode to .tmp12, subtitles included"]
R3 -->|"fail, not cancelled"| R4["ConvertUsingHandBrake(): HandBrake re-encode to .tmp8 without subtitles, MkvMerge merges the original subtitles back to .tmp12, RestoreLanguageTags()"]
R3 -->|"fail, cancelled"| RF
R4 -->|fail| RF
R3 --> R5["Convert.ReMux(), then re-verify Clean"]
R4 --> R5
R3 --> R5["Convert.ReMux(), then RestoreLanguageTags()"]
R5 -->|fail| RF
R5 -->|pass| R6
R5 --> R7{"Re-verify Clean?"}
R4 --> R7
R7 -->|no| RF
R7 -->|yes| R6
R6 -->|"TimestampOnly path"| RS
R6 -->|"DecodeError path"| RC{"Cancelled?"}
RC -->|"yes, the next run verifies the replaced media again"| FAIL
Expand Down
106 changes: 96 additions & 10 deletions PlexCleaner/Convert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ out string outputName
return false;
}

// FfMpeg discards IETF language tags, restore them before the output replaces the input
if (!Tools.MkvPropEdit.SetTrackLanguage(tempName, languageProps))
if (!RestoreLanguageTags(tempName, languageProps))
{
File.Delete(tempName);
return false;
Expand All @@ -48,7 +47,11 @@ out string outputName
return true;
}

public static bool ReMuxToMkv(string inputName, out string outputName)
public static bool ReMuxToMkv(
string inputName,
MediaProps? languageProps,
out string outputName
)
{
// This function will try both MkvMerge and FfMpeg

Expand Down Expand Up @@ -104,6 +107,16 @@ public static bool ReMuxToMkv(string inputName, out string outputName)
File.Delete(tempName);
return false;
}

// Only a Matroska input carries IETF language tags to restore
if (
languageProps?.IsContainerMkv() == true
&& !RestoreLanguageTags(tempName, languageProps)
)
{
File.Delete(tempName);
return false;
}
}

ReplaceInput(inputName, tempName, outputName);
Expand All @@ -112,17 +125,11 @@ public static bool ReMuxToMkv(string inputName, out string outputName)

public static bool ReMuxToMkv(
string inputName,
SelectMediaProps? selectMediaProps,
SelectMediaProps selectMediaProps,
out string outputName
)
{
// This function will only use MkvMerge
if (selectMediaProps == null)
{
// Use version that will try both MkvMerge and FfMpeg
return ReMuxToMkv(inputName, out outputName);
}

// This only works on MKV files and MkvMerge MediaProps types
Debug.Assert(selectMediaProps.Selected.Parser == MediaTool.ToolType.MkvMerge);
Debug.Assert(SidecarFile.IsMkvFile(inputName));
Expand Down Expand Up @@ -174,6 +181,85 @@ public static bool ReMux(string fileName)
return true;
}

// FfMpeg and HandBrake discard IETF language tags, restore them from the tracks before the rewrite
public static bool RestoreLanguageTags(string fileName, MediaProps languageProps)
{
Debug.Assert(languageProps.Parser == MediaTool.ToolType.MkvMerge);
if (!Tools.MkvMerge.GetMediaPropsJson(fileName, out string json))
{
return false;
}

// A rewrite that drops or reorders tracks fails, the tags would land on the wrong tracks
try
{
if (!HasSameTracks(MkvToolJsonSchema.MkvMerge.FromJson(json).Tracks, languageProps))
{
Log.Error(
"Rewritten tracks do not match the original tracks : {FileName}",
fileName
);
return false;
}
}
catch (Exception e) when (Log.Logger.LogAndHandle(e))
{
return false;
}
return Tools.MkvPropEdit.SetTrackLanguage(fileName, languageProps);
}

// HandBrake writes the video, then the audio, then the subtitle tracks, so number copies of the tags that way
public static MediaProps GetHandBrakeTrackOrder(MediaProps mediaProps)
{
MediaProps ordered = new(mediaProps.Parser, mediaProps.FileName);
long number = 1;
ordered.Video.AddRange(
mediaProps.Video.Select(item => CopyLanguage(new VideoProps(ordered), item, number++))
);
ordered.Audio.AddRange(
mediaProps.Audio.Select(item => CopyLanguage(new AudioProps(ordered), item, number++))
);
ordered.Subtitle.AddRange(
mediaProps.Subtitle.Select(item =>
CopyLanguage(new SubtitleProps(ordered), item, number++)
)
);
return ordered;
}

private static T CopyLanguage<T>(T copy, TrackProps item, long number)
where T : TrackProps
{
copy.Number = number;
copy.Language = item.Language;
copy.LanguageIetf = item.LanguageIetf;
return copy;
}

// Compare the JSON tracks, parsing them into MediaProps would log every missing IETF tag
private static bool HasSameTracks(List<MkvToolJsonSchema.Track> tracks, MediaProps mediaProps)
{
List<TrackProps> trackList = mediaProps.GetTrackList();
return tracks.Count == trackList.Count
&& trackList.All(item =>
tracks.Any(track =>
track.Properties.Number == item.Number
&& track.Type.Equals(GetMkvMergeTrackType(item), StringComparison.Ordinal)
&& track.Properties.Language.Equals(item.Language, StringComparison.Ordinal)
)
);
}

private static string GetMkvMergeTrackType(TrackProps trackProps) =>
trackProps switch
{
VideoProps => "video",
AudioProps => "audio",
SubtitleProps => "subtitles",
_ => throw new NotImplementedException(),
};

// A rename or move onto targetName replaces an existing entry other than fileName itself
public static bool WouldReplaceOtherFile(string fileName, string targetName) =>
Path.Exists(targetName) && !IsSameFile(fileName, targetName);
Expand Down
2 changes: 0 additions & 2 deletions PlexCleaner/HandBrakeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,6 @@ public class OutputOptions(ArgumentsBuilder argumentsBuilder)

public OutputOptions AudioEncoder(string option) => AudioEncoder().Add(option);

public OutputOptions AllSubtitles() => Add("--all-subtitles");

public OutputOptions Subtitle() => Add("--subtitle");

public OutputOptions Subtitle(string option) => Subtitle().Add(option);
Expand Down
13 changes: 2 additions & 11 deletions PlexCleaner/HandBrakeTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,7 @@ out double progress
[GeneratedRegex("\"Progress\":\\s*([0-9.]+)")]
private static partial Regex ProgressRegex();

public bool ConvertToMkv(
string inputName,
string outputName,
bool includeSubtitles,
bool deInterlace
)
public bool ConvertToMkv(string inputName, string outputName, bool deInterlace)
{
// Delete output file
File.Delete(outputName);
Expand All @@ -131,11 +126,7 @@ bool deInterlace
.Add(deInterlace, options => options.CombDetect().Decomb())
.AllAudio()
.AudioEncoder(Program.Config.ConvertOptions.HandBrakeOptions.Audio)
.Add(
includeSubtitles,
options => options.AllSubtitles(),
options => options.NoSubtitles()
)
.NoSubtitles()
)
.Build();

Expand Down
64 changes: 0 additions & 64 deletions PlexCleaner/MediaProps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,6 @@ public class MediaProps(MediaTool.ToolType parser, string fileName)
// Combined track count
public int Count => Video.Count + Audio.Count + Subtitle.Count;

public MediaProps Clone()
{
// Shallow copy
MediaProps clone = (MediaProps)MemberwiseClone();

// Create new collections containing the old items
List<VideoProps> newVideo = [];
newVideo.AddRange(Video);
clone.Video = newVideo;
List<AudioProps> newAudio = [];
newAudio.AddRange(Audio);
clone.Audio = newAudio;
List<SubtitleProps> newSubtitle = [];
newSubtitle.AddRange(Subtitle);
clone.Subtitle = newSubtitle;

return clone;
}

// ffprobe reports "matroska,webm"
// mkvmerge and mediainfo reports "matroska"
public bool IsContainerMkv() =>
Expand Down Expand Up @@ -191,49 +172,4 @@ public List<TrackProps> MatchMediaInfoToMkvMerge(List<TrackProps> mediaInfoTrack

return matchedTrackList;
}

public bool VerifyTrackOrder(MediaProps mediaProps)
{
// Verify that this MediaProps matches the presented MediaProps
// Used to verify that the track numbers for MkvMerge remains the same prior to and after ffmpeg and handbrake
// This logic works for MkvMerge only
Debug.Assert(Parser == MediaTool.ToolType.MkvMerge);
Debug.Assert(mediaProps.Parser == MediaTool.ToolType.MkvMerge);

// Track counts
if (Count != mediaProps.Count)
{
return false;
}

// Get track items as list
List<TrackProps> thisTrackList = GetTrackList();
List<TrackProps> thatTrackList = mediaProps.GetTrackList();
foreach (TrackProps thisItem in thisTrackList)
{
// Find the matching item by matroska header number
TrackProps? thatItem = thatTrackList.Find(item => item.Number == thisItem.Number);
if (thatItem == null)
{
return false;
}

// The types have to match
if (thisItem.GetType() != thatItem.GetType())
{
return false;
}

// The ISO693-3 language has to match
if (!thisItem.Language.Equals(thatItem.Language, StringComparison.Ordinal))
{
return false;
}

// Other properties may have changed during encoding
}

// Done
return true;
}
}
Loading
Loading