Compare commits

...
4 Commits
Author SHA1 Message Date
imSp4rky a28c99b811 fix(fonts): find per-user Windows fonts by their family name
Read the per-user font registry as well as the machine one, and list each font under the family name in its file. Skip missing files, bitmap fonts and values that are not text.
2026-11-11 10:46:47 -07:00
imSp4rky f85d7f5fa8 feat(subtitles): add --sub-font and subtitle.ass_style for converted ASS
Converted subtitles get one style on a 1080-line canvas shaped like the video; native SSA/ASS is never restyled.
2026-11-11 10:04:57 -07:00
imSp4rky 590a57113b feat(dl): add -ce/--continuous-episodes to number seasons on
Each season's episodes continue from the previous season's last episode (S02E05 after a four-episode S01); also on the REST API. A --remote get_tracks refresh no longer resets season, number and part.
2026-11-11 08:38:27 -07:00
imSp4rky 9fff0a6646 fix(drm): read PlayReady KIDs through the PlayReady class when converting to Widevine
pywidevine's to_widevine fails on a PSSH box with a bare WRMHEADER and byte-swaps PlayReady KIDs, which broke server CDM Widevine licensing.
2026-11-11 06:34:04 -07:00
18 changed files with 834 additions and 22 deletions
+2
View File
@@ -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`. |
+2
View File
@@ -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
+20
View File
@@ -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. |
+56
View File
@@ -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
+2
View File
@@ -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
+3 -1
View File
@@ -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`
+82
View File
@@ -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")
+37
View File
@@ -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]
+92
View File
@@ -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}
+248
View File
@@ -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
View File
@@ -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,
+10 -2
View File
@@ -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
+6
View File
@@ -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: >
+22 -1
View File
@@ -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:
"""
+6 -5
View File
@@ -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":
+10 -1
View File
@@ -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:
"""
+108 -1
View File
@@ -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:
+26 -9
View File
@@ -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: