From 4ebe41fafdbc885cf4d950be8a8bd87823c99698 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 7 Jul 2026 17:36:58 -0600 Subject: [PATCH] feat(tracks): add subtitle.type_priority config for variant ordering Optional ranking of forced/normal/sdh per language, mirroring audio.codec_priority; first track gets the default flag, so this also picks the default subtitle. Unset = existing behavior. --- docs/reference/configuration.md | 12 ++++++ tests/tracks/test_subtitle_sort.py | 68 ++++++++++++++++++++++++++++++ unshackle/commands/dl.py | 2 +- unshackle/core/tracks/tracks.py | 22 ++++++++-- 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/tracks/test_subtitle_sort.py diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 04f6c8d6..807aa0f4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -371,6 +371,7 @@ written as sidecar files. See [Subtitles](../guide/subtitles.md) for the full gu | `preferred_conversion_method` | str | *(unset)* | Preferred converter when several are available. | | `output_mode` | str | `"mux"` | `mux` embeds subtitles in the MKV; `sidecar` writes separate files. | | `sidecar_format` | str | `"srt"` | Format for sidecar files when `output_mode: sidecar`. | +| `type_priority` | list | *(unset)* | Ordered ranking of subtitle types (`forced`, `normal`, `sdh`; CC counts as SDH) that sorts the variants within each language. Types you leave out fall to the end. | ```yaml subtitle: @@ -379,6 +380,17 @@ subtitle: sidecar_format: srt ``` +!!! note "`type_priority` also picks the default subtitle" + `type_priority` sorts the tracks within each language, and at mux time the first track + in the default subtitle language gets the `default` flag. The built-in order is `forced`, + `normal`, `sdh`, which means a forced track becomes default whenever one exists. If you + want the full dialogue track as default instead, set: + + ```yaml + subtitle: + type_priority: [normal, sdh, forced] + ``` + ### `audio` - **Type:** `dict`  ยท  **Default:** `{}` diff --git a/tests/tracks/test_subtitle_sort.py b/tests/tracks/test_subtitle_sort.py new file mode 100644 index 00000000..5251603e --- /dev/null +++ b/tests/tracks/test_subtitle_sort.py @@ -0,0 +1,68 @@ +import pytest + +from unshackle.core.tracks import Subtitle, Tracks + + +def sub(lang: str, forced: bool = False, sdh: bool = False, cc: bool = False) -> Subtitle: + return Subtitle( + url=f"https://example.test/{lang}-{forced}-{sdh}-{cc}", + language=lang, + codec=Subtitle.Codec.SubRip, + forced=forced, + sdh=sdh, + cc=cc, + ) + + +def shape(tracks: Tracks) -> list[tuple[str, str]]: + return [ + (str(s.language), "forced" if s.forced else "sdh" if (s.sdh or s.cc) else "normal") for s in tracks.subtitles + ] + + +@pytest.fixture +def tracks() -> Tracks: + t = Tracks() + t.add([sub("en", sdh=True), sub("en", forced=True), sub("en"), sub("it", forced=True), sub("it")]) + return t + + +def test_default_order_is_forced_normal_sdh(tracks: Tracks) -> None: + tracks.sort_subtitles(by_language=["en"]) + assert shape(tracks) == [ + ("en", "forced"), + ("en", "normal"), + ("en", "sdh"), + ("it", "forced"), + ("it", "normal"), + ] + + +def test_type_priority_overrides_group_order(tracks: Tracks) -> None: + tracks.sort_subtitles(by_language=["en"], type_priority=["normal", "sdh", "forced"]) + assert shape(tracks) == [ + ("en", "normal"), + ("en", "sdh"), + ("en", "forced"), + ("it", "normal"), + ("it", "forced"), + ] + + +def test_type_priority_unlisted_types_fall_to_end(tracks: Tracks) -> None: + tracks.sort_subtitles(by_language=["en"], type_priority=["normal"]) + # unlisted types keep their pre-sort relative order (stable sort) + assert shape(tracks) == [ + ("en", "normal"), + ("en", "sdh"), + ("en", "forced"), + ("it", "normal"), + ("it", "forced"), + ] + + +def test_cc_counts_as_sdh() -> None: + t = Tracks() + t.add([sub("en", cc=True), sub("en")]) + t.sort_subtitles(by_language=["en"], type_priority=["normal", "sdh"]) + assert shape(t) == [("en", "normal"), ("en", "sdh")] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 95a17e4d..9dc218ef 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -2229,7 +2229,7 @@ class dl: by_language=processed_audio_sort_lang, codec_priority=config.audio.get("codec_priority"), ) - title.tracks.sort_subtitles(by_language=s_lang) + title.tracks.sort_subtitles(by_language=s_lang, type_priority=config.subtitle.get("type_priority")) if list_: available_tracks, _ = title.tracks.tree() diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index e77aa5d0..88d04cf7 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -277,7 +277,11 @@ class Tracks: continue self.audio.sort(key=lambda x: not is_close_match(language, [x.language])) - def sort_subtitles(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None: + def sort_subtitles( + self, + by_language: Optional[Sequence[Union[str, Language]]] = None, + type_priority: Optional[Sequence[str]] = None, + ) -> None: """ Sort subtitle tracks by various track attributes to a common P2P standard. You may optionally provide a sequence of languages to prioritize to the top. @@ -292,13 +296,25 @@ class Tracks: - Normal - Hard of Hearing (SDH/CC) (Least to most captions expected in the subtitle) + + type_priority overrides the Language Group Order with an explicit ranking of + "forced", "normal", and "sdh" (cc counts as sdh); unlisted types fall to the end. + The first track after sorting receives the default flag at mux time, so this also + controls which subtitle type becomes default. """ if not self.subtitles: return # language groups self.subtitles.sort(key=lambda x: str(x.language)) - self.subtitles.sort(key=lambda x: x.sdh or x.cc) - self.subtitles.sort(key=lambda x: x.forced, reverse=True) + if type_priority: + rank = {str(t).lower(): i for i, t in enumerate(type_priority)} + default_rank = len(rank) + self.subtitles.sort( + key=lambda x: rank.get("forced" if x.forced else "sdh" if (x.sdh or x.cc) else "normal", default_rank) + ) + else: + self.subtitles.sort(key=lambda x: x.sdh or x.cc) + self.subtitles.sort(key=lambda x: x.forced, reverse=True) # sections for language in reversed(by_language or []): if str(language) == "all":