feat(dl)!: treat a missing -sl language as a warning

A missing -sl language now warns and continues, with or without --best-available. --require-subs counts a forced track only when selection keeps it, and a title with no track left is skipped instead of failing at mux.

BREAKING CHANGE: a missing -sl subtitle language no longer fails the run. Use --require-subs to fail on it.
This commit is contained in:
imSp4rky
2026-11-09 15:01:54 -07:00
parent 2ddc69970f
commit 4b6d15cc2a
10 changed files with 96 additions and 63 deletions
+1 -1
View File
@@ -450,7 +450,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is
| `wanted` | string / string[] | `[]` | Episode/season selectors, as a list or as one comma-separated string. Accepts the part form, `"S01E01.2"`, and the air-date form, `"2026-08-11"` or `"2026-08-01:2026-08-31"`. For a music release, a selector is a track number, `"1-5"` or `"1,3,7"`, or `"{disc}x{track}"` such as `"2x3"`. | | `wanted` | string / string[] | `[]` | Episode/season selectors, as a list or as one comma-separated string. Accepts the part form, `"S01E01.2"`, and the air-date form, `"2026-08-11"` or `"2026-08-01:2026-08-31"`. For a music release, a selector is a track number, `"1-5"` or `"1,3,7"`, or `"{disc}x{track}"` such as `"2x3"`. |
| `latest_episode` | boolean | `false` | Only the newest episode. | | `latest_episode` | boolean | `false` | Only the newest episode. |
| `latest_episodes` | integer | `null` | Only the N newest episodes. A positive integer. When set, it overrides `latest_episode`. | | `latest_episodes` | integer | `null` | Only the N newest episodes. A positive integer. When set, it overrides `latest_episode`. |
| `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. | | `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. A missing `s_lang` language gives a warning and the job continues. |
| `require_audio` | string[] | `[]` | Audio languages that must exist. The job fails if one is missing, even with `best_available`. | | `require_audio` | string[] | `[]` | Audio languages that must exist. The job fails if one is missing, even with `best_available`. |
| `require_video` | string[] | `[]` | Video languages that must exist. The job fails if one is missing, even with `best_available`. | | `require_video` | string[] | `[]` | Video languages that must exist. The job fails if one is missing, even with `best_available`. |
| `require_subs` | string[] | `[]` | Subtitle languages that must exist. The job fails if one is missing. `s_lang` still decides what to keep. | | `require_subs` | string[] | `[]` | Subtitle languages that must exist. The job fails if one is missing. `s_lang` still decides what to keep. |
+1 -1
View File
@@ -84,7 +84,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
| `-c`, `--channels` | - | Audio channels; matches sub-layouts (5.1 ≈ 6.0). | | `-c`, `--channels` | - | Audio channels; matches sub-layouts (5.1 ≈ 6.0). |
| `-naa`, `--noatmos` | off | Exclude Dolby Atmos audio tracks. | | `-naa`, `--noatmos` | off | Exclude Dolby Atmos audio tracks. |
| `--worst` | off | Pick the lowest bitrate within the requested quality. **Requires `-q`.** | | `--worst` | off | Pick the lowest bitrate within the requested quality. **Requires `-q`.** |
| `--best-available`, `--warn-only` | off | Warn and continue when a requested resolution, range, or language is absent, instead of failing. With `--all-drm`, also when one DRM system does not license. | | `--best-available`, `--warn-only` | off | Warn and continue when a requested resolution, range, video language, or audio language is absent, instead of failing. With `--all-drm`, also when one DRM system does not license. |
| `-rvb`, `--real-video-bitrate` | off | Probe real media size for true video bitrates, overriding the manifest. | | `-rvb`, `--real-video-bitrate` | off | Probe real media size for true video bitrates, overriding the manifest. |
| `-rab`, `--real-audio-bitrate` | off | Same for audio (slower). | | `-rab`, `--real-audio-bitrate` | off | Same for audio (slower). |
+3 -2
View File
@@ -97,7 +97,7 @@ By default a missing requested resolution is an error. Two flags change that:
| Flag | Behaviour | | Flag | Behaviour |
| --- | --- | | --- | --- |
| `--best-available` (`--warn-only`) | If the requested resolution(s) are not present, continue with the best that *is* available instead of failing. Also softens missing video/audio/subtitle languages and hybrid fallbacks, and with `--all-drm` a DRM system that does not license. | | `--best-available` (`--warn-only`) | If the requested resolution(s) are not present, continue with the best that *is* available instead of failing. Also softens missing video and audio languages and hybrid fallbacks, and with `--all-drm` a DRM system that does not license. |
| `--worst` | Within the specified quality, pick the **lowest** bitrate rendition. **Requires `-q/--quality`.** | | `--worst` | Within the specified quality, pick the **lowest** bitrate rendition. **Requires `-q/--quality`.** |
```shell title="Never fail on a missing resolution" ```shell title="Never fail on a missing resolution"
@@ -421,7 +421,8 @@ selects `pt-BR`. If the title has no such variant, the tag takes every regional
A tag with a region asks for that variant only: `-l es-419` selects `es-419`, never `es-ES` A tag with a region asks for that variant only: `-l es-419` selects `es-419`, never `es-ES`
or `es-MX`. If the service has no `es-419` track, unshackle reports `es-419 not found` and or `es-MX`. If the service has no `es-419` track, unshackle reports `es-419 not found` and
stops. With `--best-available`, it continues with the other languages. stops. With `--best-available`, it continues with the other languages. A missing `-sl`
language always gives a warning and the download continues; `--require-subs` makes it fail.
A track without a region matches only the region its language implies. `en` is US English A track without a region matches only the region its language implies. `en` is US English
and `pt` is Brazilian Portuguese, so `-l en-US` accepts a track tagged `en`, but `-l en-GB` and `pt` is Brazilian Portuguese, so `-l en-US` accepts a track tagged `en`, but `-l en-GB`
+7 -11
View File
@@ -75,7 +75,7 @@ Subtitle selection happens through `dl` flags. The most important is language se
| Flag | Purpose | | Flag | Purpose |
| --- | --- | | --- | --- |
| `-sl`, `--s-lang` | Language(s) wanted for subtitles. Defaults to `all`. A `-` prefix excludes, e.g. `all,-es`. | | `-sl`, `--s-lang` | Language(s) wanted for subtitles. Defaults to `all`. A `-` prefix excludes, e.g. `all,-es`. |
| `--require-subs` | Require these languages to exist; if present, download **all** subtitles. Cannot be combined with `--s-lang`. | | `--require-subs` | Require these languages to exist. The title fails if one is missing. `--s-lang` still decides what to keep. |
| `-fs`, `--forced-subs` | Include forced subtitle tracks (excluded by default). | | `-fs`, `--forced-subs` | Include forced subtitle tracks (excluded by default). |
| `-fso`, `--forced-subs-only` | Keep only forced subtitle tracks and drop the other subtitles; implies `-fs`. | | `-fso`, `--forced-subs-only` | Keep only forced subtitle tracks and drop the other subtitles; implies `-fs`. |
| `-fsl`, `--forced-s-lang` | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. | | `-fsl`, `--forced-s-lang` | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. |
@@ -102,25 +102,21 @@ that variant:
unshackle dl --s-lang es-419 EXAMPLE 'https://www.example.com/...' unshackle dl --s-lang es-419 EXAMPLE 'https://www.example.com/...'
``` ```
If a requested language is missing, unshackle stops with an error. With `--best-available`, If a requested language is missing, unshackle continues with the languages it found and
it continues with the languages it found and tells you which ones it dropped. See tells you which ones it dropped. To stop the title instead, use `--require-subs`. See
[exact vs fuzzy matching](downloading.md#exact-vs-fuzzy-matching) for the full rules. [exact vs fuzzy matching](downloading.md#exact-vs-fuzzy-matching) for the full rules.
### Requiring languages before downloading ### Requiring languages before downloading
`--require-subs` is a gate rather than a filter. It downloads **all** available `--require-subs` is a gate rather than a filter. If any language you name is missing, the
subtitles, but only if every language you name is present. If any is missing, the title title fails, even under `--best-available`. It does not select tracks: `--s-lang` still
errors out. This is useful for batch jobs where you want a title without your must-have decides what to keep. This is useful for batch jobs where you want a title without your
language to fail loudly, and not to silently give a partial result. must-have language to fail loudly, and not to silently give a partial result.
```shell title="Fail unless both English and French subs exist, then grab everything" ```shell title="Fail unless both English and French subs exist, then grab everything"
unshackle dl --require-subs en,fr EXAMPLE B0ABCDEFGH unshackle dl --require-subs en,fr EXAMPLE B0ABCDEFGH
``` ```
!!! warning "`--require-subs` and `--s-lang` are mutually exclusive"
Because `--require-subs` forces downloading of all subtitle languages, it cannot be
combined with `--s-lang`. Choose one or the other.
### Forced subtitles ### Forced subtitles
Forced tracks carry only the important on-screen signs and foreign-dialogue lines meant Forced tracks carry only the important on-screen signs and foreign-dialogue lines meant
+1 -1
View File
@@ -51,7 +51,7 @@ Common config keys. This is a useful subset, and every `dl` flag works:
| `workers` | int | *(downloader default)* | Threads per track. | | `workers` | int | *(downloader default)* | Threads per track. |
| `speed_limit` | str | *(unlimited)* | Total download speed cap across all downloads combined, e.g. `500k`, `5M`, `1.5G` or plain bytes/sec. Values are bytes, not bits (`5M` = 5.0 MB/s). | | `speed_limit` | str | *(unlimited)* | Total download speed cap across all downloads combined, e.g. `500k`, `5M`, `1.5G` or plain bytes/sec. Values are bytes, not bits (`5M` = 5.0 MB/s). |
| `slow` | str | *(unset)* | Inter-title delay, e.g. `"20-40"`. | | `slow` | str | *(unset)* | Inter-title delay, e.g. `"20-40"`. |
| `best_available` | bool | `false` | Warn instead of failing when a requested resolution, range, or language is missing, or when one DRM system does not license under `--all-drm`. The CLI flag is `--best-available`, also spelled `--warn-only`. | | `best_available` | bool | `false` | Warn instead of failing when a requested resolution, range, video language, or audio language is missing, or when one DRM system does not license under `--all-drm`. The CLI flag is `--best-available`, also spelled `--warn-only`. |
| `generate_dv` | bool | `false` | With `-r HYBRID`, make Dolby Vision from the HDR10+ metadata when a title has no DV track. Needs `hdr10plus_tool`. See [Dolby Vision from HDR10+](../../guide/downloading.md#dolby-vision-from-hdr10). | | `generate_dv` | bool | `false` | With `-r HYBRID`, make Dolby Vision from the HDR10+ metadata when a title has no DV track. Needs `hdr10plus_tool`. See [Dolby Vision from HDR10+](../../guide/downloading.md#dolby-vision-from-hdr10). |
| `proxy` | str | *(unset)* | Default proxy URI or 2-letter country. | | `proxy` | str | *(unset)* | Default proxy URI or 2-letter country. |
| `no_folder` | bool | `false` | Do not create a per-title folder. | | `no_folder` | bool | `false` | Do not create a per-title folder. |
@@ -141,6 +141,14 @@ def test_a_single_title_with_no_such_track_exits(
assert "Skipping" not in caplog.text assert "Skipping" not in caplog.text
def test_subs_only_with_no_matching_subtitle_exits_a_single_title_and_skips_in_a_season(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
assert run(monkeypatch, caplog, {1: {}}, subs_only=True, s_lang=["fr"]) == 1
assert run(monkeypatch, caplog, {1: {}, 2: {}}, subs_only=True, s_lang=["fr"]) is None
assert caplog.text.count("There's no track left for the selected options. Skipping Show S01E") == 2
def test_a_multi_title_run_skips_the_title_and_continues( def test_a_multi_title_run_skips_the_title_and_continues(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -147,8 +147,41 @@ def test_a_forced_only_language_without_fs_is_reported_missing(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
service = RegionalService("en", ["ja"], [("en", ""), ("es-419", ""), ("es-ES", "forced")]) service = RegionalService("en", ["ja"], [("en", ""), ("es-419", ""), ("es-ES", "forced")])
assert run(monkeypatch, caplog, service, lang=["ja"], s_lang=["en", "es-ES"]) == 1 ids = run(monkeypatch, caplog, service, lang=["ja"], s_lang=["en", "es-ES"])
assert "es-ES not found in subtitle tracks" in caplog.text assert "es-ES not found in subtitle tracks, continuing with: en" in caplog.text
assert ids["subs"] == ["en"]
def test_a_missing_subtitle_language_continues_without_subtitles(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
service = RegionalService("en", ["ja"], [("en", "")])
ids = run(monkeypatch, caplog, service, lang=["ja"], s_lang=["fr"])
assert "fr not found in subtitle tracks, continuing without subtitles" in caplog.text
assert ids["subs"] == []
def test_a_missing_orig_subtitle_is_reported_as_orig(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
service = RegionalService("ja", ["ja"], [("en", "")])
ids = run(monkeypatch, caplog, service, s_lang=["orig", "en"])
assert "orig not found in subtitle tracks, continuing with: en" in caplog.text
assert ids["subs"] == ["en"]
def test_require_subs_fails_on_a_forced_track_that_is_dropped(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
service = RegionalService("ja", ["ja"], [("en", "forced")])
assert run(monkeypatch, caplog, service, require_subs=["en"]) == 1
def test_require_subs_counts_a_forced_track_that_fs_keeps(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
service = RegionalService("ja", ["ja"], [("en", "forced")])
assert run(monkeypatch, caplog, service, require_subs=["en"], forced_subs=True)["subs"] == ["en-forced"]
def test_an_exclusion_removes_every_regional_form( def test_an_exclusion_removes_every_regional_form(
+28 -43
View File
@@ -119,7 +119,7 @@ from unshackle.core.utilities import (
matched_language_tags, matched_language_tags,
matching_languages, matching_languages,
missing_required_langs, missing_required_langs,
original_tag, orig_token,
partition_exclusions, partition_exclusions,
plain_subtitle_tags, plain_subtitle_tags,
resolve_sort_langs, resolve_sort_langs,
@@ -1225,7 +1225,7 @@ class dl:
"best_available", "best_available",
is_flag=True, is_flag=True,
default=False, default=False,
help="Warn instead of failing when a requested resolution, range, or language is missing, and continue with what is available.", help="Warn instead of failing when a requested resolution, range, video language, or audio language is missing.",
) )
@click.option( @click.option(
"--generate-dv", "--generate-dv",
@@ -2538,7 +2538,11 @@ class dl:
( (
"subtitle", "subtitle",
require_subs if keep_subtitles else [], require_subs if keep_subtitles else [],
[t.language for t in title.tracks.subtitles if t.forced or not forced_subs_only], [
t.language
for t in title.tracks.subtitles
if (t.forced if forced_subs_only else forced_subs or not t.forced)
],
), ),
): ):
missing_required = missing_required_langs(required, available, title.language, exact=exact_lang) missing_required = missing_required_langs(required, available, title.language, exact=exact_lang)
@@ -2631,16 +2635,11 @@ class dl:
) )
if keep_videos and video_languages and "all" not in video_languages: if keep_videos and video_languages and "all" not in video_languages:
processed_video_lang = resolve_sort_langs(video_languages, title.language, exact=exact_lang) processed_video_lang = resolve_sort_langs(video_languages, title.language, exact=exact_lang)
v_orig_token: Optional[str] = None v_orig_token = orig_token(video_languages, title.language, exact_lang)
if "orig" in video_languages: if "orig" in video_languages and not title.language:
if title.language: self.log.warning(
v_orig_token = original_tag(title.language, exact_lang) "Original language not available for title, skipping 'orig' selection for video"
else: )
self.log.warning(
"Original language not available for title, skipping 'orig' selection for video"
)
if v_orig_token in video_languages:
v_orig_token = None
missing_v_langs = find_missing_langs( missing_v_langs = find_missing_langs(
processed_video_lang, processed_video_lang,
[v.language for v in title.tracks.videos], [v.language for v in title.tracks.videos],
@@ -2860,6 +2859,7 @@ class dl:
) )
title.tracks.select_subtitles(lambda x: str(x.language) not in drop) title.tracks.select_subtitles(lambda x: str(x.language) not in drop)
if keep_subtitles and s_lang and "all" not in s_lang: if keep_subtitles and s_lang and "all" not in s_lang:
s_orig_token = orig_token(s_lang, title.language, exact_lang)
s_lang = resolve_sort_langs(s_lang, title.language, exact=exact_lang) s_lang = resolve_sort_langs(s_lang, title.language, exact=exact_lang)
# the pool selection draws from: forced tracks count only when -fs keeps them # the pool selection draws from: forced tracks count only when -fs keeps them
missing_langs = find_missing_langs( missing_langs = find_missing_langs(
@@ -2868,25 +2868,15 @@ class dl:
exact=exact_lang, exact=exact_lang,
) )
if missing_langs: if missing_langs:
missing_str = ", ".join(missing_langs) remaining = [tok for tok in s_lang if tok not in missing_langs]
if best_available: tail = (
remaining = [tok for tok in s_lang if tok not in missing_langs] f"continuing with: {as_requested(remaining, s_orig_token)}"
if remaining: if remaining
self.log.warning( else "continuing without subtitles"
f"{missing_str} not found in subtitle tracks, continuing with: {', '.join(remaining)}" )
) self.log.warning(
s_lang = remaining f"{as_requested(missing_langs, s_orig_token)} not found in subtitle tracks, {tail}"
else: )
self.log.warning(
f"{missing_str} not found in subtitle tracks, continuing without subtitles"
)
fsl_tags = matched_language_tags(
fsl, [x.language for x in title.tracks.subtitles if x.forced], exact_lang
)
title.tracks.select_subtitles(lambda x: x.forced and str(x.language) in fsl_tags)
else:
self.log.error(missing_str + " not found in subtitle tracks")
sys.exit(1)
if s_lang and title.tracks.subtitles: if s_lang and title.tracks.subtitles:
plain_tags = plain_subtitle_tags(s_lang, title.tracks.subtitles, exact_lang) plain_tags = plain_subtitle_tags(s_lang, title.tracks.subtitles, exact_lang)
@@ -2896,9 +2886,6 @@ class dl:
title.tracks.select_subtitles( title.tracks.select_subtitles(
lambda x: str(x.language) in (forced_tags if x.forced else plain_tags) lambda x: str(x.language) in (forced_tags if x.forced else plain_tags)
) )
if not title.tracks.subtitles and not best_available:
self.log.error(f"There's no {s_lang} Subtitle Track...")
sys.exit(1)
if keep_subtitles: if keep_subtitles:
# fsl, not forced_s_lang: unresolvable orig must keep all forced, not drop them # fsl, not forced_s_lang: unresolvable orig must keep all forced, not drop them
@@ -2982,14 +2969,9 @@ class dl:
audio_languages = a_lang or lang audio_languages = a_lang or lang
if audio_languages: if audio_languages:
processed_lang = resolve_sort_langs(audio_languages, title.language, exact=exact_lang) processed_lang = resolve_sort_langs(audio_languages, title.language, exact=exact_lang)
a_orig_token: Optional[str] = None a_orig_token = orig_token(audio_languages, title.language, exact_lang)
if "orig" in audio_languages: if "orig" in audio_languages and not title.language:
if title.language: self.log.warning("Original language not available for title, skipping 'orig' selection")
a_orig_token = original_tag(title.language, exact_lang)
else:
self.log.warning("Original language not available for title, skipping 'orig' selection")
if a_orig_token in audio_languages:
a_orig_token = None
embedded_langs = embedded_audio_langs( embedded_langs = embedded_audio_langs(
title.tracks.videos, keep_videos and not audio_description_only title.tracks.videos, keep_videos and not audio_description_only
@@ -3046,6 +3028,9 @@ class dl:
kept_tracks.extend(title.tracks.attachments) kept_tracks.extend(title.tracks.attachments)
title.tracks = Tracks(kept_tracks, manifest_url=title.tracks.manifest_url) title.tracks = Tracks(kept_tracks, manifest_url=title.tracks.manifest_url)
if not (title.tracks.videos or title.tracks.audio or title.tracks.subtitles or title.tracks.chapters):
skip_title(title, "There's no track left for the selected options.")
continue
selected_tracks, tracks_progress_callables = title.tracks.tree(add_progress=True) selected_tracks, tracks_progress_callables = title.tracks.tree(add_progress=True)
+2 -2
View File
@@ -1003,7 +1003,7 @@ async def download(request: web.Request) -> web.Response:
type: array type: array
items: items:
type: string type: string
description: Language for subtitle tracks (a '-' prefix excludes, e.g. ["all", "-es"]) (default - ["all"]) description: Language for subtitle tracks (a '-' prefix excludes, e.g. ["all", "-es"]); a missing one is skipped with a warning (default - ["all"])
require_audio: require_audio:
type: array type: array
items: items:
@@ -1018,7 +1018,7 @@ async def download(request: web.Request) -> web.Response:
type: array type: array
items: items:
type: string type: string
description: Required subtitle languages (default - []) description: Subtitle languages that must exist, else the job fails (default - [])
forced_subs: forced_subs:
type: boolean type: boolean
description: Include forced subtitle tracks (default - false) description: Include forced subtitle tracks (default - false)
+10
View File
@@ -473,6 +473,16 @@ def original_tag(language: Union[str, Language], exact: bool = False) -> str:
return str(language) if exact else ServiceTag(str(language)) return str(language) if exact else ServiceTag(str(language))
def orig_token(
requested: Sequence[str], language: Optional[Union[str, Language]], exact: bool = False
) -> Optional[str]:
"""The tag 'orig' resolved to, for as_requested. None when 'orig' did not resolve or the user also named that tag."""
if "orig" not in requested or not language:
return None
tag = original_tag(language, exact)
return None if tag in requested else tag
def resolve_sort_langs( def resolve_sort_langs(
tokens: Sequence[str], original: Optional[Union[str, Language]] = None, *, exact: bool = False tokens: Sequence[str], original: Optional[Union[str, Language]] = None, *, exact: bool = False
) -> list[str]: ) -> list[str]: