mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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.
This commit is contained in:
@@ -424,7 +424,7 @@ DTS:X Profile 1 is DTS-HD Master Audio and muxes to MKV as usual.
|
|||||||
|
|
||||||
An MP4 title loses:
|
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
|
- attachments, such as subtitle fonts
|
||||||
- SubStation Alpha (`.ass`) subtitles
|
- SubStation Alpha (`.ass`) subtitles
|
||||||
- container tags (`mkvpropedit`)
|
- 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_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. |
|
| `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_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"
|
!!! 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.
|
`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.
|
||||||
|
|||||||
@@ -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.
|
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
|
- **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
|
- **SDH** (Subtitles for the Deaf and Hard-of-Hearing, also called HOH): a full
|
||||||
transcript that includes both dialogue and non-speech sound cues.
|
transcript that includes both dialogue and non-speech sound cues.
|
||||||
- **CC** (Closed Captions): captions originating from an EIA-608/708 broadcast stream,
|
- **CC** (Closed Captions): captions originating from an EIA-608/708 broadcast stream,
|
||||||
|
|||||||
@@ -236,21 +236,25 @@ subtitle:
|
|||||||
`en-GB` falls to the alphabetical tail with the rest. Name each tag you want when you use
|
`en-GB` falls to the alphabetical tail with the rest. Name each tag you want when you use
|
||||||
that flag.
|
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
|
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.
|
default 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
|
`type_priority` changes the order of the tracks in the file. It decides the flag only
|
||||||
[`muxing.default_language`](#muxing) and the first track in that language gets the flag
|
between the tracks of one language, when you set `muxing.default_language.subtitle`. Set
|
||||||
instead, which is where `type_priority` decides which track that is:
|
[`muxing.default_subtitle_type`](#muxing) to name the type that gets the flag:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
muxing:
|
muxing:
|
||||||
|
default_subtitle_type: [normal, sdh, forced]
|
||||||
default_language:
|
default_language:
|
||||||
subtitle: en
|
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`
|
## `audio`
|
||||||
|
|
||||||
- **Type:** `dict` · **Default:** `{}`
|
- **Type:** `dict` · **Default:** `{}`
|
||||||
@@ -287,6 +291,7 @@ Matroska (MKV) muxing options.
|
|||||||
|---------|------|---------|-------------|
|
|---------|------|---------|-------------|
|
||||||
| `set_title` | bool | `true` | Write a human-readable title into the MKV container. |
|
| `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_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_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. |
|
| `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). |
|
| `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:
|
default_language:
|
||||||
audio: en
|
audio: en
|
||||||
subtitle: en
|
subtitle: en
|
||||||
|
default_subtitle_type: normal
|
||||||
```
|
```
|
||||||
|
|
||||||
## `language_tags`
|
## `language_tags`
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
import pytest
|
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(
|
@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)
|
for i, lang in enumerate(languages)
|
||||||
]
|
]
|
||||||
assert Tracks.first_match_index(tracks, preferred) == expected
|
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"]
|
||||||
|
|||||||
@@ -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]:
|
def get_track_name(self) -> Optional[str]:
|
||||||
"""Return the base Track Name."""
|
"""Return the base Track Name."""
|
||||||
track_name = super().get_track_name() or ""
|
track_name = super().get_track_name() or ""
|
||||||
|
|||||||
@@ -418,11 +418,7 @@ class Tracks:
|
|||||||
if type_priority:
|
if type_priority:
|
||||||
rank = {str(t).lower(): i for i, t in enumerate(type_priority)}
|
rank = {str(t).lower(): i for i, t in enumerate(type_priority)}
|
||||||
default_rank = len(rank)
|
default_rank = len(rank)
|
||||||
self.subtitles.sort(
|
self.subtitles.sort(key=lambda x: rank.get(x.kind, default_rank))
|
||||||
key=lambda x: rank.get(
|
|
||||||
"forced" if x.forced else "sdh" if (x.sdh or x.cc) else "normal", default_rank
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.subtitles.sort(key=lambda x: x.sdh or x.cc)
|
self.subtitles.sort(key=lambda x: x.sdh or x.cc)
|
||||||
self.subtitles.sort(key=lambda x: x.forced, reverse=True)
|
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])
|
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)
|
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
|
@property
|
||||||
def sample_base(self) -> float:
|
def sample_base(self) -> float:
|
||||||
"""The earliest ``sample_start`` of the video and audio tracks, which a sample mux uses as time zero."""
|
"""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_video_idx = self.first_match_index(self.videos, preferred_video_lang)
|
||||||
preferred_audio_idx = self.first_match_index(self.audio, preferred_audio_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 = (
|
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):
|
for i, vt in enumerate(self.videos):
|
||||||
@@ -989,7 +1030,9 @@ class Tracks:
|
|||||||
if preferred_subtitle_idx is not None:
|
if preferred_subtitle_idx is not None:
|
||||||
default = i == preferred_subtitle_idx
|
default = i == preferred_subtitle_idx
|
||||||
else:
|
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(
|
cl.extend(
|
||||||
[
|
[
|
||||||
"--track-name",
|
"--track-name",
|
||||||
|
|||||||
Reference in New Issue
Block a user