From 8baf064750510a01d9c16bf3b8b508ae051ffc84 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sun, 2 Aug 2026 10:45:28 -0600 Subject: [PATCH] feat(tracks): add subtitle.group_by to keep a language with its variants sort_subtitles applied the language sort before the type sort. Python sorts are stable, so the last pass wins and type became the major key: every forced track, then every normal, then every SDH, each block ordered by language. In a title with many languages this put a track far from its own SDH version, for example Finnish at track 5 and Finnish SDH at track 42. Add a group_by parameter that selects the major key. "type" is the default and keeps the current order byte for byte. "language" swaps the two passes so each language stays next to its own variants. Read it from the new subtitle.group_by config key. Also correct the docs: type_priority was described as sorting the variants within each language, which was never true. It ranks the types across all languages. --- docs/guide/subtitles.md | 13 ++++-- docs/reference/configuration.md | 30 ++++++++++--- tests/tracks/test_subtitle_sort.py | 69 ++++++++++++++++++++++++++++++ unshackle/commands/dl.py | 6 ++- unshackle/core/tracks/tracks.py | 47 +++++++++++++------- 5 files changed, 141 insertions(+), 24 deletions(-) diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index f7c96107..8823be19 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -134,9 +134,16 @@ flags drive selection, sort order, output filenames, and the Matroska track flag often uppercase with `>>>` speaker markers and sound cues. !!! note "SDH and CC are treated together for sorting and stripping" - Within a language, unshackle orders subtitles from fewest to most captions: - **Forced → Normal → SDH/CC**. For the purposes of hearing-impaired handling, SDH and - CC tracks are grouped as the "most captions" variant. + unshackle orders subtitles from fewest to most captions: **Forced → Normal → SDH/CC**. + For the purposes of hearing-impaired handling, SDH and CC tracks are grouped as the + "most captions" variant. + + By default this type order is the primary sort, so all forced tracks come first, then + all normal tracks, then all SDH/CC tracks, each block sorted by language. To keep each + language next to its own variants instead, set + [`subtitle.group_by`](../reference/configuration.md#subtitle) to `language`. Use + [`subtitle.type_priority`](../reference/configuration.md#subtitle) to change the + Forced → Normal → SDH/CC order itself. A single track cannot be both CC and SDH, and a forced track cannot also be flagged CC or SDH. These combinations are rejected as invalid. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 45a00d26..2eab1d3b 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -375,7 +375,8 @@ 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; `both` writes sidecars and still muxes. | | `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. | +| `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. | ```yaml subtitle: @@ -384,11 +385,30 @@ subtitle: sidecar_format: srt ``` +!!! note "`group_by` puts a language next to its own SDH track" + The default `type` order groups by subtitle type first, so every normal track comes + before every SDH track. In a title with many languages, a track ends up far from its + own SDH version, for example Finnish at track 5 and Finnish SDH at track 42. Set + `group_by: language` to keep them together: + + ```yaml + subtitle: + group_by: language + ``` + + | `group_by` | Result | + |---|---| + | `type` | Danish, Finnish, French, … Danish SDH, Finnish SDH, French SDH | + | `language` | Danish, Danish SDH, Finnish, Finnish SDH, French, French SDH | + + `type_priority` still sets the order of the variants, so with `group_by: language` it + controls the order inside each language. + !!! 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: + 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: diff --git a/tests/tracks/test_subtitle_sort.py b/tests/tracks/test_subtitle_sort.py index 5251603e..d114eae0 100644 --- a/tests/tracks/test_subtitle_sort.py +++ b/tests/tracks/test_subtitle_sort.py @@ -66,3 +66,72 @@ def test_cc_counts_as_sdh() -> None: 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")] + + +@pytest.fixture +def unprioritised() -> Tracks: + """Languages that no by_language argument floats to the top.""" + t = Tracks() + t.add([sub("fi", sdh=True), sub("fi"), sub("de", sdh=True), sub("de")]) + return t + + +def test_type_grouping_separates_a_language_from_its_sdh(unprioritised: Tracks) -> None: + unprioritised.sort_subtitles() + assert shape(unprioritised) == [ + ("de", "normal"), + ("fi", "normal"), + ("de", "sdh"), + ("fi", "sdh"), + ] + + +def test_language_grouping_keeps_a_language_with_its_sdh(unprioritised: Tracks) -> None: + unprioritised.sort_subtitles(group_by="language") + assert shape(unprioritised) == [ + ("de", "normal"), + ("de", "sdh"), + ("fi", "normal"), + ("fi", "sdh"), + ] + + +def test_language_grouping_still_honours_type_priority_and_sections(tracks: Tracks) -> None: + # de sits outside by_language, where type-major sorting would interleave it with en + tracks.add([sub("de"), sub("de", sdh=True)]) + tracks.sort_subtitles(by_language=["it"], type_priority=["normal", "sdh", "forced"], group_by="language") + assert shape(tracks) == [ + ("it", "normal"), + ("it", "forced"), + ("de", "normal"), + ("de", "sdh"), + ("en", "normal"), + ("en", "sdh"), + ("en", "forced"), + ] + + +def test_language_grouping_keeps_regional_variants_adjacent_through_sections() -> None: + # "en" floats en-GB and en-US as one block, so each exact tag keeps its variants + t = Tracks() + t.add( + [ + sub("en-US", sdh=True), + sub("fi"), + sub("en-GB"), + sub("en-US"), + sub("fi", sdh=True), + sub("en-GB", forced=True), + sub("en-GB", sdh=True), + ] + ) + t.sort_subtitles(by_language=["en"], group_by="language") + assert shape(t) == [ + ("en-GB", "forced"), + ("en-GB", "normal"), + ("en-GB", "sdh"), + ("en-US", "normal"), + ("en-US", "sdh"), + ("fi", "normal"), + ("fi", "sdh"), + ] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 8ef9832e..4dd85332 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -2368,7 +2368,11 @@ class dl: by_language=processed_audio_sort_lang, codec_priority=config.audio.get("codec_priority"), ) - title.tracks.sort_subtitles(by_language=s_lang, type_priority=config.subtitle.get("type_priority")) + title.tracks.sort_subtitles( + by_language=s_lang, + type_priority=config.subtitle.get("type_priority"), + group_by=config.subtitle.get("group_by"), + ) if list_: available_tracks, _ = title.tracks.tree() diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index 5c4207d8..02bb6d9d 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -289,6 +289,7 @@ class Tracks: self, by_language: Optional[Sequence[Union[str, Language]]] = None, type_priority: Optional[Sequence[str]] = None, + group_by: Optional[str] = None, ) -> None: """ Sort subtitle tracks by various track attributes to a common P2P standard. @@ -299,30 +300,46 @@ class Tracks: - then rest ascending alphabetically after the prioritized groups (Each section ascending alphabetically, but separated) - Language Group Order: + Type Order: - Forced - 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. + type_priority overrides the Type Order with an explicit ranking of "forced", + "normal", and "sdh" (cc counts as sdh); unlisted types fall to the end. + + group_by sets the major key. "type" (default) keeps every forced track together, + then every normal, then every SDH, each block ascending by language. "language" + groups by language instead, so Finnish sits next to Finnish SDH, with the Type + Order applied inside each language. """ if not self.subtitles: return - # language groups - self.subtitles.sort(key=lambda x: str(x.language)) - 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) - ) + + def by_type() -> None: + 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) + + def by_lang() -> None: + self.subtitles.sort(key=lambda x: str(x.language)) + + # stable sorts, so the last pass is the major key + if str(group_by or "type").lower() == "language": + by_type() + by_lang() else: - self.subtitles.sort(key=lambda x: x.sdh or x.cc) - self.subtitles.sort(key=lambda x: x.forced, reverse=True) + by_lang() + by_type() # sections for language in reversed(by_language or []): if str(language) == "all":