From 2873cf4c530e55c19cf4ecc2ecd1b4ef75c535f3 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 10 Nov 2026 17:53:14 -0700 Subject: [PATCH] feat(mux): add muxing.default_subtitle_type for the default subtitle Name which subtitle type (forced, normal, sdh) gets the default flag. The forced default now follows the default audio track, not the first. --- docs/guide/output-and-naming.md | 10 ++- docs/guide/subtitles.md | 4 +- docs/reference/configuration/download.md | 20 +++-- tests/tracks/test_mux_default_language.py | 89 ++++++++++++++++++++++- unshackle/core/tracks/subtitle.py | 5 ++ unshackle/core/tracks/tracks.py | 57 +++++++++++++-- 6 files changed, 166 insertions(+), 19 deletions(-) diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index 23d287a7..7a2aa40d 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -424,7 +424,7 @@ DTS:X Profile 1 is DTS-HD Master Audio and muxes to MKV as usual. An MP4 title loses: -- the forced, hearing-impaired and original track flags +- the default, forced, hearing-impaired and original track flags - attachments, such as subtitle fonts - SubStation Alpha (`.ass`) subtitles - container tags (`mkvpropedit`) @@ -447,8 +447,14 @@ muxing: | `merge_video` | bool | `false` | Group video tracks that share the same resolution, range, and codec into one file so only language varies inside it. | | `merge_audio` | bool | `true` | Merge audio tracks of the same kind so multiple languages sit in one file. | | `default_language` | map | *(unset)* | Preferred language per track type (`video` / `audio` / `subtitle`). A track in the preferred language is flagged as the default track. | +| `default_subtitle_type` | str or list | *(unset)* | The subtitle types that can take the default flag, in order: `forced`, `normal`, `sdh` (CC counts as SDH). The first subtitle track of the first type that the title has gets the flag. | -When no track has the preferred language, unshackle applies sensible defaults. The video default falls back to the title language, then the original-language track, then the first track. The audio default is the original-language track. The subtitle default is a forced track in the first audio's language. +When no track has the preferred language, unshackle applies sensible defaults. The video default falls back to the title language, then the original-language track, then the first track. The audio default is the original-language track. The subtitle default is a forced track in the language of the default audio track. + +Set `default_subtitle_type` to give the subtitle default to a different type. `default_subtitle_type: normal` with `default_language.subtitle: pl` puts the flag on the full Polish track when the title also has a forced Polish track. Without `default_language.subtitle`, the first track of the type in file order gets the flag, and a forced track counts only in the language of the default audio track. When the title has no track of a listed type, the rules above apply. + +!!! tip "Use `normal` for players that read the forced flag" + A forced track always carries the Matroska forced flag. A player that reads it, such as mpv or Jellyfin in its Smart mode, shows the forced track when the audio is in your subtitle language and the default track when it is not. `default_subtitle_type: forced` puts the default flag on the forced track, and such a player then shows it in place of the full track. !!! note "`default_language` only sets the default-track flag" `default_language` controls **which track carries the MKV `--default-track` flag**. Nothing else. It does not change track *selection* (that stays with `-l`/`--a-lang` and friends). It also does not touch which track carries the original flag: `--original-flag` still tags the *true* original-audio track. That is the point: you can make your player default to, say, Polish audio on an English-original title without altering the original marker. When the configured language is not present in the manifest, each track type falls back to its normal default rule described above. diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index f56d1899..22eaf2ab 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -156,7 +156,9 @@ Every subtitle track carries flags describing what kind of captions it holds. Th flags drive selection, sort order, output filenames, and the Matroska track flags. - **Forced**: only signs and foreign dialogue, meant to play when the audio language - matches. Off by default. Enable it with `--forced-subs`. + matches. Off by default. Enable it with `--forced-subs`. To choose which type gets the + default flag in the file, set + [`muxing.default_subtitle_type`](../reference/configuration/download.md#muxing). - **SDH** (Subtitles for the Deaf and Hard-of-Hearing, also called HOH): a full transcript that includes both dialogue and non-speech sound cues. - **CC** (Closed Captions): captions originating from an EIA-608/708 broadcast stream, diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index 71573df3..ba149e96 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -236,21 +236,25 @@ subtitle: `en-GB` falls to the alphabetical tail with the rest. Name each tag you want when you use that flag. -!!! note "`type_priority` picks the default subtitle only with `muxing.default_language`" +!!! note "`type_priority` alone does not pick the default subtitle" At mux time the `default` flag goes to every forced subtitle whose language matches the - first audio track, so a full dialogue track never becomes default on its own. - `type_priority` alone does not change that. Name a subtitle language in - [`muxing.default_language`](#muxing) and the first track in that language gets the flag - instead, which is where `type_priority` decides which track that is: + default audio track, so a full dialogue track never becomes default on its own. + `type_priority` changes the order of the tracks in the file. It decides the flag only + between the tracks of one language, when you set `muxing.default_language.subtitle`. Set + [`muxing.default_subtitle_type`](#muxing) to name the type that gets the flag: ```yaml muxing: + default_subtitle_type: [normal, sdh, forced] default_language: subtitle: en - subtitle: - type_priority: [normal, sdh, forced] ``` + Here the flag goes to the full English track, then to the English SDH track when the + title has no full one, then to the forced one. Without `default_language.subtitle`, the + first track of the type in file order gets the flag, in any language. A forced track is + the exception: it gets the flag only in the language of the default audio track. + ## `audio` - **Type:** `dict`  ·  **Default:** `{}` @@ -287,6 +291,7 @@ Matroska (MKV) muxing options. |---------|------|---------|-------------| | `set_title` | bool | `true` | Write a human-readable title into the MKV container. | | `default_language` | dict | `{}` | Force which language is flagged *default* per track type, e.g. `{audio: en, subtitle: en}`. A tag with a region, such as `en-US`, matches only that variant. | +| `default_subtitle_type` | str or list | unset | The subtitle types that can take the *default* flag, in order: `forced`, `normal`, `sdh` (CC counts as SDH, and `cc` is the same as `sdh`). The first subtitle track of the first type that the title has gets the flag. With `default_language.subtitle`, only the tracks in that language count. unshackle ignores a different word with a warning. `normal` is the value for players that read the forced flag. Read [Muxing](../../guide/output-and-naming.md#muxing-options). | | `merge_audio` | bool | `true` | Merge all audio into one file. `--split-audio` on the CLI flips this off. | | `merge_video` | bool | `false` | Merge video tracks that share height, range, and codec into one file, so only language varies inside a file. `--merge-video` on the CLI flips this on. | | `track_names` | str | `english` | The language of the name unshackle gives a track that the service left unnamed: `english` (`Chinese (Traditional)`), `native`, the name in the language itself (`中文(繁體)`), or `none`, which leaves the track unnamed. Read [Track names and language tags](../../guide/output-and-naming.md#track-names-and-language-tags). | @@ -300,6 +305,7 @@ muxing: default_language: audio: en subtitle: en + default_subtitle_type: normal ``` ## `language_tags` diff --git a/tests/tracks/test_mux_default_language.py b/tests/tracks/test_mux_default_language.py index c3a9ddf0..8e14d7b9 100644 --- a/tests/tracks/test_mux_default_language.py +++ b/tests/tracks/test_mux_default_language.py @@ -1,10 +1,14 @@ from __future__ import annotations -from typing import Optional +import logging +from pathlib import Path +from typing import Callable, Optional import pytest +from langcodes import Language -from unshackle.core.tracks import Audio, Tracks +from unshackle.core.config import config +from unshackle.core.tracks import Audio, Subtitle, Tracks @pytest.mark.parametrize( @@ -24,3 +28,84 @@ def test_first_match_index(languages: list[str], preferred: Optional[str], expec for i, lang in enumerate(languages) ] assert Tracks.first_match_index(tracks, preferred) == expected + + +def subtitle(index: int, language: str, kind: str) -> Subtitle: + return Subtitle( + id_=f"s{index}", + url=f"https://x.example/{index}", + language=language, + codec=Subtitle.Codec.WebVTT, + forced=kind == "forced", + sdh=kind == "sdh", + ) + + +@pytest.mark.parametrize( + ("language", "types", "expected"), + [ + ("pl", [], 0), + ("pl", ["normal", "sdh", "forced"], 1), + ("pl", ["forced"], 0), + ("en", ["sdh", "normal"], 3), + ("pl", ["sdh"], 0), + ("fr", ["normal"], None), + (None, ["normal"], 1), + (None, ["sdh", "normal"], 3), + (None, [], None), + ], +) +def test_default_subtitle_index(language: Optional[str], types: list[str], expected: Optional[int]) -> None: + kinds = [("pl", "forced"), ("pl", "normal"), ("en", "normal"), ("en", "sdh")] + subtitles = [subtitle(i, lang, kind) for i, (lang, kind) in enumerate(kinds)] + assert Tracks.default_subtitle_index(subtitles, language, types) == expected + + +@pytest.mark.parametrize( + ("audio", "expected"), + [("en", 1), ("ar", 0), ("fr", 2), (None, 2)], +) +def test_a_forced_default_with_no_language_follows_the_audio(audio: Optional[str], expected: int) -> None: + kinds = [("ar", "forced"), ("en", "forced"), ("en", "normal")] + subtitles = [subtitle(i, lang, kind) for i, (lang, kind) in enumerate(kinds)] + audio_language = Language.get(audio) if audio else None + assert Tracks.default_subtitle_index(subtitles, None, ["forced", "normal"], audio_language) == expected + + +def downloaded_tracks(tmp_path: Path, audio: list[tuple[str, bool]], subtitles: list[tuple[str, str]]) -> Tracks: + tracks = Tracks() + for i, (lang, original) in enumerate(audio): + track = Audio(id_=f"a{i}", url=f"https://x.example/a{i}", language=lang, is_original_lang=original) + tracks.add(track) + for i, (lang, kind) in enumerate(subtitles): + tracks.add(subtitle(i, lang, kind)) + for track in tracks: + track.path = tmp_path / f"{track.id}.bin" + track.path.write_bytes(b"\x00") + return tracks + + +def subtitle_defaults(args: list[str], count: int) -> list[str]: + return [args[i + 1] for i, arg in enumerate(args) if arg == "--default-track"][-count:] + + +def test_mux_reads_default_subtitle_type( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + mux_args: Callable[..., list[str]], +) -> None: + monkeypatch.setattr(config, "muxing", {"default_subtitle_type": ["full", "CC", "Normal"]}) + tracks = downloaded_tracks(tmp_path, [("en", True)], [("pl", "forced"), ("pl", "normal"), ("en", "sdh")]) + + with caplog.at_level(logging.WARNING): + args = mux_args(tracks) + + assert subtitle_defaults(args, 3) == ["0:False", "0:False", "0:True"] + assert "'full' is not a subtitle type" in caplog.text + + +def test_the_forced_default_follows_the_default_audio(tmp_path: Path, mux_args: Callable[..., list[str]]) -> None: + tracks = downloaded_tracks(tmp_path, [("en", False), ("pl", True)], [("en", "forced"), ("pl", "forced")]) + + assert subtitle_defaults(mux_args(tracks), 2) == ["0:False", "0:True"] diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index 3e2d13a4..b66e40fe 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -232,6 +232,11 @@ class Subtitle(Track): ) ) + @property + def kind(self) -> str: + """The subtitle type that the config names: "forced", "sdh" (CC counts as SDH) or "normal".""" + return "forced" if self.forced else "sdh" if (self.sdh or self.cc) else "normal" + def get_track_name(self) -> Optional[str]: """Return the base Track Name.""" track_name = super().get_track_name() or "" diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index 2db964d2..77b127fe 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -418,11 +418,7 @@ class Tracks: 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 - ) - ) + self.subtitles.sort(key=lambda x: rank.get(x.kind, default_rank)) else: self.subtitles.sort(key=lambda x: x.sdh or x.cc) self.subtitles.sort(key=lambda x: x.forced, reverse=True) @@ -616,6 +612,32 @@ class Tracks: wanted = matching_languages(language, [t.language for t in tracks]) return next((idx for idx, t in enumerate(tracks) if str(t.language) in wanted), None) + @staticmethod + def default_subtitle_index( + subtitles: Sequence[Subtitle], + language: Optional[str], + types: Sequence[str], + audio_language: Optional[Language] = None, + ) -> Optional[int]: + """The index of the first subtitle of the first type in `types` that a track has. + + `language` limits the choice to the tracks it asks for. With no `types`, or no track + of a listed type, the first track in `language` is the answer. With no `language`, a + forced track counts only in `audio_language`, because it adds to that audio alone. + """ + if language: + wanted = matching_languages(language, [t.language for t in subtitles]) + candidates = [(idx, t) for idx, t in enumerate(subtitles) if str(t.language) in wanted] + fallback = candidates[0][0] if candidates else None + else: + candidates = [ + (idx, t) + for idx, t in enumerate(subtitles) + if not t.forced or (audio_language and is_close_match(t.language, [audio_language])) + ] + fallback = None + return next((idx for kind in types for idx, t in candidates if t.kind == kind), fallback) + @property def sample_base(self) -> float: """The earliest ``sample_start`` of the video and audio tracks, which a sample mux uses as time zero.""" @@ -884,8 +906,27 @@ class Tracks: preferred_video_idx = self.first_match_index(self.videos, preferred_video_lang) preferred_audio_idx = self.first_match_index(self.audio, preferred_audio_lang) + if preferred_audio_idx is not None: + default_audio: Optional[Audio] = self.audio[preferred_audio_idx] + else: + default_audio = next( + (at for at in self.audio if at.is_original_lang), self.audio[0] if self.audio else None + ) + default_audio_lang = default_audio.language if default_audio else None + + subtitle_types = [] + for value in as_list(config.muxing.get("default_subtitle_type") or []): + kind = "sdh" if str(value).lower() == "cc" else str(value).lower() + if kind in ("forced", "normal", "sdh"): + subtitle_types.append(kind) + else: + logging.getLogger("Tracks").warning("%r is not a subtitle type, ignoring it", value) preferred_subtitle_idx = ( - None if skip_subtitles else self.first_match_index(self.subtitles, preferred_subtitle_lang) + None + if skip_subtitles + else self.default_subtitle_index( + self.subtitles, preferred_subtitle_lang, subtitle_types, default_audio_lang + ) ) for i, vt in enumerate(self.videos): @@ -989,7 +1030,9 @@ class Tracks: if preferred_subtitle_idx is not None: default = i == preferred_subtitle_idx else: - default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced) + default = bool( + st.forced and default_audio_lang and is_close_match(st.language, [default_audio_lang]) + ) cl.extend( [ "--track-name",