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 4d9015f5..eec8fb91 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 685ac411..a8615afb 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -2397,7 +2397,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":