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.
This commit is contained in:
imSp4rky
2026-07-07 17:36:58 -06:00
parent 1dba4a8f82
commit 4ebe41fafd
4 changed files with 100 additions and 4 deletions
+12
View File
@@ -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:** `{}`
+68
View File
@@ -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")]
+1 -1
View File
@@ -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()
+19 -3
View File
@@ -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":