feat(tracks): add subtitle.dedupe_codecs to keep one codec per subtitle variant

This commit is contained in:
imSp4rky
2026-10-31 16:35:01 -06:00
parent 65e807bf5f
commit f9c1c3d390
5 changed files with 52 additions and 3 deletions
+2
View File
@@ -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: <backend>` |
Two keys read as if they control markup, and do not:
+1
View File
@@ -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:
+28 -3
View File
@@ -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]
+4
View File
@@ -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):
+17
View File
@@ -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: