mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-13 12:35:05 +00:00
Compare commits
4
Commits
2873cf4c53
...
a28c99b811
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28c99b811 | ||
|
|
f85d7f5fa8 | ||
|
|
590a57113b | ||
|
|
9fff0a6646 |
@@ -459,6 +459,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is
|
||||
| `forced_s_lang` | string[] | `[]` | Forced subtitle language(s); implies `forced_subs`. |
|
||||
| `exact_lang` | boolean | `false` | A language tag without a region never falls back to another variant. A tag with a region is always exact. |
|
||||
| `sub_format` | string | `null` | Output subtitle format. |
|
||||
| `sub_font` | string | `null` | Font for subtitles converted to ASS; implies `sub_format` ASS. With another `sub_format` the request returns `400`. |
|
||||
| `video_only` / `audio_only` / `subs_only` / `chapters_only` | boolean | `false` | Track-type restriction (at most one). |
|
||||
| `no_subs` / `no_audio` / `no_chapters` / `no_video` / `no_attachments` | boolean | `false` | Skip a track type. |
|
||||
| `skip_subtitle_errors` | boolean | `false` | Skip a subtitle that fails to download and keep the other tracks. |
|
||||
@@ -485,6 +486,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is
|
||||
| `tag` | string | `null` | Release group tag. |
|
||||
| `tmdb_id` / `imdb_id` / `tvdb_id` / `anilist_id` | - | `null` | External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set `enrich` to also take the title, year and original language. Give at most one of `tmdb_id`, `imdb_id` and `tvdb_id`, since unshackle resolves the others from it. Sending two returns `400`. `anilist_id` still combines with one of them. `tmdb_id` and `tvdb_id` must be positive integers, `imdb_id` must look like `tt1375666`, and `anilist_id` must be a positive integer or a string like `mal:12345`, or the request returns `400`. An ID whose provider is unconfigured, such as `tmdb_id` with no `tmdb_api_key`, fails the job rather than returning `400`. `anilist_id` needs no API key. |
|
||||
| `tvdb_order` | `official`, `dvd`, `absolute`, `alternate`, `regional` | `null` | Renumber episodes to a TVDB season order. Falls back to the `tvdb_order` config option. |
|
||||
| `continuous_episodes` | boolean | `false` | Number each season on from the season before it, so season two starts at episode 5 after four episodes. Season 0 and episode 0 keep their numbers. `wanted` selects by the new numbers, but the list endpoints show the numbers of the service. |
|
||||
| `enrich` | boolean | `false` | Overwrite title, year and original language with the external source's. Needs one of `tmdb_id`, `imdb_id`, `tvdb_id` or `anilist_id`. Without one the job fails instead of returning `400`. |
|
||||
| `daily` | boolean | `false` | Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs `enrich` and a TVDB ID. An air date the service already set is kept. |
|
||||
| `output_dir` | string | `null` | Output directory, relative to the server's downloads directory. The server rejects a path that resolves outside it with `400`. |
|
||||
|
||||
@@ -107,6 +107,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
|
||||
| `-fsl`, `--forced-s-lang` | none | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. |
|
||||
| `--exact-lang` | off | A tag without a region selects only the variant it means and never falls back to another: `-l es` does not select `es-419`. A tag with a region, such as `es-419`, is always exact. Applies to selection and to sort order. |
|
||||
| `--sub-format` | - | Output subtitle format (`SRT`/`srt`, `VTT`/`webvtt`, `ASS`/`ssa`, `TTML`, `SMI`, ...), or `original` to keep the source format. |
|
||||
| `--sub-font` | - | Font for subtitles converted to ASS, with the rest of the look from `subtitle.ass_style`. Implies `--sub-format ASS` and overrides a `sub_format` from the configuration file; a typed `--sub-format` of another format is an error. |
|
||||
|
||||
!!! tip "These flags select languages, they do not order them"
|
||||
Naming languages with `-l` or `-sl` removes the others. To keep every language but put
|
||||
@@ -172,6 +173,7 @@ Keep only certain track types, or skip certain track types.
|
||||
| `--enrich` | Overwrite show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--anilist`. |
|
||||
| `--daily` | Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs `--enrich` and a TVDB ID. See [Daily and date-based content](downloading.md#daily-and-date-based-content). |
|
||||
| `--tvdb-order` | Renumber episodes to a TVDB season order: `official` (aired), `dvd`, `absolute`, `alternate`, or `regional`. Needs `tvdb_api_key`. |
|
||||
| `-ce` / `--continuous-episodes` | Number each season on from the season before it. After a season one of four episodes, season two starts at `S02E05`. Season 0 and episode 0 keep their numbers. |
|
||||
|
||||
!!! note "One ID at a time"
|
||||
You cannot combine `--tmdb`, `--imdb` and `--tvdb`. Give one and unshackle finds the
|
||||
|
||||
@@ -1047,6 +1047,7 @@ lookups but keep the IDs you give, set
|
||||
| `--anilist` | `--anilist 21` | Use this AniList ID instead of an automatic search. `mal:12345` is also accepted. Needs no API key. |
|
||||
| `--enrich` | - | Overwrite the show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--anilist`. |
|
||||
| `--tvdb-order` | `--tvdb-order dvd` | Renumber episodes to a TVDB season order. Needs `tvdb_api_key`. |
|
||||
| `-ce` / `--continuous-episodes` | - | Number each season on from the season before it, so season two starts at `S02E05` after four episodes. |
|
||||
|
||||
```shell title="Force the right IMDb match, and keep the service's own naming"
|
||||
unshackle dl --imdb tt1375666 EXAMPLE 81234567
|
||||
@@ -1148,6 +1149,23 @@ Available orders are `official` (aired), `dvd`, `absolute`, `alternate`, and `re
|
||||
two episodes the same season/episode slot, and so the same filename, unshackle logs an
|
||||
error and leaves the numbering untouched. Pick an order that covers the whole series.
|
||||
|
||||
### Continuous episode numbers
|
||||
|
||||
Some anime release groups number each season on from the season before it. With
|
||||
`--continuous-episodes`, a season two that follows a four-episode season one starts at
|
||||
`S02E05`:
|
||||
|
||||
```shell title="Number season two on from season one"
|
||||
unshackle dl -ce EXAMPLE 81234567
|
||||
```
|
||||
|
||||
The count comes from the episodes the service lists, so download from the series URL, not
|
||||
from a single season. A season the service does not list in your region is not counted.
|
||||
Season 0 and episode 0 keep their numbers, and a season that already starts past the
|
||||
season before it is not changed. The renumbering runs before `--wanted`, so select episodes
|
||||
by their new numbers, for example `-w S02E05`. A whole-season selection such as `-w S05`
|
||||
matches episodes up to E999 only.
|
||||
|
||||
## Configuration defaults
|
||||
|
||||
You can set every flag on this page as a default in your configuration file so you do not
|
||||
@@ -1180,6 +1198,7 @@ authoritative list.
|
||||
| `--forced-subs` | `-fs` | Include forced subtitles. |
|
||||
| `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. `-` excludes. |
|
||||
| `--sub-format` | | Output subtitle format. |
|
||||
| `--sub-font` | | Font for subtitles converted to ASS; implies `--sub-format ASS`. |
|
||||
| `--wanted` | `-w` | Episode/season range, an air date, or a track number for a music release (`1-5`, `2x3`). |
|
||||
| `--daily` | | Fill missing air dates from TVDB during `--enrich`. |
|
||||
| `--select-titles` | | Interactively pick episodes or films. |
|
||||
@@ -1202,3 +1221,4 @@ authoritative list.
|
||||
| `--export` | | Export track info and keys to JSON. |
|
||||
| `--tmdb` / `--imdb` / `--tvdb` / `--anilist` / `--enrich` | | Metadata overrides. |
|
||||
| `--tvdb-order` | | Renumber episodes to a TVDB season order. |
|
||||
| `-ce` / `--continuous-episodes` | | Number each season on from the season before it. |
|
||||
|
||||
@@ -83,6 +83,7 @@ Subtitle selection happens through `dl` flags. The most important is language se
|
||||
| `-S`, `--subs-only` | Download only subtitle tracks. |
|
||||
| `-ns`, `--no-subs` | Do not download subtitle tracks at all. |
|
||||
| `--sub-format` | Output subtitle format / conversion target (see [conversion](#converting-subtitle-formats)). |
|
||||
| `--sub-font` | Font for subtitles converted to ASS; implies `--sub-format ass` (see [custom font and style](#custom-font-and-style)). |
|
||||
| `--skip-subtitle-errors` | Skip a subtitle that fails to download instead of aborting the whole title. |
|
||||
|
||||
### Choosing languages
|
||||
@@ -404,6 +405,60 @@ The pattern is `{base}.{language}[.forced][.sdh].{extension}`. The
|
||||
a format name like `srt` or `vtt`, or `original` to keep each subtitle in its downloaded
|
||||
format.
|
||||
|
||||
## Custom font and style
|
||||
|
||||
Services mostly deliver WebVTT, which players draw in their own plain font. To use a font
|
||||
of your choice, unshackle converts the subtitles to ASS and gives them one style:
|
||||
|
||||
```shell title="Convert to ASS in a custom font"
|
||||
unshackle dl --sub-font "Noto Sans" EXAMPLE 81234567
|
||||
```
|
||||
|
||||
`--sub-font` sets only the font name. The rest of the look comes from
|
||||
[`subtitle.ass_style`](#subtitle-configuration), a mapping of style fields. A field takes its
|
||||
[pysubs2 name](https://pysubs2.readthedocs.io/en/latest/api-reference.html#ssastyle-style-of-subtitles)
|
||||
(`primarycolor`) or its ASS name (`PrimaryColour`). Give sizes and margins for a 1080-line
|
||||
picture. unshackle writes a 1080-line canvas in the shape of the video, and the player scales
|
||||
it to the screen, so the text has the same size on a 720p, 1080p or 2160p video.
|
||||
|
||||
```yaml title="unshackle.yaml"
|
||||
subtitle:
|
||||
ass_style:
|
||||
fontname: Noto Sans
|
||||
fontsize: 72
|
||||
primarycolor: "&H00FFFFFF"
|
||||
marginv: 60
|
||||
```
|
||||
|
||||
The fields you leave out keep these defaults: `fontname: Arial`, `fontsize: 72`, `bold: true`,
|
||||
`outline: 3.6`, `shadow: 1.5`, `backcolor: "&HA0000000"`, `marginl: 100`, `marginr: 100` and
|
||||
`marginv: 60`. Write colours as `&HAABBGGRR` strings in quotes: in YAML, an unquoted `&` starts
|
||||
an anchor, and an unquoted number is not read as a colour.
|
||||
|
||||
- A style makes ASS the output format when you set no `--sub-format`. With `--sub-format ass`
|
||||
or `ssa` the style applies too. With another format, unshackle does not use the style.
|
||||
A `--sub-font` that you type overrides a `sub_format` from the configuration file, and stops
|
||||
the download with an error when you also type another `--sub-format`. A `sub_font` from the
|
||||
configuration file is ignored when the format is not ASS.
|
||||
- The style applies only to subtitles that unshackle converts into ASS. A subtitle that is
|
||||
already SSA/ASS keeps its own styles, positions and canvas, and so does its SDH-stripped copy.
|
||||
- The SDH-stripped copy of a converted track keeps the style.
|
||||
- unshackle checks the style before it downloads. An unknown field or a value that does not
|
||||
fit its field stops the download with an error.
|
||||
- With `output_mode: sidecar` and a `sidecar_format` other than `ass`, the sidecar files do
|
||||
not use the style.
|
||||
- Closed captions that unshackle extracts from the video stay SRT.
|
||||
|
||||
!!! note "Player settings"
|
||||
A player draws an ASS subtitle with its own style, so player options for text subtitles,
|
||||
such as the mpv options `sub-font` and `sub-font-size`, no longer apply. The text size
|
||||
follows the height of the video, so on a wide video such as 1920x800 the text is smaller
|
||||
than on a 1920x1080 video.
|
||||
|
||||
The font must be on your system or in [`directories.fonts`](../reference/configuration/directories.md),
|
||||
in a folder with the family name, for example `fonts/Noto Sans/NotoSans-Regular.ttf`. unshackle
|
||||
attaches it to the MKV, as described below.
|
||||
|
||||
## Fonts for styled subtitles
|
||||
|
||||
Styled SSA/ASS subtitles reference specific fonts by name. When unshackle muxes such a
|
||||
@@ -440,6 +495,7 @@ subtitle:
|
||||
| `group_by` | `type` | `type`, `language` | Whether all tracks of one type stay together, or each language stays next to its own variants. |
|
||||
| `language_priority` | *(unset)* | a list of languages | Languages to sort to the top. The rest keep the default order. Nothing is removed. |
|
||||
| `dedupe_codecs` | *(unset)* | a list of codecs, e.g. `[VTT, STPP, WVTT]` | Keep one copy of a subtitle that the service sends in several codecs: the earliest listed codec wins. |
|
||||
| `ass_style` | *(unset)* | a mapping of pysubs2 style fields | The look of subtitles converted to ASS; makes ASS the default output format. See [custom font and style](#custom-font-and-style). |
|
||||
|
||||
### Which settings to change
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ Common config keys. This is a useful subset, and every `dl` flag works:
|
||||
| `range` | list | `["SDR"]` | Colour range(s): `SDR`, `HLG`, `HDR10`, `HDR10P`, `DV`, `HYBRID`. |
|
||||
| `channels` | float | *(unset)* | Audio channels, e.g. `6` for 5.1. |
|
||||
| `sub_format` | str | *(unset)* | Convert subtitles to this format (`srt`, `vtt`, `original`, ...). |
|
||||
| `sub_font` | str | *(unset)* | Font for subtitles converted to ASS; implies `sub_format: ass`. Ignored when `sub_format` is another format. |
|
||||
| `forced_subs` | bool | `false` | Include forced subtitle tracks. |
|
||||
| `forced_s_lang` | list | `[]` | Forced subtitle language(s); implies `forced_subs`. A `-` prefix excludes. |
|
||||
| `forced_subs_only` | bool | `false` | Keep only forced subtitle tracks; implies `forced_subs`. |
|
||||
@@ -194,6 +195,7 @@ in or writes them as sidecar files. See [Subtitles](../../guide/subtitles.md) fo
|
||||
| `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. |
|
||||
| `language_priority` | list | *(unset)* | Languages to sort to the top, in the order you give. The rest keep the default order: original language first, then alphabetical. Nothing is removed. |
|
||||
| `ass_style` | dict | *(unset)* | Style for subtitles converted to ASS, as pysubs2 or ASS style fields (`fontname`, `fontsize`, `primarycolor` or `PrimaryColour`, ...) for a 1080-line picture. Makes ASS the output format when `sub_format` is unset. See [Custom font and style](../../guide/subtitles.md#custom-font-and-style). |
|
||||
| `dedupe_codecs` | list | *(unset)* | Subtitle codecs in order of preference, by value or name, for example `[VTT, STPP, WVTT]`. When tracks of the same language, forced/SDH/CC type and name come in several listed codecs, unshackle drops the ones in a later codec. It always keeps codecs you leave out. Unset keeps every track. |
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -59,7 +59,9 @@ folder-kind keys.
|
||||
or `{episode}`. Any series with a TVDB absolute order can use it, anime most often.
|
||||
Services can supply it, and
|
||||
[`--enrich`](../../guide/downloading.md#metadata-and-tagging) fills it in from TVDB.
|
||||
Use `{absolute?}` so the name stays clean when it is unknown.
|
||||
Use `{absolute?}` so the name stays clean when it is unknown. To keep the season and
|
||||
continue the episode number instead (`S02E05`), use
|
||||
[`--continuous-episodes`](../../guide/downloading.md#continuous-episode-numbers).
|
||||
|
||||
!!! tip "`sports` names an event by year or date"
|
||||
A service marks an event episode as sport, and that episode then uses the `sports`
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""--continuous-episodes: each season is numbered on from the season before it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from unshackle.commands.dl import dl
|
||||
from unshackle.core.remote_service import apply_title_fields
|
||||
from unshackle.core.titles.episode import Episode, Series
|
||||
|
||||
|
||||
class DummyService:
|
||||
pass
|
||||
|
||||
|
||||
def episode(season: int, number: int, part: int | None = None) -> Episode:
|
||||
return Episode(
|
||||
id_=f"ep-{season:02}-{number:02}-{part}",
|
||||
service=DummyService,
|
||||
title="The Show",
|
||||
season=season,
|
||||
number=number,
|
||||
part=part,
|
||||
)
|
||||
|
||||
|
||||
def renumber(series: Series) -> Series:
|
||||
stub = SimpleNamespace(log=SimpleNamespace(info=lambda *a, **k: None))
|
||||
return dl.apply_continuous_episodes(stub, series)
|
||||
|
||||
|
||||
def numbers(slots: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
return [(t.season, t.number) for t in renumber(Series(episode(s, n) for s, n in slots))]
|
||||
|
||||
|
||||
def test_season_two_starts_after_season_one():
|
||||
assert numbers([(1, 1), (1, 2), (1, 3), (1, 4), (2, 1), (2, 2), (3, 1)]) == [
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(1, 3),
|
||||
(1, 4),
|
||||
(2, 5),
|
||||
(2, 6),
|
||||
(3, 7),
|
||||
]
|
||||
|
||||
|
||||
def test_specials_keep_their_numbers():
|
||||
assert numbers([(0, 1), (1, 1), (1, 2), (2, 1)]) == [(0, 1), (1, 1), (1, 2), (2, 3)]
|
||||
|
||||
|
||||
def test_already_continuous_season_is_kept():
|
||||
assert numbers([(1, 1), (1, 2), (2, 3), (2, 4), (3, 1)]) == [(1, 1), (1, 2), (2, 3), (2, 4), (3, 5)]
|
||||
|
||||
|
||||
def test_episode_zero_keeps_its_number_and_a_second_run_changes_nothing():
|
||||
once = numbers([(1, 1), (1, 2), (1, 3), (1, 4), (2, 0), (2, 1), (2, 2)])
|
||||
assert once == [(1, 1), (1, 2), (1, 3), (1, 4), (2, 0), (2, 5), (2, 6)]
|
||||
assert numbers(once) == once
|
||||
|
||||
|
||||
def test_episode_zero_does_not_make_a_continuous_season_move():
|
||||
slots = [(1, 1), (1, 2), (2, 0), (2, 3), (2, 4)]
|
||||
assert numbers(slots) == slots
|
||||
|
||||
|
||||
def test_parts_of_one_episode_move_together():
|
||||
series = renumber(Series([episode(1, 1), episode(1, 2), episode(2, 1, 1), episode(2, 1, 2)]))
|
||||
assert [(t.season, t.number, t.part) for t in series] == [(1, 1, None), (1, 2, None), (2, 3, 1), (2, 3, 2)]
|
||||
|
||||
|
||||
def test_the_rebuilt_series_finds_a_renumbered_episode():
|
||||
moved = episode(2, 1)
|
||||
series = renumber(Series([episode(1, 1), moved]))
|
||||
assert moved in series
|
||||
assert series.index(moved) == 1
|
||||
|
||||
|
||||
def test_the_get_tracks_refresh_keeps_the_local_numbers():
|
||||
title = episode(2, 3)
|
||||
apply_title_fields(title, {"season": 2, "number": 1, "name": "Pilot"})
|
||||
assert (title.season, title.number, title.name) == (2, 3, "Pilot")
|
||||
@@ -9,10 +9,13 @@ from uuid import UUID
|
||||
import m3u8
|
||||
import pytest
|
||||
from lxml import etree
|
||||
from pymp4.parser import Box
|
||||
from pyplayready.misc.exceptions import InvalidPssh
|
||||
from pyplayready.system.pssh import PSSH
|
||||
from pyplayready.system.wrmheader import WRMHeader
|
||||
from pywidevine.pssh import PSSH as WV_PSSH
|
||||
|
||||
from unshackle.core.api.handlers import pssh_kids
|
||||
from unshackle.core.drm import PlayReady, Widevine, drm_from_dict
|
||||
from unshackle.core.manifests.dash import DASH
|
||||
from unshackle.core.manifests.hls import HLS
|
||||
@@ -110,3 +113,37 @@ def test_ism_loads_a_bare_header() -> None:
|
||||
|
||||
assert len(drm) == 1
|
||||
assert KID in drm[0].kids
|
||||
|
||||
|
||||
def pr_box(header: bytes, box_kids: tuple[UUID, ...] = ()) -> str:
|
||||
"""Base64 of a PlayReady PSSH box around ``header``; ``box_kids`` makes it a v1 box."""
|
||||
return base64.b64encode(
|
||||
Box.build(
|
||||
dict(
|
||||
type=b"pssh",
|
||||
version=1 if box_kids else 0,
|
||||
flags=0,
|
||||
system_ID=WV_PSSH.SystemId.PlayReady,
|
||||
key_IDs=list(box_kids) or None,
|
||||
init_data=header,
|
||||
)
|
||||
)
|
||||
).decode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["4.0.0.0", "4.3.0.0"])
|
||||
@pytest.mark.parametrize("wrap", [False, True], ids=["bare", "pro"])
|
||||
def test_widevine_converts_a_playready_box_with_the_playready_kids(version: str, wrap: bool) -> None:
|
||||
header = base64.b64decode(bare_header(version))
|
||||
box = pr_box(base64.b64decode(PlayReady.pro_b64_for(WRMHeader(header))) if wrap else header)
|
||||
|
||||
assert Widevine(pssh=WV_PSSH(box)).kids == [KID]
|
||||
assert PlayReady(pssh=PSSH(base64.b64decode(box)), pssh_b64=box).kids == [KID]
|
||||
assert pssh_kids(box, "widevine") == {KID}
|
||||
|
||||
|
||||
def test_widevine_keeps_the_kids_of_a_v1_playready_box() -> None:
|
||||
other = UUID("ffeeddcc-bbaa-9988-7766-554433221100")
|
||||
box = pr_box(base64.b64decode(bare_header("4.0.0.0")), box_kids=(other, KID))
|
||||
|
||||
assert Widevine(pssh=WV_PSSH(box)).kids == [other, KID]
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""get_windows_fonts reads the fonts installed for all users and for the current user."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path, PureWindowsPath
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core import utilities
|
||||
|
||||
HKLM, HKCU = "HKLM", "HKCU"
|
||||
|
||||
|
||||
class Key(list):
|
||||
def __enter__(self) -> Key:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(monkeypatch, tmp_path):
|
||||
"""Install a fake winreg whose hives are the returned dict, with the machine font folder in tmp_path."""
|
||||
hives: dict[str, list[tuple[str, object]]] = {}
|
||||
|
||||
def open_key(hive: str, *_: object) -> Key:
|
||||
if hive not in hives:
|
||||
raise FileNotFoundError(hive)
|
||||
return Key(hives[hive])
|
||||
|
||||
winreg = types.SimpleNamespace(
|
||||
HKEY_LOCAL_MACHINE=HKLM,
|
||||
HKEY_CURRENT_USER=HKCU,
|
||||
KEY_READ=0,
|
||||
OpenKey=open_key,
|
||||
QueryInfoKey=lambda key: (0, len(key), 0),
|
||||
EnumValue=lambda key, n: (*key[n], 1),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "winreg", winreg)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
(tmp_path / "machine").mkdir()
|
||||
monkeypatch.setattr(utilities, "WINDOWS_FONTS_DIR", tmp_path / "machine")
|
||||
monkeypatch.setattr(utilities, "extract_font_family", lambda path: None)
|
||||
return hives
|
||||
|
||||
|
||||
def font(path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"")
|
||||
return path
|
||||
|
||||
|
||||
def test_per_user_fonts_are_found(registry, tmp_path):
|
||||
machine = font(tmp_path / "machine/arial.ttf")
|
||||
user = font(tmp_path / "user/MyFont-Bold.ttf")
|
||||
registry[HKLM] = [("Arial (TrueType)", "arial.ttf")]
|
||||
registry[HKCU] = [("My Font Bold (TrueType)", str(user)), ("Arial (TrueType)", str(user))]
|
||||
|
||||
fonts = utilities.get_windows_fonts()
|
||||
assert fonts == {"Arial": machine, "My Font Bold": user}
|
||||
assert utilities.find_font_with_fallbacks("My Font", fonts) == user
|
||||
|
||||
|
||||
def test_a_font_is_found_by_the_family_name_in_its_file(registry, tmp_path, monkeypatch):
|
||||
user = font(tmp_path / "user/My Font - Bold.ttf")
|
||||
registry[HKCU] = [("MyFont-Bold (TrueType)", str(user))]
|
||||
monkeypatch.setattr(utilities, "extract_font_family", lambda path: "My Font")
|
||||
|
||||
fonts = utilities.get_windows_fonts()
|
||||
assert fonts == {"MyFont-Bold": user, "My Font": user}
|
||||
assert utilities.find_font_with_fallbacks("My Font", fonts) == user
|
||||
|
||||
|
||||
def test_an_absolute_registry_value_replaces_the_font_folder():
|
||||
user = r"C:\Users\x\AppData\Local\Microsoft\Windows\Fonts\MyFont-Bold.ttf"
|
||||
assert PureWindowsPath(r"C:\Windows\Fonts", user) == PureWindowsPath(user)
|
||||
|
||||
|
||||
def test_missing_files_bitmap_fonts_and_odd_values_are_skipped(registry, tmp_path):
|
||||
font(tmp_path / "machine/modern.fon")
|
||||
collection = font(tmp_path / "machine/msgothic.ttc")
|
||||
registry[HKLM] = [
|
||||
("Modern (All res)", "modern.fon"),
|
||||
("MS Gothic & MS UI Gothic (TrueType)", "msgothic.ttc"),
|
||||
("Gone (TrueType)", "gone.ttf"),
|
||||
("Binary (TrueType)", b"x.ttf"),
|
||||
]
|
||||
registry[HKCU] = [("Deleted (TrueType)", str(tmp_path / "user/Deleted.ttf"))]
|
||||
assert utilities.get_windows_fonts() == {"MS Gothic & MS UI Gothic": collection}
|
||||
@@ -0,0 +1,248 @@
|
||||
"""subtitle.ass_style and --sub-font: the look given to subtitles converted into ASS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pysubs2
|
||||
import pytest
|
||||
|
||||
from unshackle.commands.dl import dl, set_subtitle_look, subtitle_look
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.api.handlers import validate_download_parameters
|
||||
from unshackle.core.tracks import subtitle_convert as sc
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
from unshackle.core.tracks.video import Video
|
||||
|
||||
Codec = Subtitle.Codec
|
||||
|
||||
VTT = """WEBVTT
|
||||
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
[door creaks]
|
||||
|
||||
00:00:03.000 --> 00:00:04.000
|
||||
Hello there.
|
||||
"""
|
||||
|
||||
NATIVE_ASS = """[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 640
|
||||
PlayResY: 360
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,10,10,18,1
|
||||
Style: Top,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,-1,0,0,100,100,0,0,1,2,1,8,10,10,18,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,{\\pos(320,50)}Sign
|
||||
Dialogue: 0,0:00:03.00,0:00:04.00,Top,,0,0,0,,Hello
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_subtitleedit(monkeypatch):
|
||||
monkeypatch.setattr(binaries, "SubtitleEdit", None)
|
||||
|
||||
|
||||
def make_sub(tmp_path, name: str, text: str, codec: Codec, style: pysubs2.SSAStyle | None = None) -> Subtitle:
|
||||
path = tmp_path / name
|
||||
path.write_text(text, encoding="utf8")
|
||||
sub = Subtitle(url="https://example.test/x", language="en", codec=codec)
|
||||
sub.path = path
|
||||
sub.ass_style = style
|
||||
sub.ass_video_size = (1440, 1080)
|
||||
return sub
|
||||
|
||||
|
||||
def test_style_fields_override_the_defaults():
|
||||
style = sc.build_ass_style({"fontname": "My Font", "marginv": 40, "primarycolor": "&H0000FFFF"})
|
||||
assert (style.fontname, style.fontsize, style.bold, style.marginv) == ("My Font", 72, True, 40)
|
||||
assert style.primarycolor == pysubs2.Color(255, 255, 0, 0)
|
||||
assert style.backcolor == pysubs2.Color(0, 0, 0, 0xA0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fields",
|
||||
[
|
||||
{"fontsize_px": 50},
|
||||
{"primarycolor": "white"},
|
||||
{"bold": "false"},
|
||||
{"fontname": "A,B"},
|
||||
{"fontname": " "},
|
||||
{"marginv": "low"},
|
||||
{"fontsize": None},
|
||||
{"fontsize": {}},
|
||||
{"fontsize": -5},
|
||||
{"fontsize": float("nan")},
|
||||
{"outline": float("inf")},
|
||||
{"marginv": True},
|
||||
{"marginv": 3.6},
|
||||
{"primarycolor": 16777215},
|
||||
{"fontname": "A\u2028B"},
|
||||
{"fontname": "A\x00B"},
|
||||
{"alignment": 10},
|
||||
],
|
||||
)
|
||||
def test_bad_style_fields_raise(fields):
|
||||
with pytest.raises(ValueError):
|
||||
sc.build_ass_style(fields)
|
||||
|
||||
|
||||
def test_ass_field_names_are_accepted():
|
||||
style = sc.build_ass_style({"Fontname": "My Font", "PrimaryColour": "&H0000FFFF", "MarginV": 40.0})
|
||||
assert (style.fontname, style.primarycolor, style.marginv) == ("My Font", pysubs2.Color(255, 255, 0, 0), 40)
|
||||
|
||||
|
||||
def test_the_look_goes_only_to_subtitles_that_are_not_already_ass(tmp_path):
|
||||
style = sc.build_ass_style({})
|
||||
vtt = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT)
|
||||
ass = make_sub(tmp_path, "x.ass", NATIVE_ASS, Codec.SubStationAlphav4)
|
||||
videos = [
|
||||
Video(url="https://example.test/v", language="en"),
|
||||
Video(url="https://example.test/v", language="en", width=3840, height=1600),
|
||||
]
|
||||
set_subtitle_look([vtt, ass], style, videos)
|
||||
assert (vtt.ass_style, ass.ass_style) == (style, None)
|
||||
assert vtt.ass_video_size == ass.ass_video_size == (3840, 1600)
|
||||
|
||||
|
||||
def test_a_stripped_styled_track_with_no_cues_left_stays_srt(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("unshackle.core.config.config.subtitle", {"sdh_method": "auto"}, raising=False)
|
||||
sub = make_sub(tmp_path, "x.vtt", VTT.split("00:00:03")[0], Codec.WebVTT, sc.build_ass_style({}))
|
||||
sub.convert(Codec.SubStationAlphav4)
|
||||
sub.strip_hearing_impaired()
|
||||
assert sub.codec == Codec.SubRip
|
||||
|
||||
|
||||
def test_a_stripped_native_ass_track_is_not_restyled(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("unshackle.core.config.config.subtitle", {"sdh_method": "auto"}, raising=False)
|
||||
sub = make_sub(tmp_path, "x.ass", NATIVE_ASS, Codec.SubStationAlphav4)
|
||||
set_subtitle_look([sub], sc.build_ass_style({"fontname": "My Font"}), [])
|
||||
sub.strip_hearing_impaired()
|
||||
assert sub.codec == Codec.SubRip
|
||||
|
||||
|
||||
def test_vtt_to_ass_gets_the_style_on_a_canvas_shaped_like_the_video(tmp_path):
|
||||
sub = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT, sc.build_ass_style({"fontname": "My Font"}))
|
||||
sub.convert(Codec.SubStationAlphav4)
|
||||
|
||||
subs = pysubs2.load(str(sub.path))
|
||||
assert (subs.info["PlayResX"], subs.info["PlayResY"]) == ("1440", "1080")
|
||||
assert (subs.info["LayoutResX"], subs.info["LayoutResY"]) == ("1440", "1080")
|
||||
assert subs.styles["Default"].fontname == "My Font"
|
||||
assert subs.styles["Default"].fontsize == 72
|
||||
|
||||
|
||||
def test_vtt_to_ass_without_a_style_is_left_as_converted(tmp_path):
|
||||
sub = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT)
|
||||
sub.convert(Codec.SubStationAlphav4)
|
||||
|
||||
subs = pysubs2.load(str(sub.path))
|
||||
assert "PlayResY" not in subs.info
|
||||
assert subs.styles["Default"].fontname == "Arial"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", [Codec.SubStationAlphav4, Codec.SubStationAlpha])
|
||||
def test_a_native_ass_track_is_never_restyled(tmp_path, target):
|
||||
sub = make_sub(tmp_path, "x.ass", NATIVE_ASS, Codec.SubStationAlphav4, sc.build_ass_style({}))
|
||||
sub.convert(target)
|
||||
|
||||
subs = pysubs2.load(str(sub.path))
|
||||
assert subs.info["PlayResY"] == "360"
|
||||
assert subs.styles["Default"].fontsize == 20
|
||||
assert "\\pos(320,50)" in subs.events[0].text
|
||||
|
||||
|
||||
def test_a_converter_canvas_is_kept_and_the_style_scales_to_it(tmp_path):
|
||||
path = tmp_path / "x.ass"
|
||||
path.write_text(NATIVE_ASS, encoding="utf8")
|
||||
sc.style_substation(path, sc.build_ass_style({"fontname": "My Font"}), (1920, 1080))
|
||||
|
||||
subs = pysubs2.load(str(path))
|
||||
assert (subs.info["PlayResX"], subs.info["PlayResY"]) == ("640", "360")
|
||||
assert subs.styles["Default"].fontsize == pytest.approx(24)
|
||||
assert subs.styles["Default"].marginv == 20
|
||||
top = subs.styles["Top"]
|
||||
assert (top.fontname, top.alignment, top.italic) == ("My Font", pysubs2.Alignment.TOP_CENTER, True)
|
||||
|
||||
|
||||
def test_restyling_twice_gives_the_same_file(tmp_path):
|
||||
sub = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT, sc.build_ass_style({}))
|
||||
sub.convert(Codec.SubStationAlphav4)
|
||||
first = sub.path.read_text("utf8")
|
||||
sc.style_substation(sub.path, sub.ass_style, sub.ass_video_size)
|
||||
assert sub.path.read_text("utf8") == first
|
||||
|
||||
|
||||
def test_a_stripped_styled_track_stays_styled_ass(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("unshackle.core.config.config.subtitle", {"sdh_method": "auto"}, raising=False)
|
||||
sub = make_sub(tmp_path, "x.vtt", VTT, Codec.WebVTT, sc.build_ass_style({"fontname": "My Font"}))
|
||||
sub.convert(Codec.SubStationAlphav4)
|
||||
sub.strip_hearing_impaired()
|
||||
|
||||
assert sub.codec == Codec.SubStationAlphav4
|
||||
subs = pysubs2.load(str(sub.path))
|
||||
assert subs.styles["Default"].fontname == "My Font"
|
||||
assert [e.plaintext for e in subs.events] == ["Hello there."]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sub_format", "sub_font", "fields", "expected"),
|
||||
[
|
||||
(None, None, None, (None, None)),
|
||||
(None, None, {"fontsize": 60}, (Codec.SubStationAlphav4, "Arial")),
|
||||
(None, "My Font", None, (Codec.SubStationAlphav4, "My Font")),
|
||||
(Codec.SubStationAlpha, "My Font", {"fontname": "Other"}, (Codec.SubStationAlpha, "My Font")),
|
||||
(Codec.SubRip, None, {"fontsize": 60}, (Codec.SubRip, None)),
|
||||
("original", None, {"fontsize": 60}, ("original", None)),
|
||||
],
|
||||
)
|
||||
def test_subtitle_look_picks_the_format_and_style(sub_format, sub_font, fields, expected):
|
||||
out_format, style = subtitle_look(sub_format, sub_font, fields)
|
||||
assert (out_format, style.fontname if style else None) == expected
|
||||
|
||||
|
||||
def test_a_typed_font_overrides_a_format_from_config():
|
||||
out_format, style = subtitle_look(Codec.SubRip, "My Font", None, typed_format=False)
|
||||
assert (out_format, style.fontname if style else None) == (Codec.SubStationAlphav4, "My Font")
|
||||
|
||||
|
||||
def test_a_font_from_config_is_ignored_with_another_format():
|
||||
assert subtitle_look(Codec.SubRip, "My Font", None, typed_font=False) == (Codec.SubRip, None)
|
||||
assert subtitle_look("original", "My Font", {"fontsize": 60}, typed_font=False) == ("original", None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "error"),
|
||||
[
|
||||
({"sub_font": "My Font", "sub_format": "SRT"}, "Invalid sub_font: --sub-font needs ASS subtitles"),
|
||||
({"sub_font": "A,B"}, "Invalid sub_font: ass_style fontname"),
|
||||
],
|
||||
)
|
||||
def test_the_api_refuses_a_sub_font_it_cannot_use(data, error):
|
||||
assert (validate_download_parameters(data) or "").startswith(error)
|
||||
|
||||
|
||||
def test_the_api_takes_a_sub_font_with_ass():
|
||||
assert validate_download_parameters({"sub_font": "My Font", "sub_format": "ASS"}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sub_format", "fields"),
|
||||
[(Codec.SubRip, None), ("original", None), (None, "Arial,72"), (None, {"bold": "yes"})],
|
||||
)
|
||||
def test_subtitle_look_refuses_a_style_it_cannot_use(sub_format, fields):
|
||||
with pytest.raises(ValueError):
|
||||
subtitle_look(sub_format, "My Font" if fields is None else None, fields)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["..", "../other"])
|
||||
def test_a_font_name_that_is_a_path_finds_no_custom_font(tmp_path, monkeypatch, name):
|
||||
monkeypatch.setattr("unshackle.core.config.config.directories.fonts", tmp_path / "fonts", raising=False)
|
||||
for folder in ("fonts/My Font", "other"):
|
||||
(tmp_path / folder).mkdir(parents=True)
|
||||
(tmp_path / folder / "x.ttf").write_bytes(b"")
|
||||
(tmp_path / "x.ttf").write_bytes(b"")
|
||||
assert dl.find_custom_font(None, "My Font") == tmp_path / "fonts/My Font/x.ttf" # type: ignore[arg-type]
|
||||
assert dl.find_custom_font(None, name) is None # type: ignore[arg-type]
|
||||
+102
-2
@@ -20,7 +20,8 @@ from datetime import date, timedelta
|
||||
from functools import partial
|
||||
from http.cookiejar import CookieJar, MozillaCookieJar
|
||||
from io import StringIO
|
||||
from itertools import product
|
||||
from itertools import groupby, product
|
||||
from operator import attrgetter
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Any, Callable, Collection, Optional, Sequence, TypedDict, Union, cast
|
||||
@@ -102,6 +103,7 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
||||
from unshackle.core.tracks.attachment import Attachment
|
||||
from unshackle.core.tracks.dv_fixup import apply_dv_fixup
|
||||
from unshackle.core.tracks.hybrid import Hybrid
|
||||
from unshackle.core.tracks.subtitle_convert import SUBSTATION, build_ass_style
|
||||
from unshackle.core.tracks.track import Track, assert_fragments_decrypted, has_encrypted_sample_entry
|
||||
from unshackle.core.tracks.tracks import at_resolution
|
||||
from unshackle.core.utilities import (
|
||||
@@ -151,6 +153,8 @@ from unshackle.core.vault import Vault
|
||||
from unshackle.core.vaults import Vaults
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pysubs2
|
||||
|
||||
from unshackle.core.remote_service import ServerKeySource
|
||||
|
||||
|
||||
@@ -276,6 +280,41 @@ def keep_generated_dv_bases(videos: Sequence[Video], bases: Sequence[Video.Range
|
||||
return [v for v in videos if v.range != Video.Range.DV and (v.range not in HYBRID_BASE_RANGES or v.range in bases)]
|
||||
|
||||
|
||||
def subtitle_look(
|
||||
sub_format: Optional[Union[Subtitle.Codec, str]],
|
||||
sub_font: Optional[str],
|
||||
style_fields: Any,
|
||||
*,
|
||||
typed_font: bool = True,
|
||||
typed_format: bool = True,
|
||||
) -> tuple[Optional[Union[Subtitle.Codec, str]], Optional[pysubs2.SSAStyle]]:
|
||||
"""The subtitle format and ASS style for --sub-font and the subtitle.ass_style config.
|
||||
|
||||
A style makes ASS the format when none is set. Another format leaves the style unused. A typed --sub-font
|
||||
overrides a format from config, refuses a typed one, and a font from config is ignored like the config style.
|
||||
"""
|
||||
if typed_font and sub_font and sub_format is not None and sub_format not in SUBSTATION:
|
||||
if typed_format:
|
||||
raise ValueError(f"--sub-font needs ASS subtitles, not {getattr(sub_format, 'name', sub_format)}")
|
||||
sub_format = None
|
||||
if not (style_fields or sub_font) or sub_format not in (None, *SUBSTATION):
|
||||
return sub_format, None
|
||||
if not isinstance(style_fields, (dict, type(None))):
|
||||
raise ValueError("subtitle.ass_style must be a mapping of style fields")
|
||||
style = build_ass_style({**(style_fields or {}), **({"fontname": sub_font} if sub_font else {})})
|
||||
return sub_format or Subtitle.Codec.SubStationAlphav4, style
|
||||
|
||||
|
||||
def set_subtitle_look(
|
||||
subtitles: Iterable[Subtitle], style: Optional[pysubs2.SSAStyle], videos: Iterable[Video]
|
||||
) -> None:
|
||||
"""Give each subtitle that is not already SSA/ASS the style, and every subtitle the size of the first video."""
|
||||
size = next(((v.width, v.height) for v in videos if v.width and v.height), None)
|
||||
for subtitle in subtitles:
|
||||
subtitle.ass_style = None if subtitle.codec in SUBSTATION else style
|
||||
subtitle.ass_video_size = size
|
||||
|
||||
|
||||
def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[str]:
|
||||
"""The message that stops HYBRID for these selected videos, or None when it can go on."""
|
||||
if any(v.native_hybrid for v in videos):
|
||||
@@ -574,8 +613,10 @@ class dl:
|
||||
font_name: Font family name to find
|
||||
|
||||
Returns:
|
||||
Path to font file, or None if not found
|
||||
Path to font file, or None if not found. A name that is a path, not one folder name, finds nothing.
|
||||
"""
|
||||
if Path(font_name).name != font_name or font_name in ("", ".."):
|
||||
return None
|
||||
family_dir = Path(config.directories.fonts, font_name)
|
||||
if family_dir.exists():
|
||||
fonts = list(family_dir.glob("*.*tf"))
|
||||
@@ -995,6 +1036,13 @@ class dl:
|
||||
help="Renumber episodes to a TVDB season order. Services normally use 'official' (aired) order; "
|
||||
"a series' Streaming Order is 'alternate'. Defaults to the tvdb_order config option.",
|
||||
)
|
||||
@click.option(
|
||||
"-ce",
|
||||
"--continuous-episodes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Number each season on from the season before it, e.g. S02E05 after a four-episode season one.",
|
||||
)
|
||||
@click.option(
|
||||
"--anilist",
|
||||
"anilist_id",
|
||||
@@ -1033,6 +1081,12 @@ class dl:
|
||||
default=None,
|
||||
help="Set Output Subtitle Format, only converting if necessary. Use 'original' to keep source format.",
|
||||
)
|
||||
@click.option(
|
||||
"--sub-font",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Font for subtitles converted to ASS, with the subtitle.ass_style look. Implies --sub-format ASS.",
|
||||
)
|
||||
@click.option("-V", "--video-only", is_flag=True, default=False, help="Only download video tracks.")
|
||||
@click.option("-A", "--audio-only", is_flag=True, default=False, help="Only download audio tracks.")
|
||||
@click.option("-S", "--subs-only", is_flag=True, default=False, help="Only download subtitle tracks.")
|
||||
@@ -1271,6 +1325,7 @@ class dl:
|
||||
LICENSE_KEY_CACHE: dict[UUID, str] = {}
|
||||
ALL_DRM_LOCK = Lock()
|
||||
_remote_service: Optional["ServerKeySource"] = None
|
||||
typed_params: frozenset[str] = frozenset()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1284,6 +1339,7 @@ class dl:
|
||||
imdb_id: Optional[str] = None,
|
||||
tvdb_id: Optional[int] = None,
|
||||
tvdb_order: Optional[str] = None,
|
||||
continuous_episodes: bool = False,
|
||||
anilist_id: Optional[Union[int, str]] = None,
|
||||
enrich: bool = False,
|
||||
daily: bool = False,
|
||||
@@ -1296,6 +1352,11 @@ class dl:
|
||||
raise ValueError("A subcommand to invoke was not specified, the main code cannot continue.")
|
||||
|
||||
self.log = logging.getLogger("download")
|
||||
self.typed_params = frozenset(
|
||||
name
|
||||
for name in ("sub_font", "sub_format")
|
||||
if ctx.get_parameter_source(name) not in (None, ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP)
|
||||
)
|
||||
self.completed_files: list[Path] = []
|
||||
# Subtitles skipped under --skip-subtitle-errors, recorded so an embedding caller can
|
||||
# report which weren't available without parsing the console output. See SkippedSubtitle.
|
||||
@@ -1322,6 +1383,7 @@ class dl:
|
||||
imdb_id = ctx.params.get("imdb_id", imdb_id)
|
||||
tvdb_id = ctx.params.get("tvdb_id", tvdb_id)
|
||||
tvdb_order = ctx.params.get("tvdb_order", tvdb_order)
|
||||
continuous_episodes = ctx.params.get("continuous_episodes", continuous_episodes)
|
||||
anilist_id = ctx.params.get("anilist_id", anilist_id)
|
||||
enrich = ctx.params.get("enrich", enrich)
|
||||
daily = ctx.params.get("daily", daily)
|
||||
@@ -1337,6 +1399,7 @@ class dl:
|
||||
self.tvdb_order = (tvdb_order or config.tvdb_order or "").lower()
|
||||
if self.tvdb_order and self.tvdb_order not in SEASON_TYPES:
|
||||
raise click.UsageError(f"tvdb_order must be one of {', '.join(SEASON_TYPES)}, not {self.tvdb_order!r}")
|
||||
self.continuous_episodes = continuous_episodes
|
||||
self.enrich = enrich
|
||||
self.daily = daily
|
||||
self.output_dir = output_dir
|
||||
@@ -1685,6 +1748,7 @@ class dl:
|
||||
forced_s_lang: list[str],
|
||||
exact_lang: bool,
|
||||
sub_format: Optional[Union[Subtitle.Codec, str]],
|
||||
sub_font: Optional[str],
|
||||
video_only: bool,
|
||||
audio_only: bool,
|
||||
subs_only: bool,
|
||||
@@ -1916,6 +1980,18 @@ class dl:
|
||||
self.log.error("--select-titles and -w/--wanted cannot be used together")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
sub_format, ass_style = subtitle_look(
|
||||
sub_format,
|
||||
sub_font,
|
||||
config.subtitle.get("ass_style"),
|
||||
typed_font="sub_font" in self.typed_params,
|
||||
typed_format="sub_format" in self.typed_params,
|
||||
)
|
||||
except ValueError as e:
|
||||
self.log.error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if generate_dv and Video.Range.DV in range_ and Video.Range.HYBRID not in range_:
|
||||
self.log.warning("--generate-dv ignored: DV is in the selected ranges")
|
||||
range_, generated_dv_bases = generated_dv_ranges(range_, generate_dv)
|
||||
@@ -2063,6 +2139,9 @@ class dl:
|
||||
if self.tvdb_order and isinstance(titles, Series):
|
||||
titles = self.apply_tvdb_order(titles, title_cacher, cache_title_id, cache_region, cache_account_hash)
|
||||
|
||||
if self.continuous_episodes and isinstance(titles, Series):
|
||||
titles = self.apply_continuous_episodes(titles)
|
||||
|
||||
console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2)))
|
||||
console.print(Padding(titles.tree(verbose=list_titles), (0, 5)))
|
||||
|
||||
@@ -3323,6 +3402,7 @@ class dl:
|
||||
shutil.copy2(subtitle.path, original_path)
|
||||
sidecar_original_paths[subtitle.id] = original_path
|
||||
|
||||
set_subtitle_look(title.tracks.subtitles, ass_style, title.tracks.videos)
|
||||
with console.status("Converting Subtitles..."):
|
||||
sub_conversions: dict[tuple[str, str], int] = {}
|
||||
for subtitle in title.tracks.subtitles:
|
||||
@@ -4099,6 +4179,26 @@ class dl:
|
||||
if filled:
|
||||
self.log.info("Filled absolute numbers for %d episode(s) from TVDB (ID %s).", filled, tvdb_id)
|
||||
|
||||
def apply_continuous_episodes(self, titles: Series) -> Series:
|
||||
"""Number each season on from the last episode of the season before it.
|
||||
|
||||
Season 0 and episode 0 keep their numbers, and a season that already starts past the season before it is kept.
|
||||
The Series is rebuilt, as it does not re-sort an episode whose number changed.
|
||||
"""
|
||||
last = 0
|
||||
renumbered = 0
|
||||
for season, group in groupby(titles, key=attrgetter("season")):
|
||||
episodes = [t for t in group if t.number]
|
||||
if not season or not episodes:
|
||||
continue
|
||||
if min(t.number for t in episodes) <= last:
|
||||
for t in episodes:
|
||||
t.number += last
|
||||
renumbered += len(episodes)
|
||||
last = max(t.number for t in episodes)
|
||||
self.log.info("Numbered %d episode(s) on from the season before.", renumbered)
|
||||
return type(titles)(list(titles))
|
||||
|
||||
def apply_tvdb_order(
|
||||
self,
|
||||
titles: Series,
|
||||
|
||||
@@ -92,6 +92,7 @@ DEFAULT_DOWNLOAD_PARAMS = {
|
||||
"forced_s_lang": [],
|
||||
"exact_lang": False,
|
||||
"sub_format": None,
|
||||
"sub_font": None,
|
||||
"video_only": False,
|
||||
"audio_only": False,
|
||||
"subs_only": False,
|
||||
@@ -133,6 +134,7 @@ DEFAULT_DOWNLOAD_PARAMS = {
|
||||
"imdb_id": None,
|
||||
"tvdb_id": None,
|
||||
"tvdb_order": None,
|
||||
"continuous_episodes": False,
|
||||
"anilist_id": None,
|
||||
"enrich": False,
|
||||
"daily": False,
|
||||
@@ -2252,13 +2254,19 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
|
||||
if value is not None and (not isinstance(value, list) or not all(isinstance(v, str) for v in value)):
|
||||
return f"{key} must be an array of language strings"
|
||||
|
||||
from unshackle.commands.dl import dl
|
||||
from unshackle.commands.dl import dl, subtitle_look
|
||||
|
||||
try:
|
||||
cast_dl_params(dl.cli, data)
|
||||
params = cast_dl_params(dl.cli, data)
|
||||
except click.BadParameter as e:
|
||||
return e.message
|
||||
|
||||
if params.get("sub_font"):
|
||||
try:
|
||||
subtitle_look(params.get("sub_format"), params["sub_font"], None)
|
||||
except ValueError as e:
|
||||
return f"Invalid sub_font: {e}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1036,6 +1036,9 @@ async def download(request: web.Request) -> web.Response:
|
||||
sub_format:
|
||||
type: string
|
||||
description: Output subtitle format such as SRT or VTT, or "original" to keep the source format (default - None)
|
||||
sub_font:
|
||||
type: string
|
||||
description: Font for subtitles converted to ASS, implies sub_format ASS (default - None)
|
||||
video_only:
|
||||
type: boolean
|
||||
description: Only download video tracks (default - false)
|
||||
@@ -1184,6 +1187,9 @@ async def download(request: web.Request) -> web.Response:
|
||||
type: string
|
||||
enum: [official, dvd, absolute, alternate, regional]
|
||||
description: Renumber episodes to a TVDB season order (default - the tvdb_order config option)
|
||||
continuous_episodes:
|
||||
type: boolean
|
||||
description: Number each season on from the season before it, e.g. S02E05 after a four-episode season one (default - false)
|
||||
output_dir:
|
||||
type: string
|
||||
description: >
|
||||
|
||||
@@ -44,7 +44,7 @@ class Widevine:
|
||||
raise TypeError(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||
|
||||
if pssh.system_id == PSSH.SystemId.PlayReady:
|
||||
pssh.to_widevine()
|
||||
pssh = self.from_playready(pssh)
|
||||
|
||||
self._kid: Optional[UUID] = None
|
||||
if kid:
|
||||
@@ -68,6 +68,27 @@ class Widevine:
|
||||
self.content_keys: dict[UUID, str] = {}
|
||||
self.data: dict = kwargs or {}
|
||||
|
||||
@staticmethod
|
||||
def from_playready(pssh: PSSH) -> PSSH:
|
||||
"""A Widevine PSSH naming a v1 box's own KIDs, then the KIDs the PlayReady class reads from it.
|
||||
|
||||
pywidevine's own conversion fails on a bare WRMHEADER and byte-swaps PlayReady KIDs.
|
||||
"""
|
||||
from pyplayready.misc.exceptions import InvalidPssh
|
||||
|
||||
from unshackle.core.drm.playready import PlayReady
|
||||
|
||||
box = pssh.dump()
|
||||
kids: list[UUID] = list(Box.parse(box).key_IDs or [])
|
||||
try:
|
||||
kids += PlayReady.from_pssh_b64(base64.b64encode(box).decode()).kids
|
||||
except PlayReady.Exceptions.KIDNotFound:
|
||||
pass
|
||||
except InvalidPssh:
|
||||
if not kids:
|
||||
raise
|
||||
return PSSH.new(system_id=PSSH.SystemId.Widevine, key_ids=list(dict.fromkeys(kids)))
|
||||
|
||||
@classmethod
|
||||
def from_track(cls, track: AnyTrack, session: Optional[AnySession] = None) -> Widevine:
|
||||
"""
|
||||
|
||||
@@ -552,11 +552,12 @@ def parse_air_date(value: Any) -> Any:
|
||||
|
||||
|
||||
def apply_title_fields(title: Title_T, info: Dict[str, Any]) -> None:
|
||||
"""Copy every serialized title field onto a local title.
|
||||
"""Copy the serialized metadata fields onto a local title.
|
||||
|
||||
A service can change its title inside ``get_tracks`` (a corrected original language,
|
||||
a real episode name, an air date), so the client re-applies the server's view of the
|
||||
title after every call that returns one.
|
||||
title after every call that returns one. Season, number and part are set only by
|
||||
``build_title``, as dl can renumber them after the listing.
|
||||
"""
|
||||
if info.get("language"):
|
||||
title.language = Language.get(info["language"])
|
||||
@@ -570,9 +571,8 @@ def apply_title_fields(title: Title_T, info: Dict[str, Any]) -> None:
|
||||
title.title = info["series_title"]
|
||||
if "name" in info:
|
||||
title.name = episode_name(info["name"], title.title)
|
||||
for key in ("season", "number", "part", "absolute"):
|
||||
if info.get(key) is not None:
|
||||
setattr(title, key, info[key])
|
||||
if info.get("absolute") is not None:
|
||||
title.absolute = info["absolute"]
|
||||
if info.get("air_date") is not None:
|
||||
title.air_date = parse_air_date(info["air_date"])
|
||||
elif info.get("name"):
|
||||
@@ -602,6 +602,7 @@ def build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Tit
|
||||
title=info.get("series_title", "Unknown"),
|
||||
season=info.get("season", 0),
|
||||
number=info.get("number", 0),
|
||||
part=info.get("part"),
|
||||
language=lang,
|
||||
)
|
||||
elif title_type == "movie":
|
||||
|
||||
@@ -9,7 +9,7 @@ from enum import Enum
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Optional, Sequence, Union
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Sequence, Union
|
||||
|
||||
import click
|
||||
import pycaption
|
||||
@@ -29,6 +29,9 @@ from unshackle.core.utilities import PYMP4_LOCK, try_ensure_utf8, with_script
|
||||
from unshackle.core.utils.subprocess import log_tool_run
|
||||
from unshackle.core.utils.webvtt import merge_segmented_webvtt, rebase_webvtt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pysubs2
|
||||
|
||||
# silence srt library INFO logging
|
||||
logging.getLogger("srt").setLevel(logging.ERROR)
|
||||
|
||||
@@ -186,6 +189,9 @@ class Subtitle(Track):
|
||||
if self.forced and (self.cc or self.sdh):
|
||||
raise ValueError("A text track cannot be CC/SDH as well as Forced.")
|
||||
|
||||
self.ass_style: Optional[pysubs2.SSAStyle] = None
|
||||
self.ass_video_size: Optional[tuple[int, int]] = None
|
||||
|
||||
# TODO: Migrate to new event observer system
|
||||
# Called after Track has been converted to another format
|
||||
self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None
|
||||
@@ -1227,6 +1233,7 @@ class Subtitle(Track):
|
||||
format=output_format,
|
||||
)
|
||||
else:
|
||||
styled_codec = self.codec if self.ass_style else None
|
||||
if config.subtitle.get("convert_before_strip", True) and self.codec != Subtitle.Codec.SubRip:
|
||||
# Filter reads SRT only; force the (possibly lossy) conversion so content and
|
||||
# codec label stay in sync. convert() relabels path/codec only on real conversion.
|
||||
@@ -1255,6 +1262,8 @@ class Subtitle(Track):
|
||||
log.warning(f"Failed to strip SDH from {self.path.name}, keeping it as is: {e}")
|
||||
else:
|
||||
raise
|
||||
if styled_codec and self.codec != styled_codec and self.path.read_text("utf-8", errors="replace").strip():
|
||||
self.convert(styled_codec)
|
||||
|
||||
def reverse_rtl(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -16,12 +16,13 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from typing import Optional, Protocol
|
||||
from typing import Any, Mapping, Optional, Protocol
|
||||
|
||||
import pycaption
|
||||
import pysubs2
|
||||
@@ -315,6 +316,110 @@ def decode_entities(out: Path) -> None:
|
||||
out.write_text(decoded, encoding="utf8")
|
||||
|
||||
|
||||
ASS_CANVAS_HEIGHT = 1080
|
||||
ASS_STYLE_DEFAULTS: dict[str, Any] = {
|
||||
"fontsize": 72,
|
||||
"bold": True,
|
||||
"outline": 3.6,
|
||||
"shadow": 1.5,
|
||||
"backcolor": "&HA0000000",
|
||||
"marginl": 100,
|
||||
"marginr": 100,
|
||||
"marginv": 60,
|
||||
}
|
||||
NON_NEGATIVE_STYLE_FIELDS = frozenset({"fontsize", "outline", "shadow", "scalex", "scaley"})
|
||||
|
||||
|
||||
def ass_color(text: str) -> pysubs2.Color:
|
||||
"""Read an ``&HAABBGGRR`` colour, the form an ASS style line uses."""
|
||||
value = int(text.strip().upper().removeprefix("&H").rstrip("&"), 16)
|
||||
if not 0 <= value <= 0xFFFFFFFF:
|
||||
raise ValueError(f"{text!r} is not an &HAABBGGRR colour")
|
||||
return pysubs2.Color(value & 0xFF, value >> 8 & 0xFF, value >> 16 & 0xFF, value >> 24 & 0xFF)
|
||||
|
||||
|
||||
def style_field_value(name: str, current: Any, value: Any) -> Any:
|
||||
"""``value`` as the type of the style field that holds ``current``; ValueError or TypeError when it does not fit."""
|
||||
if isinstance(current, pysubs2.Color):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError
|
||||
return ass_color(value)
|
||||
if isinstance(current, bool) != isinstance(value, bool):
|
||||
raise ValueError
|
||||
if isinstance(current, bool):
|
||||
return value
|
||||
if isinstance(current, str):
|
||||
if not isinstance(value, str) or not value.strip() or "," in value or not value.isprintable():
|
||||
raise ValueError
|
||||
return value
|
||||
number = float(value)
|
||||
if not math.isfinite(number) or (name in NON_NEGATIVE_STYLE_FIELDS and number < 0):
|
||||
raise ValueError
|
||||
if isinstance(current, float):
|
||||
return number
|
||||
if not number.is_integer():
|
||||
raise ValueError
|
||||
return type(current)(int(number))
|
||||
|
||||
|
||||
def build_ass_style(fields: Mapping[str, Any]) -> pysubs2.SSAStyle:
|
||||
"""Build the style for converted subtitles from style fields over ``ASS_STYLE_DEFAULTS``.
|
||||
|
||||
A field is a pysubs2 name (``primarycolor``) or an ASS one (``PrimaryColour``). Sizes and margins are for a
|
||||
1080-line canvas. A value that does not fit its field raises ValueError.
|
||||
"""
|
||||
style = pysubs2.SSAStyle()
|
||||
for name, value in {**ASS_STYLE_DEFAULTS, **fields}.items():
|
||||
field = str(name).lower().replace("colour", "color")
|
||||
if field not in vars(style):
|
||||
raise ValueError(f"ass_style has no field {name!r}")
|
||||
try:
|
||||
setattr(style, field, style_field_value(field, getattr(style, field), value))
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(f"ass_style {name}: {value!r} is not valid") from None
|
||||
return style
|
||||
|
||||
|
||||
def style_substation(path: Path, style: pysubs2.SSAStyle, video_size: Optional[tuple[int, int]]) -> None:
|
||||
"""Give every style of a converted SSA/ASS file the look of ``style``.
|
||||
|
||||
A file with no PlayResY gets a 1080-line canvas in the shape of the video. A file with one keeps it, so positions
|
||||
a converter wrote stay put, and the style scales to it. Each style keeps its own alignment and italic flag.
|
||||
"""
|
||||
try:
|
||||
subs = pysubs2.load(str(path), encoding="utf-8-sig")
|
||||
except (pysubs2.exceptions.Pysubs2Error, UnicodeDecodeError, ValueError) as e:
|
||||
log.warning(f"Cannot style {path.name}: {e}")
|
||||
return
|
||||
try:
|
||||
height = float(subs.info["PlayResY"])
|
||||
except (KeyError, ValueError):
|
||||
height = 0.0
|
||||
if height <= 0:
|
||||
width, video_height = video_size or (16, 9)
|
||||
height = ASS_CANVAS_HEIGHT
|
||||
subs.info["PlayResX"] = str(round(height * width / video_height))
|
||||
subs.info["PlayResY"] = str(ASS_CANVAS_HEIGHT)
|
||||
if video_size:
|
||||
subs.info["LayoutResX"], subs.info["LayoutResY"] = str(width), str(video_height)
|
||||
subs.info["ScaledBorderAndShadow"] = "yes"
|
||||
|
||||
scale = height / ASS_CANVAS_HEIGHT
|
||||
scaled = style.copy()
|
||||
for name in ("fontsize", "outline", "shadow", "spacing"):
|
||||
setattr(scaled, name, getattr(style, name) * scale)
|
||||
for name in ("marginl", "marginr", "marginv"):
|
||||
setattr(scaled, name, round(getattr(style, name) * scale))
|
||||
|
||||
styles = {}
|
||||
for name, old in subs.styles.items():
|
||||
styles[name] = scaled.copy()
|
||||
styles[name].alignment, styles[name].italic = old.alignment, old.italic
|
||||
styles["Default"] = scaled
|
||||
subs.styles = styles
|
||||
subs.save(str(path), format_=subs.format, encoding="utf-8")
|
||||
|
||||
|
||||
def finalize(sub: Subtitle, target: Codec, out: Path) -> Path:
|
||||
"""Swap the track onto the converted file and fire the OnConverted callback."""
|
||||
original = sub.path
|
||||
@@ -388,6 +493,8 @@ def run_conversion(sub: Subtitle, target: Codec, *, pin: Optional[str] = None, f
|
||||
continue
|
||||
if target in PLAIN_TEXT_TARGETS:
|
||||
decode_entities(out)
|
||||
if sub.ass_style and target in SUBSTATION and source not in SUBSTATION:
|
||||
style_substation(out, sub.ass_style, sub.ass_video_size)
|
||||
return finalize(sub, target, out)
|
||||
finally:
|
||||
if sorted_src:
|
||||
|
||||
@@ -838,9 +838,16 @@ def extract_font_family(font_path: Path) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
WINDOWS_FONTS_DIR = Path(r"C:\Windows\Fonts")
|
||||
|
||||
|
||||
def get_windows_fonts() -> dict[str, Path]:
|
||||
"""
|
||||
Get fonts from Windows registry.
|
||||
Get the fonts installed for all users and for the current user from the Windows registry.
|
||||
|
||||
Only TrueType and OpenType files that exist are kept: a bitmap font cannot be attached, and a per-user entry
|
||||
can outlive its file. Each font is also listed under the family name in its file, because a registry name
|
||||
can differ from it (``GandhiSansPL-Bold`` for the family ``Gandhi Sans PL``).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping font family names to their file paths
|
||||
@@ -849,14 +856,24 @@ def get_windows_fonts() -> dict[str, Path]:
|
||||
raise OSError("Windows font registry is only available on Windows")
|
||||
import winreg
|
||||
|
||||
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg:
|
||||
key = winreg.OpenKey(reg, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ)
|
||||
total_fonts = winreg.QueryInfoKey(key)[1]
|
||||
return {
|
||||
name.replace(" (TrueType)", ""): Path(r"C:\Windows\Fonts", filename)
|
||||
for n in range(0, total_fonts)
|
||||
for name, filename, _ in [winreg.EnumValue(key, n)]
|
||||
}
|
||||
fonts: dict[str, Path] = {}
|
||||
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||||
try:
|
||||
key = winreg.OpenKey(hive, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ)
|
||||
except OSError:
|
||||
continue
|
||||
with key:
|
||||
for n in range(winreg.QueryInfoKey(key)[1]):
|
||||
name, filename, _ = winreg.EnumValue(key, n)
|
||||
if not isinstance(filename, str):
|
||||
continue
|
||||
path = Path(WINDOWS_FONTS_DIR, filename)
|
||||
if path.suffix.lower() in {".ttf", ".otf", ".ttc"} and path.is_file():
|
||||
fonts.setdefault(name.replace(" (TrueType)", ""), path)
|
||||
for path in dict.fromkeys(fonts.values()):
|
||||
if family := extract_font_family(path):
|
||||
fonts.setdefault(family, path)
|
||||
return fonts
|
||||
|
||||
|
||||
def scan_font_directory(font_dir: Path, fonts: dict[str, Path], log: logging.Logger) -> None:
|
||||
|
||||
Reference in New Issue
Block a user