From f9c1c3d390917de34a28e65c1fb0632e1fba2702 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sat, 31 Oct 2026 16:35:01 -0600 Subject: [PATCH] feat(tracks): add subtitle.dedupe_codecs to keep one codec per subtitle variant --- docs/guide/subtitles.md | 2 ++ docs/reference/configuration/download.md | 1 + tests/tracks/test_subtitle_sort.py | 31 +++++++++++++++++++++--- unshackle/commands/dl.py | 4 +++ unshackle/core/tracks/tracks.py | 17 +++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index 5538cf8e..b46c33d9 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -441,6 +441,7 @@ subtitle: | `sidecar_format` | `srt` | a format name (e.g. `srt`, `vtt`) or `original` | Format used for sidecar files. | | `group_by` | `type` | `type`, `language` | Whether all tracks of one type stay together, or each language stays next to its own variants. | | `language_priority` | *(unset)* | a list of languages | Languages to sort to the top. The rest keep the default order. Nothing is removed. | +| `dedupe_codecs` | *(unset)* | a list of codecs, e.g. `[VTT, STPP, WVTT]` | Keep one copy of a subtitle that the service sends in several codecs: the earliest listed codec wins. | ### Which settings to change @@ -454,6 +455,7 @@ only when you want something the defaults do not do: | Music and lyric lines stripped along with the sound effects | `sdh_method: filter-subs` | | Lyric lines kept | `sdh_method: subby`, or install SubtitleEdit | | No stripped copies made at all | `strip_sdh: false` | +| One copy of each subtitle when the service sends the same one in several codecs | `dedupe_codecs: [VTT, STPP, WVTT]` | | One backend for every conversion, whatever you install | `conversion_method: ` | Two keys read as if they control markup, and do not: diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index 783e903e..35052e95 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -194,6 +194,7 @@ in or writes them as sidecar files. See [Subtitles](../../guide/subtitles.md) fo | `type_priority` | list | *(unset)* | Ordered ranking of subtitle types (`forced`, `normal`, `sdh`; CC counts as SDH). Types you leave out fall to the end. | | `group_by` | str | `"type"` | Which key groups the subtitle tracks. `type` puts all forced tracks together, then all normal, then all SDH. `language` keeps each language next to its own variants. | | `language_priority` | list | *(unset)* | Languages to sort to the top, in the order you give. The rest keep the default order: original language first, then alphabetical. Nothing is removed. | +| `dedupe_codecs` | list | *(unset)* | Subtitle codecs in order of preference, by value or name, for example `[VTT, STPP, WVTT]`. When tracks of the same language, forced/SDH/CC type and name come in several listed codecs, unshackle drops the ones in a later codec. It always keeps codecs you leave out. Unset keeps every track. | ```yaml subtitle: diff --git a/tests/tracks/test_subtitle_sort.py b/tests/tracks/test_subtitle_sort.py index 9cd7b544..c55c7442 100644 --- a/tests/tracks/test_subtitle_sort.py +++ b/tests/tracks/test_subtitle_sort.py @@ -3,11 +3,19 @@ import pytest from unshackle.core.tracks import Subtitle, Tracks -def sub(lang: str, forced: bool = False, sdh: bool = False, cc: bool = False, orig: bool = False) -> Subtitle: +def sub( + lang: str, + forced: bool = False, + sdh: bool = False, + cc: bool = False, + orig: bool = False, + codec: Subtitle.Codec = Subtitle.Codec.SubRip, + tag: str = "", +) -> Subtitle: track = Subtitle( - url=f"https://example.test/{lang}-{forced}-{sdh}-{cc}", + url=f"https://example.test/{lang}-{forced}-{sdh}-{cc}-{codec.value}{tag}", language=lang, - codec=Subtitle.Codec.SubRip, + codec=codec, forced=forced, sdh=sdh, cc=cc, @@ -266,3 +274,20 @@ def test_all_sorts_the_original_track_then_its_other_regional_forms() -> None: ) t.sort_audio(by_language=["all"]) assert [str(a.language) for a in t.audio] == ["fr-CA", "fr-FR", "en"] + + +def test_dedupe_subtitle_codecs_keeps_the_first_listed_codec_of_each_variant() -> None: + codec = Subtitle.Codec + vtt = sub("en", codec=codec.WebVTT) + wvtt = sub("en", codec=codec.fVTT) + stpp = sub("en", codec=codec.fTTML) + stpp_cc = sub("en", cc=True, codec=codec.fTTML) + srt = sub("en") # unlisted codec: always kept + de_wvtt = sub("de", codec=codec.fVTT) + vtt_second = sub("en", codec=codec.WebVTT, tag="-second") # same winning codec: kept, it may differ in content + tracks = Tracks() + tracks.subtitles = [wvtt, stpp, vtt, stpp_cc, srt, de_wvtt, vtt_second] + + tracks.dedupe_subtitle_codecs([codec.WebVTT, codec.fTTML, codec.fVTT]) + + assert tracks.subtitles == [vtt, stpp_cc, srt, de_wvtt, vtt_second] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index eaf7487c..62e93cd0 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -2453,6 +2453,10 @@ class dl: level="INFO", operation="get_tracks", service=self.service, context=tracks_info ) + if dedupe_codecs := config.subtitle.get("dedupe_codecs"): + codecs = map(SUBTITLE_CODEC.convert, dedupe_codecs) + title.tracks.dedupe_subtitle_codecs([c for c in codecs if isinstance(c, Subtitle.Codec)]) + # a plain track covers an SDH track only when it is the same variant, as -sl selects them, # so en-US SDH is stripped next to a plain en-GB: -sl en-US would otherwise keep only the SDH if config.subtitle.get("strip_sdh", True): diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index 697dd2cc..46f2287b 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -308,6 +308,23 @@ class Tracks: kept.append(track) setattr(self, kind, kept) + def dedupe_subtitle_codecs(self, priority: Sequence[Subtitle.Codec]) -> None: + """ + Drop a subtitle when the same language, flag set and name exists in a codec earlier in priority. + A subtitle in an unlisted codec is always kept. + """ + rank = {codec: i for i, codec in reversed(list(enumerate(priority)))} + + def variant(sub: Subtitle) -> tuple: + signature = self.signature(sub) + return signature[:2] + signature[3:] # the signature without its codec + + best: dict[tuple, int] = {} + for sub in self.subtitles: + if sub.codec in rank: + best[variant(sub)] = min(best.get(variant(sub), rank[sub.codec]), rank[sub.codec]) + self.subtitles = [s for s in self.subtitles if s.codec not in rank or rank[s.codec] == best[variant(s)]] + def sort_videos( self, by_language: Optional[Sequence[Union[str, Language]]] = None, exact_match: bool = False ) -> None: