merge: subtitle.group_by track option

This commit is contained in:
imSp4rky
2026-08-02 10:50:20 -06:00
5 changed files with 141 additions and 24 deletions
+10 -3
View File
@@ -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.
+25 -5
View File
@@ -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:
+69
View File
@@ -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"),
]
+5 -1
View File
@@ -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()
+32 -15
View File
@@ -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":