Merge branch 'dev' into Config-Filenames
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@ unshackle/PRDs/
|
|||||||
temp/
|
temp/
|
||||||
logs/
|
logs/
|
||||||
services/
|
services/
|
||||||
|
/.[^/]*/
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
1050
CHANGELOG.md
1050
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
48
cliff.toml
48
cliff.toml
@@ -1,4 +1,4 @@
|
|||||||
# git-cliff ~ default configuration file
|
# git-cliff ~ configuration file
|
||||||
# https://git-cliff.org/docs/configuration
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
[changelog]
|
[changelog]
|
||||||
@@ -8,8 +8,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
Versions [3.0.0] and older use a format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
This changelog is automatically generated using [git-cliff](https://git-cliff.org).\n
|
||||||
but versions thereafter use a custom changelog format using [git-cliff](https://git-cliff.org).\n
|
|
||||||
"""
|
"""
|
||||||
body = """
|
body = """
|
||||||
{% if version -%}
|
{% if version -%}
|
||||||
@@ -17,9 +16,11 @@ body = """
|
|||||||
{% else -%}
|
{% else -%}
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
{% for group, commits in commits | group_by(attribute="group") %}
|
{% for group, commits in commits
|
||||||
|
| filter(attribute="merge_commit", value=false)
|
||||||
|
| group_by(attribute="group") %}
|
||||||
### {{ group | striptags | trim | upper_first }}
|
### {{ group | striptags | trim | upper_first }}
|
||||||
{% for commit in commits %}
|
{% for commit in commits | unique(attribute="message") %}
|
||||||
- {% if commit.scope %}*{{ commit.scope }}*: {% endif %}\
|
- {% if commit.scope %}*{{ commit.scope }}*: {% endif %}\
|
||||||
{% if commit.breaking %}[**breaking**] {% endif %}\
|
{% if commit.breaking %}[**breaking**] {% endif %}\
|
||||||
{{ commit.message | upper_first }}\
|
{{ commit.message | upper_first }}\
|
||||||
@@ -38,34 +39,39 @@ footer = """
|
|||||||
[unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
|
[unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
|
||||||
/compare/{{ release.previous.version }}..HEAD
|
/compare/{{ release.previous.version }}..HEAD
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
{% endfor %}
|
{% endfor -%}
|
||||||
"""
|
"""
|
||||||
trim = true
|
trim = true
|
||||||
postprocessors = [
|
postprocessors = []
|
||||||
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
|
|
||||||
]
|
|
||||||
|
|
||||||
[git]
|
[git]
|
||||||
conventional_commits = true
|
conventional_commits = true
|
||||||
filter_unconventional = true
|
filter_unconventional = true
|
||||||
split_commits = false
|
split_commits = false
|
||||||
commit_preprocessors = []
|
commit_preprocessors = [
|
||||||
|
# Strip emoji (both UTF-8 and :shortcode: styles) from commit messages
|
||||||
|
{ pattern = ' *(:\w+:|[\p{Emoji_Presentation}\p{Extended_Pictographic}]\x{FE0F}?\x{200D}?) *', replace = "" },
|
||||||
|
# Remove trailing PR/issue numbers like (#123) from commit messages
|
||||||
|
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" },
|
||||||
|
]
|
||||||
commit_parsers = [
|
commit_parsers = [
|
||||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||||
{ message = "^fix|revert", group = "<!-- 1 -->Bug Fixes" },
|
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||||
{ message = "^docs", group = "<!-- 2 -->Documentation" },
|
{ message = "^revert", group = "<!-- 2 -->Reverts" },
|
||||||
{ message = "^style", skip = true },
|
{ message = "^docs", group = "<!-- 3 -->Documentation" },
|
||||||
{ message = "^refactor", group = "<!-- 3 -->Changes" },
|
|
||||||
{ message = "^perf", group = "<!-- 4 -->Performance Improvements" },
|
{ message = "^perf", group = "<!-- 4 -->Performance Improvements" },
|
||||||
|
{ message = "^refactor", group = "<!-- 5 -->Changes" },
|
||||||
|
{ message = "^style", skip = true },
|
||||||
{ message = "^test", skip = true },
|
{ message = "^test", skip = true },
|
||||||
{ message = "^build", group = "<!-- 5 -->Builds" },
|
{ message = "^chore\\(release\\)", skip = true },
|
||||||
|
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||||
|
{ message = "^chore\\(pr\\)", skip = true },
|
||||||
|
{ message = "^chore", group = "<!-- 6 -->Maintenance" },
|
||||||
{ message = "^ci", skip = true },
|
{ message = "^ci", skip = true },
|
||||||
{ message = "^chore", skip = true },
|
{ message = "^build", group = "<!-- 7 -->Builds" },
|
||||||
|
{ body = ".*security", group = "<!-- 8 -->Security" },
|
||||||
]
|
]
|
||||||
protect_breaking_commits = false
|
protect_breaking_commits = true
|
||||||
filter_commits = false
|
filter_commits = true
|
||||||
# tag_pattern = "v[0-9].*"
|
|
||||||
# skip_tags = ""
|
|
||||||
# ignore_tags = ""
|
|
||||||
topo_order = false
|
topo_order = false
|
||||||
sort_commits = "oldest"
|
sort_commits = "oldest"
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ Options:
|
|||||||
- `curl_impersonate` - <https://github.com/yifeikong/curl-impersonate> (via <https://github.com/yifeikong/curl_cffi>)
|
- `curl_impersonate` - <https://github.com/yifeikong/curl-impersonate> (via <https://github.com/yifeikong/curl_cffi>)
|
||||||
- `n_m3u8dl_re` - <https://github.com/nilaoda/N_m3u8DL-RE>
|
- `n_m3u8dl_re` - <https://github.com/nilaoda/N_m3u8DL-RE>
|
||||||
|
|
||||||
Note that aria2c can reach the highest speeds as it utilizes threading and more connections than the other downloaders. However, aria2c can also be one of the more unstable downloaders. It will work one day, then not another day. It also does not support HTTP(S) proxies while the other downloaders do.
|
Note that aria2c can reach the highest speeds as it utilizes threading and more connections than the other downloaders. However, aria2c can also be one of the more unstable downloaders. It will work one day, then not another day. It also does not support HTTP(S) proxies natively (non-HTTP proxies are bridged via pproxy).
|
||||||
|
|
||||||
|
Note that `n_m3u8dl_re` will automatically fall back to `requests` for track types it does not support, specifically: direct URL downloads, Subtitle tracks, and Attachment tracks.
|
||||||
|
|
||||||
Example mapping:
|
Example mapping:
|
||||||
|
|
||||||
@@ -72,10 +74,12 @@ The `default` entry is optional. If omitted, `requests` will be used for service
|
|||||||
|
|
||||||
## n_m3u8dl_re (dict)
|
## n_m3u8dl_re (dict)
|
||||||
|
|
||||||
Configuration for N_m3u8DL-RE downloader. This downloader is particularly useful for HLS streams.
|
Configuration for N_m3u8DL-RE downloader. This downloader supports HLS, DASH, and ISM (Smooth Streaming) manifests.
|
||||||
|
It will automatically fall back to the `requests` downloader for unsupported track types (direct URLs, subtitles, attachments).
|
||||||
|
|
||||||
- `thread_count`
|
- `thread_count`
|
||||||
Number of threads to use for downloading. Default: Uses the same value as max_workers from the command.
|
Number of threads to use for downloading. Default: Uses the same value as max_workers from the command
|
||||||
|
(which defaults to `min(32,(cpu_count+4))`).
|
||||||
- `ad_keyword`
|
- `ad_keyword`
|
||||||
Keyword to identify and potentially skip advertisement segments. Default: `None`
|
Keyword to identify and potentially skip advertisement segments. Default: `None`
|
||||||
- `use_proxy`
|
- `use_proxy`
|
||||||
@@ -83,6 +87,9 @@ Configuration for N_m3u8DL-RE downloader. This downloader is particularly useful
|
|||||||
- `retry_count`
|
- `retry_count`
|
||||||
Number of times to retry failed downloads. Default: `10`
|
Number of times to retry failed downloads. Default: `10`
|
||||||
|
|
||||||
|
N_m3u8DL-RE also respects the `decryption` config setting. When content keys are provided, it will use
|
||||||
|
the configured decryption engine (`shaka` or `mp4decrypt`) and automatically locate the corresponding binary.
|
||||||
|
|
||||||
For example,
|
For example,
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -140,6 +147,57 @@ sub_format: vtt
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## subtitle (dict)
|
||||||
|
|
||||||
|
Configuration for subtitle processing and conversion.
|
||||||
|
|
||||||
|
- `conversion_method`
|
||||||
|
Method to use for converting subtitles between formats. Default: `"auto"`
|
||||||
|
- `"auto"` — Smart routing: uses subby for WebVTT/SAMI, pycaption for others.
|
||||||
|
- `"subby"` — Always use subby with advanced processing.
|
||||||
|
- `"pycaption"` — Use only pycaption library (no SubtitleEdit, no subby).
|
||||||
|
- `"subtitleedit"` — Prefer SubtitleEdit when available, fall back to pycaption.
|
||||||
|
- `"pysubs2"` — Use pysubs2 library (supports SRT/SSA/ASS/WebVTT/TTML/SAMI/MicroDVD/MPL2/TMP).
|
||||||
|
- `sdh_method`
|
||||||
|
Method to use for SDH (hearing impaired) stripping. Default: `"auto"`
|
||||||
|
- `"auto"` — Try subby (SRT only), then SubtitleEdit (if available), then subtitle-filter.
|
||||||
|
- `"subby"` — Use subby library (SRT only).
|
||||||
|
- `"subtitleedit"` — Use SubtitleEdit tool (Windows only, falls back to subtitle-filter).
|
||||||
|
- `"filter-subs"` — Use subtitle-filter library directly.
|
||||||
|
- `strip_sdh`
|
||||||
|
Automatically create stripped (non-SDH) versions of SDH subtitles. Default: `true`
|
||||||
|
- `convert_before_strip`
|
||||||
|
Auto-convert VTT/other formats to SRT before using subtitle-filter for SDH stripping.
|
||||||
|
Ensures compatibility when subtitle-filter is used as fallback. Default: `true`
|
||||||
|
- `preserve_formatting`
|
||||||
|
Preserve original subtitle formatting (tags, positioning, styling).
|
||||||
|
When `true`, skips pycaption processing for WebVTT files to keep tags like `<i>`, `<b>`,
|
||||||
|
positioning intact. Combined with no `sub_format` setting, ensures subtitles remain in
|
||||||
|
their original format. Default: `true`
|
||||||
|
- `output_mode`
|
||||||
|
Output mode for subtitles. Default: `"mux"`
|
||||||
|
- `"mux"` — Embed subtitles in MKV container only.
|
||||||
|
- `"sidecar"` — Save subtitles as separate files only.
|
||||||
|
- `"both"` — Embed in MKV and save as sidecar files.
|
||||||
|
- `sidecar_format`
|
||||||
|
Format for sidecar subtitle files when `output_mode` is `"sidecar"` or `"both"`. Default: `"srt"`
|
||||||
|
Options: `srt`, `vtt`, `ass`, `original` (keep current format).
|
||||||
|
|
||||||
|
For example,
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
subtitle:
|
||||||
|
conversion_method: auto
|
||||||
|
sdh_method: auto
|
||||||
|
strip_sdh: true
|
||||||
|
convert_before_strip: true
|
||||||
|
preserve_formatting: true
|
||||||
|
output_mode: mux
|
||||||
|
sidecar_format: srt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## decryption (str | dict)
|
## decryption (str | dict)
|
||||||
|
|
||||||
Choose what software to use to decrypt DRM-protected content throughout unshackle where needed.
|
Choose what software to use to decrypt DRM-protected content throughout unshackle where needed.
|
||||||
|
|||||||
@@ -41,6 +41,29 @@ DSNP:
|
|||||||
default: chromecdm_903_l3
|
default: chromecdm_903_l3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also select CDMs based on video resolution using comparison operators (`>=`, `>`, `<=`, `<`)
|
||||||
|
or exact match on the resolution height.
|
||||||
|
|
||||||
|
For example,
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
EXAMPLE:
|
||||||
|
"<=1080": generic_android_l3 # Use L3 for 1080p and below
|
||||||
|
">1080": nexus_5_l1 # Use L1 for above 1080p (1440p, 2160p)
|
||||||
|
default: generic_android_l3 # Fallback if no quality match
|
||||||
|
```
|
||||||
|
|
||||||
|
You can mix profiles and quality thresholds in the same service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
NETFLIX:
|
||||||
|
john: netflix_l3_profile # Profile-based selection
|
||||||
|
"<=720": netflix_mobile_l3 # Quality-based selection
|
||||||
|
"1080": netflix_standard_l3 # Exact match for 1080p
|
||||||
|
">=1440": netflix_premium_l1 # Quality-based selection
|
||||||
|
default: netflix_standard_l3 # Fallback
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## remote_cdm (list\[dict])
|
## remote_cdm (list\[dict])
|
||||||
@@ -113,8 +136,10 @@ remote_cdm:
|
|||||||
- Support for both Widevine and PlayReady
|
- Support for both Widevine and PlayReady
|
||||||
- Multiple security levels (L1, L2, L3, SL2000, SL3000)
|
- Multiple security levels (L1, L2, L3, SL2000, SL3000)
|
||||||
|
|
||||||
**Note:** The `device_type` and `security_level` fields are optional metadata. They don't affect API communication
|
**Note:** The `device_type` field determines whether the CDM operates in PlayReady or Widevine mode.
|
||||||
but are used for internal device identification.
|
Setting `device_type: PLAYREADY` (or using `device_name: SL2` / `SL3`) activates PlayReady mode.
|
||||||
|
The `security_level` field is auto-computed from `device_name` when not specified (e.g., SL2 defaults
|
||||||
|
to 2000, SL3 to 3000, and Widevine devices default to 3). You can override these if needed.
|
||||||
|
|
||||||
### Custom API Remote CDM
|
### Custom API Remote CDM
|
||||||
|
|
||||||
@@ -171,7 +196,7 @@ remote_cdm:
|
|||||||
header_name: X-API-Key
|
header_name: X-API-Key
|
||||||
key: YOUR_SECRET_KEY
|
key: YOUR_SECRET_KEY
|
||||||
custom_headers:
|
custom_headers:
|
||||||
User-Agent: Unshackle/2.0.0
|
User-Agent: Unshackle/3.1.0
|
||||||
X-Client-Version: "1.0"
|
X-Client-Version: "1.0"
|
||||||
|
|
||||||
# Endpoint configuration
|
# Endpoint configuration
|
||||||
@@ -224,6 +249,7 @@ remote_cdm:
|
|||||||
- `header` - Custom header authentication
|
- `header` - Custom header authentication
|
||||||
- `basic` - HTTP Basic authentication
|
- `basic` - HTTP Basic authentication
|
||||||
- `body` - Credentials in request body
|
- `body` - Credentials in request body
|
||||||
|
- `query` - Authentication added to query string parameters
|
||||||
|
|
||||||
### Legacy PyWidevine Serve Format
|
### Legacy PyWidevine Serve Format
|
||||||
|
|
||||||
@@ -266,6 +292,58 @@ used as a fallback.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## decryption (str|dict)
|
||||||
|
|
||||||
|
Configure which decryption tool to use for DRM-protected content. Default: `shaka`.
|
||||||
|
|
||||||
|
Supported values:
|
||||||
|
- `shaka` - Shaka Packager (default)
|
||||||
|
- `mp4decrypt` - Bento4 mp4decrypt
|
||||||
|
|
||||||
|
You can specify a single decrypter for all services:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
decryption: shaka
|
||||||
|
```
|
||||||
|
|
||||||
|
Or configure per-service with a `DEFAULT` fallback:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
decryption:
|
||||||
|
DEFAULT: shaka
|
||||||
|
AMZN: mp4decrypt
|
||||||
|
NF: shaka
|
||||||
|
```
|
||||||
|
|
||||||
|
Service keys are case-insensitive (normalized to uppercase internally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MonaLisa DRM
|
||||||
|
|
||||||
|
MonaLisa is a WASM-based DRM system that uses local key extraction and two-stage segment decryption.
|
||||||
|
Unlike Widevine and PlayReady, MonaLisa does not use a challenge/response flow with a license server.
|
||||||
|
Instead, the PSSH value (ticket) is provided directly by the service API, and keys are extracted
|
||||||
|
locally via a WASM module.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- **ML-Worker binary**: Must be available on your system `PATH` (discovered via `binaries.ML_Worker`).
|
||||||
|
This is the binary that performs stage-1 decryption.
|
||||||
|
|
||||||
|
### Decryption stages
|
||||||
|
|
||||||
|
1. **ML-Worker binary**: Removes MonaLisa encryption layer (bbts -> ents). The key is passed via command-line argument.
|
||||||
|
2. **AES-ECB decryption**: Final decryption with service-provided key.
|
||||||
|
|
||||||
|
MonaLisa uses per-segment decryption during download (not post-download like Widevine/PlayReady),
|
||||||
|
so segments are decrypted as they are downloaded.
|
||||||
|
|
||||||
|
**Note:** MonaLisa is configured per-service rather than through global config options. Services
|
||||||
|
that use MonaLisa handle ticket/key retrieval and CDM initialization internally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## key_vaults (list\[dict])
|
## key_vaults (list\[dict])
|
||||||
|
|
||||||
Key Vaults store your obtained Content Encryption Keys (CEKs) and Key IDs per-service.
|
Key Vaults store your obtained Content Encryption Keys (CEKs) and Key IDs per-service.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Format: `gluetun:provider:region`
|
|||||||
|
|
||||||
**OpenVPN (Recommended)**: Most providers support OpenVPN with just `username` and `password` - the simplest setup.
|
**OpenVPN (Recommended)**: Most providers support OpenVPN with just `username` and `password` - the simplest setup.
|
||||||
|
|
||||||
**WireGuard**: Requires private keys and varies by provider. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for provider-specific requirements.
|
**WireGuard**: Requires private keys and varies by provider. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for provider-specific requirements. Note that `vpn_type` defaults to `wireguard` if not specified.
|
||||||
|
|
||||||
## Getting Your Credentials
|
## Getting Your Credentials
|
||||||
|
|
||||||
@@ -104,6 +104,13 @@ providers:
|
|||||||
credentials:
|
credentials:
|
||||||
private_key: YOUR_PRIVATE_KEY
|
private_key: YOUR_PRIVATE_KEY
|
||||||
|
|
||||||
|
# Surfshark/Mullvad/IVPN (private_key AND addresses required)
|
||||||
|
surfshark:
|
||||||
|
vpn_type: wireguard
|
||||||
|
credentials:
|
||||||
|
private_key: YOUR_PRIVATE_KEY
|
||||||
|
addresses: 10.x.x.x/32
|
||||||
|
|
||||||
# Windscribe (all three credentials required)
|
# Windscribe (all three credentials required)
|
||||||
windscribe:
|
windscribe:
|
||||||
vpn_type: wireguard
|
vpn_type: wireguard
|
||||||
@@ -124,6 +131,46 @@ Most providers use `SERVER_COUNTRIES`, but some use `SERVER_REGIONS`:
|
|||||||
|
|
||||||
Unshackle handles this automatically - just use 2-letter country codes.
|
Unshackle handles this automatically - just use 2-letter country codes.
|
||||||
|
|
||||||
|
### Per-Provider Server Mapping
|
||||||
|
|
||||||
|
You can explicitly map region codes to country names, cities, or hostnames per provider:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
providers:
|
||||||
|
nordvpn:
|
||||||
|
vpn_type: openvpn
|
||||||
|
credentials:
|
||||||
|
username: YOUR_USERNAME
|
||||||
|
password: YOUR_PASSWORD
|
||||||
|
server_countries:
|
||||||
|
us: "United States"
|
||||||
|
uk: "United Kingdom"
|
||||||
|
server_cities:
|
||||||
|
us: "New York"
|
||||||
|
server_hostnames:
|
||||||
|
us: "us1239.nordvpn.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific Server Selection
|
||||||
|
|
||||||
|
Use `--proxy gluetun:nordvpn:us1239` for specific server selection. Unshackle builds the hostname
|
||||||
|
automatically based on the provider (e.g., `us1239.nordvpn.com` for NordVPN).
|
||||||
|
|
||||||
|
### Extra Environment Variables
|
||||||
|
|
||||||
|
You can pass additional Gluetun environment variables per provider using `extra_env`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
providers:
|
||||||
|
nordvpn:
|
||||||
|
vpn_type: openvpn
|
||||||
|
credentials:
|
||||||
|
username: YOUR_USERNAME
|
||||||
|
password: YOUR_PASSWORD
|
||||||
|
extra_env:
|
||||||
|
LOG_LEVEL: debug
|
||||||
|
```
|
||||||
|
|
||||||
## Global Settings
|
## Global Settings
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -133,17 +180,19 @@ proxy_providers:
|
|||||||
base_port: 8888 # Starting port (default: 8888)
|
base_port: 8888 # Starting port (default: 8888)
|
||||||
auto_cleanup: true # Remove containers on exit (default: true)
|
auto_cleanup: true # Remove containers on exit (default: true)
|
||||||
verify_ip: true # Verify IP matches region (default: true)
|
verify_ip: true # Verify IP matches region (default: true)
|
||||||
container_prefix: "unshackle-gluetun"
|
container_prefix: "unshackle-gluetun" # Docker container name prefix (default: "unshackle-gluetun")
|
||||||
auth_user: username # Proxy auth (optional)
|
auth_user: username # Proxy auth (optional)
|
||||||
auth_password: password
|
auth_password: password # Proxy auth (optional)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Container Reuse**: First request takes 10-30s; subsequent requests are instant
|
- **Container Reuse**: First request takes 10-30s; subsequent requests are instant. Containers from other sessions are also detected and reused.
|
||||||
- **IP Verification**: Automatically verifies VPN exit IP matches requested region
|
- **IP Verification**: Automatically verifies VPN exit IP matches requested region (configurable via `verify_ip`)
|
||||||
- **Concurrent Sessions**: Multiple downloads share the same container
|
- **Concurrent Sessions**: Multiple downloads share the same container
|
||||||
- **Specific Servers**: Use `--proxy gluetun:nordvpn:us1239` for specific server selection
|
- **Specific Servers**: Use `--proxy gluetun:nordvpn:us1239` for specific server selection
|
||||||
|
- **Automatic Image Pull**: The Gluetun Docker image (`qmcgaw/gluetun:latest`) is pulled automatically on first use
|
||||||
|
- **Secure Credentials**: Credentials are passed via temporary env files (mode 0600) rather than command-line arguments
|
||||||
|
|
||||||
## Container Management
|
## Container Management
|
||||||
|
|
||||||
@@ -174,8 +223,9 @@ docker logs unshackle-gluetun-nordvpn-us
|
|||||||
|
|
||||||
Common issues:
|
Common issues:
|
||||||
- Invalid/missing credentials
|
- Invalid/missing credentials
|
||||||
- Windscribe requires `preshared_key` (can be empty string)
|
- Windscribe WireGuard requires `preshared_key` (can be empty string, but must be set in credentials)
|
||||||
- VPN provider server issues
|
- VPN provider server issues
|
||||||
|
- Container startup timeout (default 60 seconds)
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ system where required.
|
|||||||
You can also specify specific servers to use per-region with the `server_map` key.
|
You can also specify specific servers to use per-region with the `server_map` key.
|
||||||
Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps.
|
Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps.
|
||||||
|
|
||||||
|
You can also select servers by city using the format `--proxy nordvpn:us:seattle` or `--proxy nordvpn:ca:calgary`.
|
||||||
|
|
||||||
For example,
|
For example,
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -48,8 +50,8 @@ server_map:
|
|||||||
The username and password should NOT be your normal NordVPN Account Credentials.
|
The username and password should NOT be your normal NordVPN Account Credentials.
|
||||||
They should be the `Service credentials` which can be found on your Nord Account Dashboard.
|
They should be the `Service credentials` which can be found on your Nord Account Dashboard.
|
||||||
|
|
||||||
Once set, you can also specifically opt in to use a NordVPN proxy by specifying `--proxy=gb` or such.
|
Once set, you can also specifically opt in to use a NordVPN proxy by specifying `--proxy nordvpn:gb` or such.
|
||||||
You can even set a specific server number this way, e.g., `--proxy=gb2366`.
|
You can even set a specific server number this way, e.g., `--proxy nordvpn:gb2366`.
|
||||||
|
|
||||||
Note that `gb` is used instead of `uk` to be more consistent across regional systems.
|
Note that `gb` is used instead of `uk` to be more consistent across regional systems.
|
||||||
|
|
||||||
@@ -58,6 +60,8 @@ Note that `gb` is used instead of `uk` to be more consistent across regional sys
|
|||||||
Enable Surfshark VPN proxy service using Surfshark Service credentials (not your login password).
|
Enable Surfshark VPN proxy service using Surfshark Service credentials (not your login password).
|
||||||
You may pin specific server IDs per region using `server_map`.
|
You may pin specific server IDs per region using `server_map`.
|
||||||
|
|
||||||
|
You can also select servers by city using the format `--proxy surfsharkvpn:us:seattle`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
username: your_surfshark_service_username # https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
username: your_surfshark_service_username # https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
||||||
password: your_surfshark_service_password # service credentials, not account password
|
password: your_surfshark_service_password # service credentials, not account password
|
||||||
@@ -67,16 +71,13 @@ server_map:
|
|||||||
au: 4621 # force AU server #4621
|
au: 4621 # force AU server #4621
|
||||||
```
|
```
|
||||||
|
|
||||||
### hola (dict)
|
### hola
|
||||||
|
|
||||||
Enable Hola VPN proxy service. Requires the `hola-proxy` binary to be installed and available in your PATH.
|
Enable Hola VPN proxy service. Requires the `hola-proxy` binary to be installed and available in your PATH.
|
||||||
|
No configuration is needed under `proxy_providers`. Hola is loaded automatically when the `hola-proxy` binary
|
||||||
|
is detected.
|
||||||
|
|
||||||
```yaml
|
Once available, use `--proxy hola:us` or similar to connect through Hola.
|
||||||
proxy_providers:
|
|
||||||
hola: {}
|
|
||||||
```
|
|
||||||
|
|
||||||
Once configured, use `--proxy hola:us` or similar to connect through Hola.
|
|
||||||
|
|
||||||
### windscribevpn (dict)
|
### windscribevpn (dict)
|
||||||
|
|
||||||
@@ -105,32 +106,28 @@ proxy_providers:
|
|||||||
gb: uk-london-001.totallyacdn.com # Force specific UK server
|
gb: uk-london-001.totallyacdn.com # Force specific UK server
|
||||||
```
|
```
|
||||||
|
|
||||||
Once configured, use `--proxy windscribe:us` or `--proxy windscribe:gb` etc. to connect through Windscribe.
|
Once configured, use `--proxy windscribevpn:us` or `--proxy windscribevpn:gb` etc. to connect through Windscribe.
|
||||||
|
|
||||||
### Legacy nordvpn Configuration
|
You can also select specific servers by number (e.g., `--proxy windscribevpn:sg007`) or filter by city
|
||||||
|
(e.g., `--proxy windscribevpn:ca:toronto`).
|
||||||
|
|
||||||
**Legacy configuration. Use `proxy_providers.nordvpn` instead.**
|
### gluetun (dict)
|
||||||
|
|
||||||
Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy
|
Docker-managed VPN proxy supporting 50+ VPN providers via Gluetun. See [GLUETUN.md](GLUETUN.md) for full
|
||||||
system where required.
|
configuration and usage details.
|
||||||
|
|
||||||
You can also specify specific servers to use per-region with the `server_map` key.
|
|
||||||
Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps.
|
|
||||||
|
|
||||||
For example,
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
nordvpn:
|
proxy_providers:
|
||||||
username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format
|
gluetun:
|
||||||
password: wXVHmht22hhRKUEQ32PQVjCZ
|
providers:
|
||||||
server_map:
|
windscribe:
|
||||||
us: 12 # force US server #12 for US proxies
|
vpn_type: openvpn
|
||||||
|
credentials:
|
||||||
|
username: "YOUR_OPENVPN_USERNAME"
|
||||||
|
password: "YOUR_OPENVPN_PASSWORD"
|
||||||
```
|
```
|
||||||
|
|
||||||
The username and password should NOT be your normal NordVPN Account Credentials.
|
Usage: `--proxy gluetun:windscribe:us`
|
||||||
They should be the `Service credentials` which can be found on your Nord Account Dashboard.
|
|
||||||
|
|
||||||
Note that `gb` is used instead of `uk` to be more consistent across regional systems.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -141,14 +138,14 @@ All requests will use these unless changed explicitly or implicitly via a Server
|
|||||||
These should be sane defaults and anything that would only be useful for some Services should not
|
These should be sane defaults and anything that would only be useful for some Services should not
|
||||||
be put here.
|
be put here.
|
||||||
|
|
||||||
Avoid headers like 'Accept-Encoding' as that would be a compatibility header that Python-requests will
|
Avoid headers like 'Accept-Encoding' as that would be a compatibility header that curl_cffi will
|
||||||
set for you.
|
set for you.
|
||||||
|
|
||||||
I recommend using,
|
I recommend using,
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
Accept-Language: "en-US,en;q=0.8"
|
Accept-Language: "en-US,en;q=0.8"
|
||||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36"
|
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ Set scene-style naming for titles. When `true` uses scene naming patterns (e.g.,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## dash_naming (bool)
|
||||||
|
|
||||||
|
Use dash-separated naming convention for output files. Default: `false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## unicode_filenames (bool)
|
||||||
|
|
||||||
|
Allow Unicode characters in output filenames. When `false`, Unicode characters are transliterated
|
||||||
|
to ASCII equivalents. Default: `false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## series_year (bool)
|
## series_year (bool)
|
||||||
|
|
||||||
Whether to include the series year in series names for episodes and folders. Default: `true`.
|
Whether to include the series year in series names for episodes and folders. Default: `true`.
|
||||||
@@ -67,6 +80,14 @@ Enable/disable tagging downloaded files with IMDB/TMDB/TVDB identifiers (when av
|
|||||||
|
|
||||||
- `set_title`
|
- `set_title`
|
||||||
Set the container title to `Show SXXEXX Episode Name` or `Movie (Year)`. Default: `true`
|
Set the container title to `Show SXXEXX Episode Name` or `Movie (Year)`. Default: `true`
|
||||||
|
- `merge_audio`
|
||||||
|
Merge all audio tracks into each output file. Default: `true`
|
||||||
|
- `true`: All selected audio tracks are muxed into one MKV per quality.
|
||||||
|
- `false`: Separate MKV per (quality, audio_codec) combination.
|
||||||
|
For example: `Title.1080p.AAC.mkv`, `Title.1080p.EC3.mkv`.
|
||||||
|
|
||||||
|
Note: The `--split-audio` CLI flag overrides this setting. When `--split-audio` is passed,
|
||||||
|
`merge_audio` is effectively set to `false` for that run.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,8 +135,9 @@ Notes:
|
|||||||
For example,
|
For example,
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
downloads: "D:/Downloads/unshackle"
|
directories:
|
||||||
temp: "D:/Temp/unshackle"
|
downloads: "D:/Downloads/unshackle"
|
||||||
|
temp: "D:/Temp/unshackle"
|
||||||
```
|
```
|
||||||
|
|
||||||
There are directories not listed that cannot be modified as they are crucial to the operation of unshackle.
|
There are directories not listed that cannot be modified as they are crucial to the operation of unshackle.
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ This document covers service-specific configuration, authentication, and metadat
|
|||||||
|
|
||||||
## services (dict)
|
## services (dict)
|
||||||
|
|
||||||
Configuration data for each Service. The Service will have the data within this section merged into the `config.yaml`
|
Configuration data for each Service. The Service will have the data within this section merged into the per-service
|
||||||
before provided to the Service class.
|
`config.yaml` (located in the service's directory) before being provided to the Service class.
|
||||||
|
|
||||||
Think of this config to be used for more sensitive configuration data, like user or device-specific API keys, IDs,
|
Think of this config to be used for more sensitive configuration data, like user or device-specific API keys, IDs,
|
||||||
device attributes, and so on. A `config.yaml` file is typically shared and not meant to be modified, so use this for
|
device attributes, and so on. A per-service `config.yaml` file is typically shared and not meant to be modified,
|
||||||
any sensitive configuration data.
|
so use this for any sensitive configuration data.
|
||||||
|
|
||||||
The Key is the Service Tag, but can take any arbitrary form for its value. It's expected to begin as either a list or
|
The Key is the Service Tag, but can take any arbitrary form for its value. It's expected to begin as either a list or
|
||||||
a dictionary.
|
a dictionary.
|
||||||
@@ -23,6 +23,29 @@ NOW:
|
|||||||
# ... more sensitive data
|
# ... more sensitive data
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Per-Service Configuration Overrides
|
||||||
|
|
||||||
|
You can override many global configuration options on a per-service basis by nesting them under the
|
||||||
|
service tag in the `services` section. Supported override keys include: `dl`, `aria2c`, `n_m3u8dl_re`,
|
||||||
|
`curl_impersonate`, `subtitle`, `muxing`, `headers`, and more.
|
||||||
|
|
||||||
|
Overrides are merged with global config (not replaced) -- only specified keys are overridden, others
|
||||||
|
use global defaults. CLI arguments always take priority over service-specific config.
|
||||||
|
|
||||||
|
For example,
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
RATE_LIMITED_SERVICE:
|
||||||
|
dl:
|
||||||
|
downloads: 2 # Limit concurrent track downloads
|
||||||
|
workers: 4 # Reduce workers to avoid rate limits
|
||||||
|
n_m3u8dl_re:
|
||||||
|
thread_count: 4 # Very low thread count
|
||||||
|
aria2c:
|
||||||
|
max_concurrent_downloads: 1
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## credentials (dict[str, str|list|dict])
|
## credentials (dict[str, str|list|dict])
|
||||||
@@ -114,3 +137,28 @@ Maximum retention time in seconds for serving slightly stale cached title metada
|
|||||||
Default: `86400` (24 hours). Effective retention is `min(title_cache_time + grace, title_cache_max_retention)`.
|
Default: `86400` (24 hours). Effective retention is `min(title_cache_time + grace, title_cache_max_retention)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## debug (bool)
|
||||||
|
|
||||||
|
Enable structured JSON debug logging for troubleshooting and service development. Default: `false`.
|
||||||
|
|
||||||
|
When enabled (via config or the `--debug` CLI flag):
|
||||||
|
- Creates JSON Lines (`.jsonl`) log files with complete debugging context
|
||||||
|
- Logs: session info, CLI params, service config, CDM details, authentication, titles, tracks metadata,
|
||||||
|
DRM operations, vault queries, errors with stack traces
|
||||||
|
- File location: `logs/unshackle_debug_{service}_{timestamp}.jsonl`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## debug_keys (bool)
|
||||||
|
|
||||||
|
Log decryption keys in debug logs. Default: `false`.
|
||||||
|
|
||||||
|
When `true`, actual content encryption keys (CEKs) are included in debug log output. Useful for
|
||||||
|
debugging key retrieval and decryption issues.
|
||||||
|
|
||||||
|
**Security note:** Passwords, tokens, cookies, and session tokens are always redacted regardless
|
||||||
|
of this setting. Only content keys (`content_key`, `key` fields) are affected. Key IDs (`kid`),
|
||||||
|
key counts, and other metadata are always logged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ This document covers subtitle processing and formatting options.
|
|||||||
|
|
||||||
Control subtitle conversion and SDH (hearing-impaired) stripping behavior.
|
Control subtitle conversion and SDH (hearing-impaired) stripping behavior.
|
||||||
|
|
||||||
- `conversion_method`: How to convert subtitles between formats. Default: `pysubs2`.
|
- `conversion_method`: How to convert subtitles between formats. Default: `auto`.
|
||||||
- `auto`: Use subby for WebVTT/SAMI, standard for others.
|
- `auto`: Smart routing - use subby for WebVTT/SAMI, pycaption for others.
|
||||||
- `subby`: Always use subby with CommonIssuesFixer.
|
- `subby`: Always use subby with CommonIssuesFixer.
|
||||||
- `subtitleedit`: Prefer SubtitleEdit when available; otherwise fallback to standard conversion.
|
- `subtitleedit`: Prefer SubtitleEdit when available; otherwise fallback to standard conversion.
|
||||||
- `pycaption`: Use only the pycaption library (no SubtitleEdit, no subby).
|
- `pycaption`: Use only the pycaption library (no SubtitleEdit, no subby).
|
||||||
@@ -23,17 +23,30 @@ Control subtitle conversion and SDH (hearing-impaired) stripping behavior.
|
|||||||
|
|
||||||
- `convert_before_strip`: When using `filter-subs` SDH method, automatically convert subtitles to SRT format first for better compatibility. Default: `true`.
|
- `convert_before_strip`: When using `filter-subs` SDH method, automatically convert subtitles to SRT format first for better compatibility. Default: `true`.
|
||||||
|
|
||||||
- `preserve_formatting`: Keep original subtitle tags and positioning during conversion. Default: `true`.
|
- `preserve_formatting`: Keep original subtitle tags and positioning during conversion. When true, skips pycaption processing for WebVTT files to keep tags like `<i>`, `<b>`, and positioning intact. Default: `true`.
|
||||||
|
|
||||||
|
- `output_mode`: Controls how subtitles are included in the output. Default: `mux`.
|
||||||
|
- `mux`: Embed subtitles in the MKV container only.
|
||||||
|
- `sidecar`: Save subtitles as separate files only (not muxed into the container).
|
||||||
|
- `both`: Embed subtitles in the MKV container and save as sidecar files.
|
||||||
|
|
||||||
|
- `sidecar_format`: Format for sidecar subtitle files (used when `output_mode` is `sidecar` or `both`). Default: `srt`.
|
||||||
|
- `srt`: SubRip format.
|
||||||
|
- `vtt`: WebVTT format.
|
||||||
|
- `ass`: Advanced SubStation Alpha format.
|
||||||
|
- `original`: Keep the subtitle in its current format without conversion.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
subtitle:
|
subtitle:
|
||||||
conversion_method: pysubs2
|
conversion_method: auto
|
||||||
sdh_method: auto
|
sdh_method: auto
|
||||||
strip_sdh: true
|
strip_sdh: true
|
||||||
convert_before_strip: true
|
convert_before_strip: true
|
||||||
preserve_formatting: true
|
preserve_formatting: true
|
||||||
|
output_mode: mux
|
||||||
|
sidecar_format: srt
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "unshackle"
|
name = "unshackle"
|
||||||
version = "2.3.1"
|
version = "3.1.0"
|
||||||
description = "Modular Movie, TV, and Music Archival Software."
|
description = "Modular Movie, TV, and Music Archival Software."
|
||||||
authors = [{ name = "unshackle team" }]
|
authors = [{ name = "unshackle team" }]
|
||||||
requires-python = ">=3.10,<3.13"
|
requires-python = ">=3.10,<3.13"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -43,6 +44,7 @@ from rich.tree import Tree
|
|||||||
|
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
from unshackle.core.cdm import CustomRemoteCDM, DecryptLabsRemoteCDM
|
from unshackle.core.cdm import CustomRemoteCDM, DecryptLabsRemoteCDM
|
||||||
|
from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.console import console
|
from unshackle.core.console import console
|
||||||
from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings
|
from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings
|
||||||
@@ -62,8 +64,10 @@ from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger
|
|||||||
is_close_match, suggest_font_packages, time_elapsed_since)
|
is_close_match, suggest_font_packages, time_elapsed_since)
|
||||||
from unshackle.core.utils import tags
|
from unshackle.core.utils import tags
|
||||||
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
|
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
|
||||||
ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice)
|
ContextData, MultipleChoice, MultipleVideoCodecChoice,
|
||||||
|
SubtitleCodecChoice)
|
||||||
from unshackle.core.utils.collections import merge_dict
|
from unshackle.core.utils.collections import merge_dict
|
||||||
|
from unshackle.core.utils.selector import select_multiple
|
||||||
from unshackle.core.utils.subprocess import ffprobe
|
from unshackle.core.utils.subprocess import ffprobe
|
||||||
from unshackle.core.vaults import Vaults
|
from unshackle.core.vaults import Vaults
|
||||||
|
|
||||||
@@ -193,12 +197,7 @@ class dl:
|
|||||||
sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else ""
|
sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else ""
|
||||||
|
|
||||||
extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension
|
extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension
|
||||||
if (
|
if not target_codec and not subtitle.codec and source_path and source_path.suffix:
|
||||||
not target_codec
|
|
||||||
and not subtitle.codec
|
|
||||||
and source_path
|
|
||||||
and source_path.suffix
|
|
||||||
):
|
|
||||||
extension = source_path.suffix.lstrip(".")
|
extension = source_path.suffix.lstrip(".")
|
||||||
|
|
||||||
filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}"
|
filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}"
|
||||||
@@ -290,9 +289,9 @@ class dl:
|
|||||||
@click.option(
|
@click.option(
|
||||||
"-v",
|
"-v",
|
||||||
"--vcodec",
|
"--vcodec",
|
||||||
type=VideoCodecChoice(Video.Codec),
|
type=MultipleVideoCodecChoice(Video.Codec),
|
||||||
default=None,
|
default=[],
|
||||||
help="Video Codec to download, defaults to any codec.",
|
help="Video Codec(s) to download, defaults to any codec.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-a",
|
"-a",
|
||||||
@@ -345,6 +344,12 @@ class dl:
|
|||||||
default=None,
|
default=None,
|
||||||
help="Create separate output files per audio codec instead of merging all audio.",
|
help="Create separate output files per audio codec instead of merging all audio.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--select-titles",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Interactively select downloads from a list. Only use with Series to select Episodes",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-w",
|
"-w",
|
||||||
"--wanted",
|
"--wanted",
|
||||||
@@ -402,6 +407,7 @@ class dl:
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--tag", type=str, default=None, help="Set the Group Tag to be used, overriding the one in config if any."
|
"--tag", type=str, default=None, help="Set the Group Tag to be used, overriding the one in config if any."
|
||||||
)
|
)
|
||||||
|
@click.option("--repack", is_flag=True, default=False, help="Add REPACK tag to the output filename.")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--tmdb",
|
"--tmdb",
|
||||||
"tmdb_id",
|
"tmdb_id",
|
||||||
@@ -481,6 +487,14 @@ class dl:
|
|||||||
help="Max workers/threads to download with per-track. Default depends on the downloader.",
|
help="Max workers/threads to download with per-track. Default depends on the downloader.",
|
||||||
)
|
)
|
||||||
@click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.")
|
@click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.")
|
||||||
|
@click.option(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
"output_dir",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Override the output directory for this download, instead of the one in config.",
|
||||||
|
)
|
||||||
@click.option("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.")
|
@click.option("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
|
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
|
||||||
@@ -504,10 +518,12 @@ class dl:
|
|||||||
no_proxy: bool,
|
no_proxy: bool,
|
||||||
profile: Optional[str] = None,
|
profile: Optional[str] = None,
|
||||||
proxy: Optional[str] = None,
|
proxy: Optional[str] = None,
|
||||||
|
repack: bool = False,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
tmdb_id: Optional[int] = None,
|
tmdb_id: Optional[int] = None,
|
||||||
tmdb_name: bool = False,
|
tmdb_name: bool = False,
|
||||||
tmdb_year: bool = False,
|
tmdb_year: bool = False,
|
||||||
|
output_dir: Optional[Path] = None,
|
||||||
*_: Any,
|
*_: Any,
|
||||||
**__: Any,
|
**__: Any,
|
||||||
):
|
):
|
||||||
@@ -553,6 +569,7 @@ class dl:
|
|||||||
self.tmdb_id = tmdb_id
|
self.tmdb_id = tmdb_id
|
||||||
self.tmdb_name = tmdb_name
|
self.tmdb_name = tmdb_name
|
||||||
self.tmdb_year = tmdb_year
|
self.tmdb_year = tmdb_year
|
||||||
|
self.output_dir = output_dir
|
||||||
|
|
||||||
# Initialize debug logger with service name if debug logging is enabled
|
# Initialize debug logger with service name if debug logging is enabled
|
||||||
if config.debug or logging.root.level == logging.DEBUG:
|
if config.debug or logging.root.level == logging.DEBUG:
|
||||||
@@ -585,6 +602,59 @@ class dl:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Log binary versions for diagnostics
|
||||||
|
binary_versions = {}
|
||||||
|
for name, binary in [
|
||||||
|
("shaka_packager", binaries.ShakaPackager),
|
||||||
|
("mp4decrypt", binaries.Mp4decrypt),
|
||||||
|
("n_m3u8dl_re", binaries.N_m3u8DL_RE),
|
||||||
|
("mkvmerge", binaries.MKVToolNix),
|
||||||
|
("ffmpeg", binaries.FFMPEG),
|
||||||
|
("ffprobe", binaries.FFProbe),
|
||||||
|
]:
|
||||||
|
if binary:
|
||||||
|
version = None
|
||||||
|
try:
|
||||||
|
if name == "shaka_packager":
|
||||||
|
r = subprocess.run(
|
||||||
|
[str(binary), "--version"], capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
version = (r.stdout or r.stderr or "").strip()
|
||||||
|
elif name in ("ffmpeg", "ffprobe"):
|
||||||
|
r = subprocess.run(
|
||||||
|
[str(binary), "-version"], capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
version = (r.stdout or "").split("\n")[0].strip()
|
||||||
|
elif name == "mkvmerge":
|
||||||
|
r = subprocess.run(
|
||||||
|
[str(binary), "--version"], capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
version = (r.stdout or "").strip()
|
||||||
|
elif name == "mp4decrypt":
|
||||||
|
r = subprocess.run(
|
||||||
|
[str(binary)], capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
output = (r.stdout or "") + (r.stderr or "")
|
||||||
|
lines = [line.strip() for line in output.split("\n") if line.strip()]
|
||||||
|
version = " | ".join(lines[:2]) if lines else None
|
||||||
|
elif name == "n_m3u8dl_re":
|
||||||
|
r = subprocess.run(
|
||||||
|
[str(binary), "--version"], capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
version = (r.stdout or r.stderr or "").strip().split("\n")[0]
|
||||||
|
except Exception:
|
||||||
|
version = "<error getting version>"
|
||||||
|
binary_versions[name] = {"path": str(binary), "version": version}
|
||||||
|
else:
|
||||||
|
binary_versions[name] = None
|
||||||
|
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="binary_versions",
|
||||||
|
message="Binary tool versions",
|
||||||
|
context=binary_versions,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.debug_logger = None
|
self.debug_logger = None
|
||||||
|
|
||||||
@@ -777,6 +847,9 @@ class dl:
|
|||||||
r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE
|
r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE
|
||||||
):
|
):
|
||||||
proxy = proxy.lower()
|
proxy = proxy.lower()
|
||||||
|
# Preserve the original user query (region code) for service-specific proxy_map overrides.
|
||||||
|
# NOTE: `proxy` may be overwritten with the resolved proxy URI later.
|
||||||
|
proxy_query = proxy
|
||||||
status_msg = (
|
status_msg = (
|
||||||
f"Connecting to VPN ({proxy})..."
|
f"Connecting to VPN ({proxy})..."
|
||||||
if requested_provider == "gluetun"
|
if requested_provider == "gluetun"
|
||||||
@@ -791,7 +864,6 @@ class dl:
|
|||||||
if not proxy_provider:
|
if not proxy_provider:
|
||||||
self.log.error(f"The proxy provider '{requested_provider}' was not recognised.")
|
self.log.error(f"The proxy provider '{requested_provider}' was not recognised.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
proxy_query = proxy # Save query before overwriting with URI
|
|
||||||
proxy_uri = proxy_provider.get_proxy(proxy)
|
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||||
if not proxy_uri:
|
if not proxy_uri:
|
||||||
self.log.error(f"The proxy provider {requested_provider} had no proxy for {proxy}")
|
self.log.error(f"The proxy provider {requested_provider} had no proxy for {proxy}")
|
||||||
@@ -810,7 +882,6 @@ class dl:
|
|||||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||||
else:
|
else:
|
||||||
for proxy_provider in self.proxy_providers:
|
for proxy_provider in self.proxy_providers:
|
||||||
proxy_query = proxy # Save query before overwriting with URI
|
|
||||||
proxy_uri = proxy_provider.get_proxy(proxy)
|
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||||
if proxy_uri:
|
if proxy_uri:
|
||||||
proxy = ctx.params["proxy"] = proxy_uri
|
proxy = ctx.params["proxy"] = proxy_uri
|
||||||
@@ -827,7 +898,7 @@ class dl:
|
|||||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||||
break
|
break
|
||||||
# Store proxy query info for service-specific overrides
|
# Store proxy query info for service-specific overrides
|
||||||
ctx.params["proxy_query"] = proxy
|
ctx.params["proxy_query"] = proxy_query
|
||||||
ctx.params["proxy_provider"] = requested_provider
|
ctx.params["proxy_provider"] = requested_provider
|
||||||
else:
|
else:
|
||||||
self.log.info(f"Using explicit Proxy: {proxy}")
|
self.log.info(f"Using explicit Proxy: {proxy}")
|
||||||
@@ -839,6 +910,9 @@ class dl:
|
|||||||
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
|
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if repack:
|
||||||
|
config.repack = True
|
||||||
|
|
||||||
if tag:
|
if tag:
|
||||||
config.tag = tag
|
config.tag = tag
|
||||||
|
|
||||||
@@ -850,13 +924,14 @@ class dl:
|
|||||||
self,
|
self,
|
||||||
service: Service,
|
service: Service,
|
||||||
quality: list[int],
|
quality: list[int],
|
||||||
vcodec: Optional[Video.Codec],
|
vcodec: list[Video.Codec],
|
||||||
acodec: list[Audio.Codec],
|
acodec: list[Audio.Codec],
|
||||||
vbitrate: int,
|
vbitrate: int,
|
||||||
abitrate: int,
|
abitrate: int,
|
||||||
range_: list[Video.Range],
|
range_: list[Video.Range],
|
||||||
channels: float,
|
channels: float,
|
||||||
no_atmos: bool,
|
no_atmos: bool,
|
||||||
|
select_titles: bool,
|
||||||
wanted: list[str],
|
wanted: list[str],
|
||||||
latest_episode: bool,
|
latest_episode: bool,
|
||||||
lang: list[str],
|
lang: list[str],
|
||||||
@@ -897,6 +972,9 @@ class dl:
|
|||||||
self.search_source = None
|
self.search_source = None
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
if skip_dl:
|
||||||
|
DOWNLOAD_LICENCE_ONLY.set()
|
||||||
|
|
||||||
if not acodec:
|
if not acodec:
|
||||||
acodec = []
|
acodec = []
|
||||||
elif isinstance(acodec, Audio.Codec):
|
elif isinstance(acodec, Audio.Codec):
|
||||||
@@ -1042,6 +1120,78 @@ class dl:
|
|||||||
if list_titles:
|
if list_titles:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Enables manual selection for Series when --select-titles is set
|
||||||
|
if select_titles and isinstance(titles, Series):
|
||||||
|
console.print(Padding(Rule("[rule.text]Select Titles"), (1, 2)))
|
||||||
|
|
||||||
|
selection_titles = []
|
||||||
|
dependencies = {}
|
||||||
|
original_indices = {}
|
||||||
|
|
||||||
|
current_season = None
|
||||||
|
current_season_header_idx = -1
|
||||||
|
|
||||||
|
unique_seasons = {t.season for t in titles}
|
||||||
|
multiple_seasons = len(unique_seasons) > 1
|
||||||
|
|
||||||
|
# Build selection options
|
||||||
|
for i, t in enumerate(titles):
|
||||||
|
# Insert season header only if multiple seasons exist
|
||||||
|
if multiple_seasons and t.season != current_season:
|
||||||
|
current_season = t.season
|
||||||
|
header_text = f"Season {t.season}"
|
||||||
|
selection_titles.append(header_text)
|
||||||
|
current_season_header_idx = len(selection_titles) - 1
|
||||||
|
dependencies[current_season_header_idx] = []
|
||||||
|
# Note: Headers are not mapped to actual title indices
|
||||||
|
|
||||||
|
# Format display name
|
||||||
|
display_name = ((t.name[:35].rstrip() + "…") if len(t.name) > 35 else t.name) if t.name else None
|
||||||
|
|
||||||
|
# Apply indentation only for multiple seasons
|
||||||
|
prefix = " " if multiple_seasons else ""
|
||||||
|
option_text = f"{prefix}{t.number}" + (f". {display_name}" if t.name else "")
|
||||||
|
|
||||||
|
selection_titles.append(option_text)
|
||||||
|
current_ui_idx = len(selection_titles) - 1
|
||||||
|
|
||||||
|
# Map UI index to actual title index
|
||||||
|
original_indices[current_ui_idx] = i
|
||||||
|
|
||||||
|
# Link episode to season header for group selection
|
||||||
|
if current_season_header_idx != -1:
|
||||||
|
dependencies[current_season_header_idx].append(current_ui_idx)
|
||||||
|
|
||||||
|
selection_start = time.time()
|
||||||
|
|
||||||
|
# Execute selector with dependencies (headers select all children)
|
||||||
|
selected_ui_idx = select_multiple(
|
||||||
|
selection_titles, minimal_count=1, page_size=8, return_indices=True, dependencies=dependencies
|
||||||
|
)
|
||||||
|
|
||||||
|
selection_end = time.time()
|
||||||
|
start_time += selection_end - selection_start
|
||||||
|
|
||||||
|
# Map UI indices back to title indices (excluding headers)
|
||||||
|
selected_idx = []
|
||||||
|
for idx in selected_ui_idx:
|
||||||
|
if idx in original_indices:
|
||||||
|
selected_idx.append(original_indices[idx])
|
||||||
|
|
||||||
|
# Ensure indices are unique and ordered
|
||||||
|
selected_idx = sorted(set(selected_idx))
|
||||||
|
keep = set(selected_idx)
|
||||||
|
|
||||||
|
# In-place filter: remove unselected items (iterate backwards)
|
||||||
|
for i in range(len(titles) - 1, -1, -1):
|
||||||
|
if i not in keep:
|
||||||
|
del titles[i]
|
||||||
|
|
||||||
|
# Show selected count
|
||||||
|
if titles:
|
||||||
|
count = len(titles)
|
||||||
|
console.print(Padding(f"[text]Total selected: {count}[/]", (0, 5)))
|
||||||
|
|
||||||
# Determine the latest episode if --latest-episode is set
|
# Determine the latest episode if --latest-episode is set
|
||||||
latest_episode_id = None
|
latest_episode_id = None
|
||||||
if latest_episode and isinstance(titles, Series) and len(titles) > 0:
|
if latest_episode and isinstance(titles, Series) and len(titles) > 0:
|
||||||
@@ -1245,9 +1395,12 @@ class dl:
|
|||||||
if isinstance(title, (Movie, Episode)):
|
if isinstance(title, (Movie, Episode)):
|
||||||
# filter video tracks
|
# filter video tracks
|
||||||
if vcodec:
|
if vcodec:
|
||||||
title.tracks.select_video(lambda x: x.codec == vcodec)
|
title.tracks.select_video(lambda x: x.codec in vcodec)
|
||||||
|
missing_codecs = [c for c in vcodec if not any(x.codec == c for x in title.tracks.videos)]
|
||||||
|
for codec in missing_codecs:
|
||||||
|
self.log.warning(f"Skipping {codec.name} video tracks as none are available.")
|
||||||
if not title.tracks.videos:
|
if not title.tracks.videos:
|
||||||
self.log.error(f"There's no {vcodec.name} Video Track...")
|
self.log.error(f"There's no {', '.join(c.name for c in vcodec)} Video Track...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if range_:
|
if range_:
|
||||||
@@ -1259,10 +1412,20 @@ class dl:
|
|||||||
self.log.warning(f"Skipping {color_range.name} video tracks as none are available.")
|
self.log.warning(f"Skipping {color_range.name} video tracks as none are available.")
|
||||||
|
|
||||||
if vbitrate:
|
if vbitrate:
|
||||||
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
|
if any(r == Video.Range.HYBRID for r in range_):
|
||||||
if not title.tracks.videos:
|
# In HYBRID mode, only apply bitrate filter to non-DV tracks
|
||||||
self.log.error(f"There's no {vbitrate}kbps Video Track...")
|
# DV tracks are kept regardless since they're only used for RPU metadata
|
||||||
sys.exit(1)
|
title.tracks.select_video(
|
||||||
|
lambda x: x.range == Video.Range.DV or (x.bitrate and x.bitrate // 1000 == vbitrate)
|
||||||
|
)
|
||||||
|
if not any(x.range != Video.Range.DV for x in title.tracks.videos):
|
||||||
|
self.log.error(f"There's no {vbitrate}kbps Video Track...")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
|
||||||
|
if not title.tracks.videos:
|
||||||
|
self.log.error(f"There's no {vbitrate}kbps Video Track...")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
video_languages = [lang for lang in (v_lang or lang) if lang != "best"]
|
video_languages = [lang for lang in (v_lang or lang) if lang != "best"]
|
||||||
if video_languages and "all" not in video_languages:
|
if video_languages and "all" not in video_languages:
|
||||||
@@ -1289,10 +1452,38 @@ class dl:
|
|||||||
self.log.error(f"There's no {processed_video_lang} Video Track...")
|
self.log.error(f"There's no {processed_video_lang} Video Track...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
has_hybrid = any(r == Video.Range.HYBRID for r in range_)
|
||||||
|
non_hybrid_ranges = [r for r in range_ if r != Video.Range.HYBRID]
|
||||||
|
|
||||||
if quality:
|
if quality:
|
||||||
missing_resolutions = []
|
missing_resolutions = []
|
||||||
if any(r == Video.Range.HYBRID for r in range_):
|
if has_hybrid:
|
||||||
title.tracks.select_video(title.tracks.select_hybrid(title.tracks.videos, quality))
|
# Split tracks: hybrid candidates vs non-hybrid
|
||||||
|
hybrid_candidate_tracks = [
|
||||||
|
v for v in title.tracks.videos
|
||||||
|
if v.range in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||||
|
]
|
||||||
|
non_hybrid_tracks = [
|
||||||
|
v for v in title.tracks.videos
|
||||||
|
if v.range not in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Apply hybrid selection to HDR10+DV tracks
|
||||||
|
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality)
|
||||||
|
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||||
|
|
||||||
|
if non_hybrid_ranges and non_hybrid_tracks:
|
||||||
|
# Also filter non-hybrid tracks by resolution
|
||||||
|
non_hybrid_selected = [
|
||||||
|
v for v in non_hybrid_tracks
|
||||||
|
if any(
|
||||||
|
v.height == res or int(v.width * (9 / 16)) == res
|
||||||
|
for res in quality
|
||||||
|
)
|
||||||
|
]
|
||||||
|
title.tracks.videos = hybrid_selected + non_hybrid_selected
|
||||||
|
else:
|
||||||
|
title.tracks.videos = hybrid_selected
|
||||||
else:
|
else:
|
||||||
title.tracks.by_resolutions(quality)
|
title.tracks.by_resolutions(quality)
|
||||||
|
|
||||||
@@ -1319,21 +1510,63 @@ class dl:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# choose best track by range and quality
|
# choose best track by range and quality
|
||||||
if any(r == Video.Range.HYBRID for r in range_):
|
if has_hybrid:
|
||||||
# For hybrid mode, always apply hybrid selection
|
# Apply hybrid selection for HYBRID tracks
|
||||||
# If no quality specified, use only the best (highest) resolution
|
hybrid_candidate_tracks = [
|
||||||
|
v for v in title.tracks.videos
|
||||||
|
if v.range in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||||
|
]
|
||||||
|
non_hybrid_tracks = [
|
||||||
|
v for v in title.tracks.videos
|
||||||
|
if v.range not in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||||
|
]
|
||||||
|
|
||||||
if not quality:
|
if not quality:
|
||||||
# Get the highest resolution available
|
best_resolution = max(
|
||||||
best_resolution = max((v.height for v in title.tracks.videos), default=None)
|
(v.height for v in hybrid_candidate_tracks), default=None
|
||||||
|
)
|
||||||
if best_resolution:
|
if best_resolution:
|
||||||
# Use the hybrid selection logic with only the best resolution
|
hybrid_filter = title.tracks.select_hybrid(
|
||||||
title.tracks.select_video(
|
hybrid_candidate_tracks, [best_resolution]
|
||||||
title.tracks.select_hybrid(title.tracks.videos, [best_resolution])
|
|
||||||
)
|
)
|
||||||
# If quality was specified, hybrid selection was already applied above
|
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||||
|
else:
|
||||||
|
hybrid_selected = []
|
||||||
|
else:
|
||||||
|
hybrid_filter = title.tracks.select_hybrid(
|
||||||
|
hybrid_candidate_tracks, quality
|
||||||
|
)
|
||||||
|
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||||
|
|
||||||
|
# For non-hybrid ranges, apply Cartesian product selection
|
||||||
|
non_hybrid_selected: list[Video] = []
|
||||||
|
if non_hybrid_ranges and non_hybrid_tracks:
|
||||||
|
for resolution, color_range, codec in product(
|
||||||
|
quality or [None], non_hybrid_ranges, vcodec or [None]
|
||||||
|
):
|
||||||
|
match = next(
|
||||||
|
(
|
||||||
|
t
|
||||||
|
for t in non_hybrid_tracks
|
||||||
|
if (
|
||||||
|
not resolution
|
||||||
|
or t.height == resolution
|
||||||
|
or int(t.width * (9 / 16)) == resolution
|
||||||
|
)
|
||||||
|
and (not color_range or t.range == color_range)
|
||||||
|
and (not codec or t.codec == codec)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if match and match not in non_hybrid_selected:
|
||||||
|
non_hybrid_selected.append(match)
|
||||||
|
|
||||||
|
title.tracks.videos = hybrid_selected + non_hybrid_selected
|
||||||
else:
|
else:
|
||||||
selected_videos: list[Video] = []
|
selected_videos: list[Video] = []
|
||||||
for resolution, color_range in product(quality or [None], range_ or [None]):
|
for resolution, color_range, codec in product(
|
||||||
|
quality or [None], range_ or [None], vcodec or [None]
|
||||||
|
):
|
||||||
match = next(
|
match = next(
|
||||||
(
|
(
|
||||||
t
|
t
|
||||||
@@ -1344,6 +1577,7 @@ class dl:
|
|||||||
or int(t.width * (9 / 16)) == resolution
|
or int(t.width * (9 / 16)) == resolution
|
||||||
)
|
)
|
||||||
and (not color_range or t.range == color_range)
|
and (not color_range or t.range == color_range)
|
||||||
|
and (not codec or t.codec == codec)
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1353,26 +1587,44 @@ class dl:
|
|||||||
|
|
||||||
# validate hybrid mode requirements
|
# validate hybrid mode requirements
|
||||||
if any(r == Video.Range.HYBRID for r in range_):
|
if any(r == Video.Range.HYBRID for r in range_):
|
||||||
hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10]
|
base_tracks = [
|
||||||
|
v for v in title.tracks.videos
|
||||||
|
if v.range in (Video.Range.HDR10, Video.Range.HDR10P)
|
||||||
|
]
|
||||||
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
|
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
|
||||||
|
|
||||||
if not hdr10_tracks and not dv_tracks:
|
hybrid_failed = False
|
||||||
|
if not base_tracks and not dv_tracks:
|
||||||
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
||||||
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but neither is available")
|
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available"
|
||||||
self.log.error(
|
msg_detail = (
|
||||||
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
|
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
hybrid_failed = True
|
||||||
elif not hdr10_tracks:
|
elif not base_tracks:
|
||||||
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
||||||
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only DV is available")
|
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available"
|
||||||
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
|
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
|
||||||
sys.exit(1)
|
hybrid_failed = True
|
||||||
elif not dv_tracks:
|
elif not dv_tracks:
|
||||||
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
||||||
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only HDR10 is available")
|
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available"
|
||||||
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
|
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
|
||||||
sys.exit(1)
|
hybrid_failed = True
|
||||||
|
|
||||||
|
if hybrid_failed:
|
||||||
|
other_ranges = [r for r in range_ if r != Video.Range.HYBRID]
|
||||||
|
if best_available and other_ranges:
|
||||||
|
self.log.warning(msg)
|
||||||
|
self.log.warning(
|
||||||
|
f"Continuing with remaining range(s): "
|
||||||
|
f"{', '.join(r.name for r in other_ranges)}"
|
||||||
|
)
|
||||||
|
range_ = other_ranges
|
||||||
|
else:
|
||||||
|
self.log.error(msg)
|
||||||
|
self.log.error(msg_detail)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# filter subtitle tracks
|
# filter subtitle tracks
|
||||||
if require_subs:
|
if require_subs:
|
||||||
@@ -1456,33 +1708,81 @@ class dl:
|
|||||||
if language not in processed_lang:
|
if language not in processed_lang:
|
||||||
processed_lang.append(language)
|
processed_lang.append(language)
|
||||||
|
|
||||||
if "best" in processed_lang:
|
if "best" in processed_lang or "all" in processed_lang:
|
||||||
unique_languages = {track.language for track in title.tracks.audio}
|
unique_languages = {track.language for track in title.tracks.audio}
|
||||||
selected_audio = []
|
selected_audio = []
|
||||||
|
for language in unique_languages:
|
||||||
|
codecs_to_check = acodec if (acodec and len(acodec) > 1) else [None]
|
||||||
|
for codec in codecs_to_check:
|
||||||
|
base_candidates = [
|
||||||
|
t
|
||||||
|
for t in title.tracks.audio
|
||||||
|
if t.language == language and (codec is None or t.codec == codec)
|
||||||
|
]
|
||||||
|
if not base_candidates:
|
||||||
|
continue
|
||||||
|
if audio_description:
|
||||||
|
standards = [t for t in base_candidates if not t.descriptive]
|
||||||
|
if standards:
|
||||||
|
selected_audio.append(max(standards, key=lambda x: x.bitrate or 0))
|
||||||
|
descs = [t for t in base_candidates if t.descriptive]
|
||||||
|
if descs:
|
||||||
|
selected_audio.append(max(descs, key=lambda x: x.bitrate or 0))
|
||||||
|
else:
|
||||||
|
selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0))
|
||||||
|
title.tracks.audio = selected_audio
|
||||||
|
else:
|
||||||
|
# If multiple codecs were explicitly requested, pick the best track per codec per
|
||||||
|
# requested language instead of selecting *all* bitrate variants of a codec.
|
||||||
if acodec and len(acodec) > 1:
|
if acodec and len(acodec) > 1:
|
||||||
for language in unique_languages:
|
selected_audio: list[Audio] = []
|
||||||
|
|
||||||
|
for language in processed_lang:
|
||||||
for codec in acodec:
|
for codec in acodec:
|
||||||
candidates = [
|
codec_tracks = [a for a in title.tracks.audio if a.codec == codec]
|
||||||
track
|
if not codec_tracks:
|
||||||
for track in title.tracks.audio
|
continue
|
||||||
if track.language == language and track.codec == codec
|
|
||||||
]
|
candidates = title.tracks.by_language(
|
||||||
|
codec_tracks, [language], per_language=0, exact_match=exact_lang
|
||||||
|
)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
continue
|
continue
|
||||||
selected_audio.append(max(candidates, key=lambda x: x.bitrate or 0))
|
|
||||||
|
if audio_description:
|
||||||
|
standards = [t for t in candidates if not t.descriptive]
|
||||||
|
if standards:
|
||||||
|
selected_audio.append(max(standards, key=lambda x: x.bitrate or 0))
|
||||||
|
descs = [t for t in candidates if t.descriptive]
|
||||||
|
if descs:
|
||||||
|
selected_audio.append(max(descs, key=lambda x: x.bitrate or 0))
|
||||||
|
else:
|
||||||
|
selected_audio.append(max(candidates, key=lambda x: x.bitrate or 0))
|
||||||
|
|
||||||
|
title.tracks.audio = selected_audio
|
||||||
else:
|
else:
|
||||||
for language in unique_languages:
|
per_language = 1
|
||||||
highest_quality = max(
|
if audio_description:
|
||||||
(track for track in title.tracks.audio if track.language == language),
|
standard_audio = [a for a in title.tracks.audio if not a.descriptive]
|
||||||
key=lambda x: x.bitrate or 0,
|
selected_standards = title.tracks.by_language(
|
||||||
|
standard_audio,
|
||||||
|
processed_lang,
|
||||||
|
per_language=per_language,
|
||||||
|
exact_match=exact_lang,
|
||||||
|
)
|
||||||
|
desc_audio = [a for a in title.tracks.audio if a.descriptive]
|
||||||
|
# Include all descriptive tracks for the requested languages.
|
||||||
|
selected_descs = title.tracks.by_language(
|
||||||
|
desc_audio, processed_lang, per_language=0, exact_match=exact_lang
|
||||||
|
)
|
||||||
|
title.tracks.audio = selected_standards + selected_descs
|
||||||
|
else:
|
||||||
|
title.tracks.audio = title.tracks.by_language(
|
||||||
|
title.tracks.audio,
|
||||||
|
processed_lang,
|
||||||
|
per_language=per_language,
|
||||||
|
exact_match=exact_lang,
|
||||||
)
|
)
|
||||||
selected_audio.append(highest_quality)
|
|
||||||
title.tracks.audio = selected_audio
|
|
||||||
elif "all" not in processed_lang:
|
|
||||||
per_language = 0 if acodec and len(acodec) > 1 else 1
|
|
||||||
title.tracks.audio = title.tracks.by_language(
|
|
||||||
title.tracks.audio, processed_lang, per_language=per_language, exact_match=exact_lang
|
|
||||||
)
|
|
||||||
if not title.tracks.audio:
|
if not title.tracks.audio:
|
||||||
self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...")
|
self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -1552,9 +1852,7 @@ class dl:
|
|||||||
if video_tracks:
|
if video_tracks:
|
||||||
highest_quality = max((track.height for track in video_tracks if track.height), default=0)
|
highest_quality = max((track.height for track in video_tracks if track.height), default=0)
|
||||||
if highest_quality > 0:
|
if highest_quality > 0:
|
||||||
if isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) and not (
|
if is_widevine_cdm(self.cdm):
|
||||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
|
||||||
):
|
|
||||||
quality_based_cdm = self.get_cdm(
|
quality_based_cdm = self.get_cdm(
|
||||||
self.service, self.profile, drm="widevine", quality=highest_quality
|
self.service, self.profile, drm="widevine", quality=highest_quality
|
||||||
)
|
)
|
||||||
@@ -1563,9 +1861,7 @@ class dl:
|
|||||||
f"Pre-selecting Widevine CDM based on highest quality {highest_quality}p across all video tracks"
|
f"Pre-selecting Widevine CDM based on highest quality {highest_quality}p across all video tracks"
|
||||||
)
|
)
|
||||||
self.cdm = quality_based_cdm
|
self.cdm = quality_based_cdm
|
||||||
elif isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) and (
|
elif is_playready_cdm(self.cdm):
|
||||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
|
||||||
):
|
|
||||||
quality_based_cdm = self.get_cdm(
|
quality_based_cdm = self.get_cdm(
|
||||||
self.service, self.profile, drm="playready", quality=highest_quality
|
self.service, self.profile, drm="playready", quality=highest_quality
|
||||||
)
|
)
|
||||||
@@ -1577,9 +1873,6 @@ class dl:
|
|||||||
|
|
||||||
dl_start_time = time.time()
|
dl_start_time = time.time()
|
||||||
|
|
||||||
if skip_dl:
|
|
||||||
DOWNLOAD_LICENCE_ONLY.set()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Live(Padding(download_table, (1, 5)), console=console, refresh_per_second=5):
|
with Live(Padding(download_table, (1, 5)), console=console, refresh_per_second=5):
|
||||||
with ThreadPoolExecutor(downloads) as pool:
|
with ThreadPoolExecutor(downloads) as pool:
|
||||||
@@ -1599,12 +1892,7 @@ class dl:
|
|||||||
),
|
),
|
||||||
licence=partial(
|
licence=partial(
|
||||||
service.get_playready_license
|
service.get_playready_license
|
||||||
if (
|
if (is_playready_cdm(self.cdm))
|
||||||
isinstance(self.cdm, PlayReadyCdm)
|
|
||||||
or (
|
|
||||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and hasattr(service, "get_playready_license")
|
and hasattr(service, "get_playready_license")
|
||||||
else service.get_widevine_license,
|
else service.get_widevine_license,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -1722,9 +2010,7 @@ class dl:
|
|||||||
# Subtitle output mode configuration (for sidecar originals)
|
# Subtitle output mode configuration (for sidecar originals)
|
||||||
subtitle_output_mode = config.subtitle.get("output_mode", "mux")
|
subtitle_output_mode = config.subtitle.get("output_mode", "mux")
|
||||||
sidecar_format = config.subtitle.get("sidecar_format", "srt")
|
sidecar_format = config.subtitle.get("sidecar_format", "srt")
|
||||||
skip_subtitle_mux = (
|
skip_subtitle_mux = subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
|
||||||
subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
|
|
||||||
)
|
|
||||||
sidecar_subtitles: list[Subtitle] = []
|
sidecar_subtitles: list[Subtitle] = []
|
||||||
sidecar_original_paths: dict[str, Path] = {}
|
sidecar_original_paths: dict[str, Path] = {}
|
||||||
if subtitle_output_mode in ("sidecar", "both") and not no_mux:
|
if subtitle_output_mode in ("sidecar", "both") and not no_mux:
|
||||||
@@ -1800,6 +2086,7 @@ class dl:
|
|||||||
|
|
||||||
muxed_paths = []
|
muxed_paths = []
|
||||||
muxed_audio_codecs: dict[Path, Optional[Audio.Codec]] = {}
|
muxed_audio_codecs: dict[Path, Optional[Audio.Codec]] = {}
|
||||||
|
append_audio_codec_suffix = True
|
||||||
|
|
||||||
if no_mux:
|
if no_mux:
|
||||||
# Skip muxing, handle individual track files
|
# Skip muxing, handle individual track files
|
||||||
@@ -1816,12 +2103,16 @@ class dl:
|
|||||||
console=console,
|
console=console,
|
||||||
)
|
)
|
||||||
|
|
||||||
if split_audio is not None:
|
merge_audio = (
|
||||||
merge_audio = not split_audio
|
(not split_audio) if split_audio is not None else config.muxing.get("merge_audio", True)
|
||||||
else:
|
)
|
||||||
merge_audio = config.muxing.get("merge_audio", True)
|
# When we split audio (merge_audio=False), multiple outputs may exist per title, so suffix codec.
|
||||||
|
append_audio_codec_suffix = not merge_audio
|
||||||
|
|
||||||
multiplex_tasks: list[tuple[TaskID, Tracks, Optional[Audio.Codec]]] = []
|
multiplex_tasks: list[tuple[TaskID, Tracks, Optional[Audio.Codec]]] = []
|
||||||
|
# Track hybrid-processing outputs explicitly so we can always clean them up,
|
||||||
|
# even if muxing fails early (e.g. SystemExit) before the normal delete loop.
|
||||||
|
hybrid_temp_paths: list[Path] = []
|
||||||
|
|
||||||
def clone_tracks_for_audio(base_tracks: Tracks, audio_tracks: list[Audio]) -> Tracks:
|
def clone_tracks_for_audio(base_tracks: Tracks, audio_tracks: list[Audio]) -> Tracks:
|
||||||
task_tracks = Tracks()
|
task_tracks = Tracks()
|
||||||
@@ -1856,12 +2147,15 @@ class dl:
|
|||||||
# Hybrid mode: process DV and HDR10 tracks separately for each resolution
|
# Hybrid mode: process DV and HDR10 tracks separately for each resolution
|
||||||
self.log.info("Processing Hybrid HDR10+DV tracks...")
|
self.log.info("Processing Hybrid HDR10+DV tracks...")
|
||||||
|
|
||||||
# Group video tracks by resolution
|
# Group video tracks by resolution (prefer HDR10+ over HDR10 as base)
|
||||||
resolutions_processed = set()
|
resolutions_processed = set()
|
||||||
hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10]
|
base_tracks_list = [
|
||||||
|
v for v in title.tracks.videos
|
||||||
|
if v.range in (Video.Range.HDR10P, Video.Range.HDR10)
|
||||||
|
]
|
||||||
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
|
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
|
||||||
|
|
||||||
for hdr10_track in hdr10_tracks:
|
for hdr10_track in base_tracks_list:
|
||||||
resolution = hdr10_track.height
|
resolution = hdr10_track.height
|
||||||
if resolution in resolutions_processed:
|
if resolution in resolutions_processed:
|
||||||
continue
|
continue
|
||||||
@@ -1883,10 +2177,13 @@ class dl:
|
|||||||
# Create unique output filename for this resolution
|
# Create unique output filename for this resolution
|
||||||
hybrid_filename = f"HDR10-DV-{resolution}p.hevc"
|
hybrid_filename = f"HDR10-DV-{resolution}p.hevc"
|
||||||
hybrid_output_path = config.directories.temp / hybrid_filename
|
hybrid_output_path = config.directories.temp / hybrid_filename
|
||||||
|
hybrid_temp_paths.append(hybrid_output_path)
|
||||||
|
|
||||||
# The Hybrid class creates HDR10-DV.hevc, rename it for this resolution
|
# The Hybrid class creates HDR10-DV.hevc, rename it for this resolution
|
||||||
default_output = config.directories.temp / "HDR10-DV.hevc"
|
default_output = config.directories.temp / "HDR10-DV.hevc"
|
||||||
if default_output.exists():
|
if default_output.exists():
|
||||||
|
# If a previous run left this behind, replace it to avoid move() failures.
|
||||||
|
hybrid_output_path.unlink(missing_ok=True)
|
||||||
shutil.move(str(default_output), str(hybrid_output_path))
|
shutil.move(str(default_output), str(hybrid_output_path))
|
||||||
|
|
||||||
# Create tracks with the hybrid video output for this resolution
|
# Create tracks with the hybrid video output for this resolution
|
||||||
@@ -1895,9 +2192,11 @@ class dl:
|
|||||||
|
|
||||||
# Create a new video track for the hybrid output
|
# Create a new video track for the hybrid output
|
||||||
hybrid_track = deepcopy(hdr10_track)
|
hybrid_track = deepcopy(hdr10_track)
|
||||||
|
hybrid_track.id = f"hybrid_{hdr10_track.id}_{resolution}"
|
||||||
hybrid_track.path = hybrid_output_path
|
hybrid_track.path = hybrid_output_path
|
||||||
hybrid_track.range = Video.Range.DV # It's now a DV track
|
hybrid_track.range = Video.Range.DV # It's now a DV track
|
||||||
hybrid_track.needs_duration_fix = True
|
hybrid_track.needs_duration_fix = True
|
||||||
|
title.tracks.add(hybrid_track)
|
||||||
task_tracks.videos = [hybrid_track]
|
task_tracks.videos = [hybrid_track]
|
||||||
|
|
||||||
enqueue_mux_tasks(task_description, task_tracks)
|
enqueue_mux_tasks(task_description, task_tracks)
|
||||||
@@ -1912,6 +2211,8 @@ class dl:
|
|||||||
task_description += f" {video_track.height}p"
|
task_description += f" {video_track.height}p"
|
||||||
if len(range_) > 1:
|
if len(range_) > 1:
|
||||||
task_description += f" {video_track.range.name}"
|
task_description += f" {video_track.range.name}"
|
||||||
|
if len(vcodec) > 1:
|
||||||
|
task_description += f" {video_track.codec.name}"
|
||||||
|
|
||||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||||
if video_track:
|
if video_track:
|
||||||
@@ -1919,80 +2220,97 @@ class dl:
|
|||||||
|
|
||||||
enqueue_mux_tasks(task_description, task_tracks)
|
enqueue_mux_tasks(task_description, task_tracks)
|
||||||
|
|
||||||
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
try:
|
||||||
mux_index = 0
|
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
||||||
for task_id, task_tracks, audio_codec in multiplex_tasks:
|
mux_index = 0
|
||||||
progress.start_task(task_id) # TODO: Needed?
|
for task_id, task_tracks, audio_codec in multiplex_tasks:
|
||||||
audio_expected = not video_only and not no_audio
|
progress.start_task(task_id) # TODO: Needed?
|
||||||
muxed_path, return_code, errors = task_tracks.mux(
|
audio_expected = not video_only and not no_audio
|
||||||
str(title),
|
muxed_path, return_code, errors = task_tracks.mux(
|
||||||
progress=partial(progress.update, task_id=task_id),
|
str(title),
|
||||||
delete=False,
|
progress=partial(progress.update, task_id=task_id),
|
||||||
audio_expected=audio_expected,
|
delete=False,
|
||||||
title_language=title.language,
|
audio_expected=audio_expected,
|
||||||
skip_subtitles=skip_subtitle_mux,
|
title_language=title.language,
|
||||||
)
|
skip_subtitles=skip_subtitle_mux,
|
||||||
if muxed_path.exists():
|
|
||||||
mux_index += 1
|
|
||||||
unique_path = muxed_path.with_name(
|
|
||||||
f"{muxed_path.stem}.{mux_index}{muxed_path.suffix}"
|
|
||||||
)
|
)
|
||||||
if unique_path != muxed_path:
|
if muxed_path.exists():
|
||||||
shutil.move(muxed_path, unique_path)
|
mux_index += 1
|
||||||
muxed_path = unique_path
|
unique_path = muxed_path.with_name(
|
||||||
muxed_paths.append(muxed_path)
|
f"{muxed_path.stem}.{mux_index}{muxed_path.suffix}"
|
||||||
muxed_audio_codecs[muxed_path] = audio_codec
|
)
|
||||||
if return_code >= 2:
|
if unique_path != muxed_path:
|
||||||
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):")
|
shutil.move(muxed_path, unique_path)
|
||||||
elif return_code == 1 or errors:
|
muxed_path = unique_path
|
||||||
self.log.warning("mkvmerge had at least one warning or error, continuing anyway...")
|
muxed_paths.append(muxed_path)
|
||||||
for line in errors:
|
muxed_audio_codecs[muxed_path] = audio_codec
|
||||||
if line.startswith("#GUI#error"):
|
if return_code >= 2:
|
||||||
self.log.error(line)
|
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):")
|
||||||
|
elif return_code == 1 or errors:
|
||||||
|
self.log.warning("mkvmerge had at least one warning or error, continuing anyway...")
|
||||||
|
for line in errors:
|
||||||
|
if line.startswith("#GUI#error"):
|
||||||
|
self.log.error(line)
|
||||||
|
else:
|
||||||
|
self.log.warning(line)
|
||||||
|
if return_code >= 2:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Output sidecar subtitles before deleting track files
|
||||||
|
if sidecar_subtitles and not no_mux:
|
||||||
|
media_info = MediaInfo.parse(muxed_paths[0]) if muxed_paths else None
|
||||||
|
if media_info:
|
||||||
|
base_filename = title.get_filename(media_info, show_service=not no_source)
|
||||||
else:
|
else:
|
||||||
self.log.warning(line)
|
base_filename = str(title)
|
||||||
if return_code >= 2:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Output sidecar subtitles before deleting track files
|
sidecar_dir = self.output_dir or config.directories.downloads
|
||||||
if sidecar_subtitles and not no_mux:
|
if not no_folder and isinstance(title, (Episode, Song)) and media_info:
|
||||||
media_info = MediaInfo.parse(muxed_paths[0]) if muxed_paths else None
|
sidecar_dir /= title.get_filename(
|
||||||
if media_info:
|
media_info, show_service=not no_source, folder=True
|
||||||
base_filename = title.get_filename(media_info, show_service=not no_source)
|
)
|
||||||
else:
|
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
||||||
base_filename = str(title)
|
|
||||||
|
|
||||||
sidecar_dir = config.directories.downloads
|
with console.status("Saving subtitle sidecar files..."):
|
||||||
if not no_folder and isinstance(title, (Episode, Song)) and media_info:
|
created = self.output_subtitle_sidecars(
|
||||||
sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
|
sidecar_subtitles,
|
||||||
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
base_filename,
|
||||||
|
sidecar_dir,
|
||||||
|
sidecar_format,
|
||||||
|
original_paths=sidecar_original_paths or None,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.log.info(f"Saved {len(created)} sidecar subtitle files")
|
||||||
|
|
||||||
with console.status("Saving subtitle sidecar files..."):
|
for track in title.tracks:
|
||||||
created = self.output_subtitle_sidecars(
|
track.delete()
|
||||||
sidecar_subtitles,
|
|
||||||
base_filename,
|
|
||||||
sidecar_dir,
|
|
||||||
sidecar_format,
|
|
||||||
original_paths=sidecar_original_paths or None,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
self.log.info(f"Saved {len(created)} sidecar subtitle files")
|
|
||||||
|
|
||||||
for track in title.tracks:
|
# Clear temp font attachment paths and delete other attachments
|
||||||
track.delete()
|
for attachment in title.tracks.attachments:
|
||||||
|
if attachment.path and attachment.path in temp_font_files:
|
||||||
|
attachment.path = None
|
||||||
|
else:
|
||||||
|
attachment.delete()
|
||||||
|
|
||||||
# Clear temp font attachment paths and delete other attachments
|
# Clean up temp fonts
|
||||||
for attachment in title.tracks.attachments:
|
for temp_path in temp_font_files:
|
||||||
if attachment.path and attachment.path in temp_font_files:
|
temp_path.unlink(missing_ok=True)
|
||||||
attachment.path = None
|
for temp_path in sidecar_original_paths.values():
|
||||||
else:
|
temp_path.unlink(missing_ok=True)
|
||||||
attachment.delete()
|
finally:
|
||||||
|
# Hybrid() produces a temp HEVC output we rename; make sure it's never left behind.
|
||||||
# Clean up temp fonts
|
# Also attempt to remove the default hybrid output name if it still exists.
|
||||||
for temp_path in temp_font_files:
|
for temp_path in hybrid_temp_paths:
|
||||||
temp_path.unlink(missing_ok=True)
|
try:
|
||||||
for temp_path in sidecar_original_paths.values():
|
temp_path.unlink(missing_ok=True)
|
||||||
temp_path.unlink(missing_ok=True)
|
except PermissionError:
|
||||||
|
self.log.warning(f"Failed to delete temp file (in use?): {temp_path}")
|
||||||
|
try:
|
||||||
|
(config.directories.temp / "HDR10-DV.hevc").unlink(missing_ok=True)
|
||||||
|
except PermissionError:
|
||||||
|
self.log.warning(
|
||||||
|
f"Failed to delete temp file (in use?): {config.directories.temp / 'HDR10-DV.hevc'}"
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# dont mux
|
# dont mux
|
||||||
@@ -2000,7 +2318,7 @@ class dl:
|
|||||||
|
|
||||||
if no_mux:
|
if no_mux:
|
||||||
# Handle individual track files without muxing
|
# Handle individual track files without muxing
|
||||||
final_dir = config.directories.downloads
|
final_dir = self.output_dir or config.directories.downloads
|
||||||
if not no_folder and isinstance(title, (Episode, Song)):
|
if not no_folder and isinstance(title, (Episode, Song)):
|
||||||
# Create folder based on title
|
# Create folder based on title
|
||||||
# Use first available track for filename generation
|
# Use first available track for filename generation
|
||||||
@@ -2050,21 +2368,37 @@ class dl:
|
|||||||
self.log.debug(f"Saved: {final_path.name}")
|
self.log.debug(f"Saved: {final_path.name}")
|
||||||
else:
|
else:
|
||||||
# Handle muxed files
|
# Handle muxed files
|
||||||
|
used_final_paths: set[Path] = set()
|
||||||
for muxed_path in muxed_paths:
|
for muxed_path in muxed_paths:
|
||||||
media_info = MediaInfo.parse(muxed_path)
|
media_info = MediaInfo.parse(muxed_path)
|
||||||
final_dir = config.directories.downloads
|
final_dir = self.output_dir or config.directories.downloads
|
||||||
final_filename = title.get_filename(media_info, show_service=not no_source)
|
final_filename = title.get_filename(media_info, show_service=not no_source)
|
||||||
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
|
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
|
||||||
if audio_codec_suffix:
|
|
||||||
final_filename = f"{final_filename}.{audio_codec_suffix.name}"
|
|
||||||
|
|
||||||
if not no_folder and isinstance(title, (Episode, Song)):
|
if not no_folder and isinstance(title, (Episode, Song)):
|
||||||
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
|
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
|
||||||
|
|
||||||
final_dir.mkdir(parents=True, exist_ok=True)
|
final_dir.mkdir(parents=True, exist_ok=True)
|
||||||
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
|
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
|
||||||
|
if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix:
|
||||||
|
sep = "." if config.scene_naming else " "
|
||||||
|
final_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}"
|
||||||
|
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
|
||||||
|
|
||||||
shutil.move(muxed_path, final_path)
|
if final_path in used_final_paths:
|
||||||
|
sep = "." if config.scene_naming else " "
|
||||||
|
i = 2
|
||||||
|
while final_path in used_final_paths:
|
||||||
|
final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}"
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.replace(muxed_path, final_path)
|
||||||
|
except OSError:
|
||||||
|
if final_path.exists():
|
||||||
|
final_path.unlink()
|
||||||
|
shutil.move(muxed_path, final_path)
|
||||||
|
used_final_paths.add(final_path)
|
||||||
tags.tag_file(final_path, title, self.tmdb_id)
|
tags.tag_file(final_path, title, self.tmdb_id)
|
||||||
|
|
||||||
title_dl_time = time_elapsed_since(dl_start_time)
|
title_dl_time = time_elapsed_since(dl_start_time)
|
||||||
@@ -2106,9 +2440,7 @@ class dl:
|
|||||||
track_quality = track.height
|
track_quality = track.height
|
||||||
|
|
||||||
if isinstance(drm, Widevine):
|
if isinstance(drm, Widevine):
|
||||||
if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or (
|
if not is_widevine_cdm(self.cdm):
|
||||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
|
||||||
):
|
|
||||||
widevine_cdm = self.get_cdm(self.service, self.profile, drm="widevine", quality=track_quality)
|
widevine_cdm = self.get_cdm(self.service, self.profile, drm="widevine", quality=track_quality)
|
||||||
if widevine_cdm:
|
if widevine_cdm:
|
||||||
if track_quality:
|
if track_quality:
|
||||||
@@ -2118,9 +2450,7 @@ class dl:
|
|||||||
self.cdm = widevine_cdm
|
self.cdm = widevine_cdm
|
||||||
|
|
||||||
elif isinstance(drm, PlayReady):
|
elif isinstance(drm, PlayReady):
|
||||||
if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or (
|
if not is_playready_cdm(self.cdm):
|
||||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and not self.cdm.is_playready
|
|
||||||
):
|
|
||||||
playready_cdm = self.get_cdm(self.service, self.profile, drm="playready", quality=track_quality)
|
playready_cdm = self.get_cdm(self.service, self.profile, drm="playready", quality=track_quality)
|
||||||
if playready_cdm:
|
if playready_cdm:
|
||||||
if track_quality:
|
if track_quality:
|
||||||
@@ -2673,12 +3003,12 @@ class dl:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return RemoteCdm(
|
return RemoteCdm(
|
||||||
device_type=cdm_api["Device Type"],
|
device_type=cdm_api.get("Device Type", cdm_api.get("device_type", "")),
|
||||||
system_id=cdm_api["System ID"],
|
system_id=cdm_api.get("System ID", cdm_api.get("system_id", "")),
|
||||||
security_level=cdm_api["Security Level"],
|
security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)),
|
||||||
host=cdm_api["Host"],
|
host=cdm_api.get("Host", cdm_api.get("host")),
|
||||||
secret=cdm_api["Secret"],
|
secret=cdm_api.get("Secret", cdm_api.get("secret")),
|
||||||
device_name=cdm_api["Device Name"],
|
device_name=cdm_api.get("Device Name", cdm_api.get("device_name")),
|
||||||
)
|
)
|
||||||
|
|
||||||
prd_path = config.directories.prds / f"{cdm_name}.prd"
|
prd_path = config.directories.prds / f"{cdm_name}.prd"
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ def serve(
|
|||||||
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
|
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
|
||||||
if no_key:
|
if no_key:
|
||||||
app = web.Application(middlewares=[cors_middleware])
|
app = web.Application(middlewares=[cors_middleware])
|
||||||
app["config"] = {"users": []}
|
app["config"] = {"users": {}}
|
||||||
else:
|
else:
|
||||||
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
||||||
app["config"] = {"users": {api_secret: {"devices": [], "username": "api_user"}}}
|
app["config"] = {"users": {api_secret: {"devices": [], "username": "api_user"}}}
|
||||||
@@ -164,7 +164,12 @@ def serve(
|
|||||||
|
|
||||||
for user_key, user_config in serve_config["users"].items():
|
for user_key, user_config in serve_config["users"].items():
|
||||||
if "playready_devices" not in user_config:
|
if "playready_devices" not in user_config:
|
||||||
user_config["playready_devices"] = prd_device_names
|
# Require explicit PlayReady device access per user (default: no access).
|
||||||
|
user_config["playready_devices"] = []
|
||||||
|
log.warning(
|
||||||
|
f'User "{user_key}" has no "playready_devices" configured; PlayReady access disabled for this user. '
|
||||||
|
f"Available PlayReady devices: {prd_device_names}"
|
||||||
|
)
|
||||||
|
|
||||||
def create_serve_authentication(serve_playready_flag: bool):
|
def create_serve_authentication(serve_playready_flag: bool):
|
||||||
@web.middleware
|
@web.middleware
|
||||||
@@ -206,13 +211,13 @@ def serve(
|
|||||||
"devices": prd_devices,
|
"devices": prd_devices,
|
||||||
"users": {
|
"users": {
|
||||||
user_key: {
|
user_key: {
|
||||||
"devices": user_cfg.get("playready_devices", prd_device_names),
|
"devices": user_cfg.get("playready_devices", []),
|
||||||
"username": user_cfg.get("username", "user"),
|
"username": user_cfg.get("username", "user"),
|
||||||
}
|
}
|
||||||
for user_key, user_cfg in serve_config["users"].items()
|
for user_key, user_cfg in serve_config["users"].items()
|
||||||
}
|
}
|
||||||
if not no_key
|
if not no_key
|
||||||
else [],
|
else {},
|
||||||
}
|
}
|
||||||
playready_app["config"] = playready_config
|
playready_app["config"] = playready_config
|
||||||
playready_app.on_startup.append(pyplayready_serve._startup)
|
playready_app.on_startup.append(pyplayready_serve._startup)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.3.1"
|
__version__ = "3.1.0"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import urllib3
|
import urllib3
|
||||||
@@ -58,7 +59,7 @@ def main(version: bool, debug: bool) -> None:
|
|||||||
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ",
|
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ",
|
||||||
style="ascii.art",
|
style="ascii.art",
|
||||||
),
|
),
|
||||||
f"v [repr.number]{__version__}[/] - © 2025 - github.com/unshackle-dl/unshackle",
|
f"v [repr.number]{__version__}[/] - © 2025-{datetime.now().year} - github.com/unshackle-dl/unshackle",
|
||||||
),
|
),
|
||||||
(1, 11, 1, 10),
|
(1, 11, 1, 10),
|
||||||
expand=True,
|
expand=True,
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
"""API key tier management for remote services."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
log = logging.getLogger("api.keys")
|
|
||||||
|
|
||||||
|
|
||||||
def get_api_key_from_request(request: web.Request) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Extract API key from request headers.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: aiohttp request object
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
API key string or None
|
|
||||||
"""
|
|
||||||
api_key = request.headers.get("X-API-Key")
|
|
||||||
if api_key:
|
|
||||||
return api_key
|
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
|
||||||
if auth_header.startswith("Bearer "):
|
|
||||||
return auth_header[7:] # len("Bearer ") == 7
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_api_key_config(app: web.Application, api_key: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get configuration for a specific API key.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: aiohttp application
|
|
||||||
api_key: API key to look up
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
API key configuration dict or None if not found
|
|
||||||
"""
|
|
||||||
config = app.get("config", {})
|
|
||||||
|
|
||||||
# Check new-style tiered API keys
|
|
||||||
api_keys = config.get("api_keys", [])
|
|
||||||
for key_config in api_keys:
|
|
||||||
if isinstance(key_config, dict) and key_config.get("key") == api_key:
|
|
||||||
return key_config
|
|
||||||
|
|
||||||
# Check legacy users list (backward compatibility)
|
|
||||||
users = config.get("users", [])
|
|
||||||
if api_key in users:
|
|
||||||
return {
|
|
||||||
"key": api_key,
|
|
||||||
"tier": "basic",
|
|
||||||
"allowed_cdms": []
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_premium_user(app: web.Application, api_key: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if an API key belongs to a premium user.
|
|
||||||
|
|
||||||
Premium users can use server-side CDM for decryption.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: aiohttp application
|
|
||||||
api_key: API key to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if premium user, False otherwise
|
|
||||||
"""
|
|
||||||
key_config = get_api_key_config(app, api_key)
|
|
||||||
if not key_config:
|
|
||||||
return False
|
|
||||||
|
|
||||||
tier = key_config.get("tier", "basic")
|
|
||||||
return tier == "premium"
|
|
||||||
|
|
||||||
|
|
||||||
def get_allowed_cdms(app: web.Application, api_key: str) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get list of CDMs that an API key is allowed to use.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: aiohttp application
|
|
||||||
api_key: API key to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of allowed CDM names, or empty list if not premium
|
|
||||||
"""
|
|
||||||
key_config = get_api_key_config(app, api_key)
|
|
||||||
if not key_config:
|
|
||||||
return []
|
|
||||||
|
|
||||||
allowed_cdms = key_config.get("allowed_cdms", [])
|
|
||||||
|
|
||||||
# Handle wildcard
|
|
||||||
if allowed_cdms == "*" or allowed_cdms == ["*"]:
|
|
||||||
return ["*"]
|
|
||||||
|
|
||||||
return allowed_cdms if isinstance(allowed_cdms, list) else []
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_cdm(app: web.Application, api_key: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Get default CDM for an API key.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: aiohttp application
|
|
||||||
api_key: API key to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Default CDM name or None
|
|
||||||
"""
|
|
||||||
key_config = get_api_key_config(app, api_key)
|
|
||||||
if not key_config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return key_config.get("default_cdm")
|
|
||||||
|
|
||||||
|
|
||||||
def can_use_cdm(app: web.Application, api_key: str, cdm_name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if an API key can use a specific CDM.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: aiohttp application
|
|
||||||
api_key: API key to check
|
|
||||||
cdm_name: CDM name to check access for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if allowed, False otherwise
|
|
||||||
"""
|
|
||||||
allowed_cdms = get_allowed_cdms(app, api_key)
|
|
||||||
|
|
||||||
# Wildcard access
|
|
||||||
if "*" in allowed_cdms:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Specific CDM access
|
|
||||||
return cdm_name in allowed_cdms
|
|
||||||
@@ -207,6 +207,7 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
|
|||||||
|
|
||||||
# Get PSSH - handle both Widevine and PlayReady
|
# Get PSSH - handle both Widevine and PlayReady
|
||||||
if hasattr(drm, "_pssh") and drm._pssh:
|
if hasattr(drm, "_pssh") and drm._pssh:
|
||||||
|
pssh_obj = None
|
||||||
try:
|
try:
|
||||||
pssh_obj = drm._pssh
|
pssh_obj = drm._pssh
|
||||||
# Try to get base64 representation
|
# Try to get base64 representation
|
||||||
@@ -225,8 +226,24 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
|
|||||||
# Check if it's already base64-like or an object repr
|
# Check if it's already base64-like or an object repr
|
||||||
if not pssh_str.startswith("<"):
|
if not pssh_str.startswith("<"):
|
||||||
drm_info["pssh"] = pssh_str
|
drm_info["pssh"] = pssh_str
|
||||||
|
except (ValueError, TypeError, KeyError):
|
||||||
|
# Some PSSH implementations can fail to parse/serialize; log and continue.
|
||||||
|
pssh_type = type(pssh_obj).__name__ if pssh_obj is not None else None
|
||||||
|
log.warning(
|
||||||
|
"Failed to extract/serialize PSSH for DRM type=%s pssh_type=%s",
|
||||||
|
drm_class,
|
||||||
|
pssh_type,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
# Don't silently swallow unexpected failures; make them visible and propagate.
|
||||||
|
pssh_type = type(pssh_obj).__name__ if pssh_obj is not None else None
|
||||||
|
log.exception(
|
||||||
|
"Unexpected error while extracting/serializing PSSH for DRM type=%s pssh_type=%s",
|
||||||
|
drm_class,
|
||||||
|
pssh_type,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
# Get KIDs
|
# Get KIDs
|
||||||
if hasattr(drm, "kids") and drm.kids:
|
if hasattr(drm, "kids") and drm.kids:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,6 @@ from unshackle.core import __version__
|
|||||||
from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
|
from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
|
||||||
from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler,
|
from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler,
|
||||||
list_download_jobs_handler, list_titles_handler, list_tracks_handler)
|
list_download_jobs_handler, list_titles_handler, list_tracks_handler)
|
||||||
from unshackle.core.api.remote_handlers import (remote_decrypt, remote_get_chapters, remote_get_license,
|
|
||||||
remote_get_manifest, remote_get_titles, remote_get_tracks,
|
|
||||||
remote_list_services, remote_search)
|
|
||||||
from unshackle.core.services import Services
|
from unshackle.core.services import Services
|
||||||
from unshackle.core.update_checker import UpdateChecker
|
from unshackle.core.update_checker import UpdateChecker
|
||||||
|
|
||||||
@@ -733,16 +730,6 @@ def setup_routes(app: web.Application) -> None:
|
|||||||
app.router.add_get("/api/download/jobs/{job_id}", download_job_detail)
|
app.router.add_get("/api/download/jobs/{job_id}", download_job_detail)
|
||||||
app.router.add_delete("/api/download/jobs/{job_id}", cancel_download_job)
|
app.router.add_delete("/api/download/jobs/{job_id}", cancel_download_job)
|
||||||
|
|
||||||
# Remote service endpoints
|
|
||||||
app.router.add_get("/api/remote/services", remote_list_services)
|
|
||||||
app.router.add_post("/api/remote/{service}/search", remote_search)
|
|
||||||
app.router.add_post("/api/remote/{service}/titles", remote_get_titles)
|
|
||||||
app.router.add_post("/api/remote/{service}/tracks", remote_get_tracks)
|
|
||||||
app.router.add_post("/api/remote/{service}/manifest", remote_get_manifest)
|
|
||||||
app.router.add_post("/api/remote/{service}/chapters", remote_get_chapters)
|
|
||||||
app.router.add_post("/api/remote/{service}/license", remote_get_license)
|
|
||||||
app.router.add_post("/api/remote/{service}/decrypt", remote_decrypt)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_swagger(app: web.Application) -> None:
|
def setup_swagger(app: web.Application) -> None:
|
||||||
"""Setup Swagger UI documentation."""
|
"""Setup Swagger UI documentation."""
|
||||||
@@ -767,14 +754,5 @@ def setup_swagger(app: web.Application) -> None:
|
|||||||
web.get("/api/download/jobs", download_jobs),
|
web.get("/api/download/jobs", download_jobs),
|
||||||
web.get("/api/download/jobs/{job_id}", download_job_detail),
|
web.get("/api/download/jobs/{job_id}", download_job_detail),
|
||||||
web.delete("/api/download/jobs/{job_id}", cancel_download_job),
|
web.delete("/api/download/jobs/{job_id}", cancel_download_job),
|
||||||
# Remote service routes
|
|
||||||
web.get("/api/remote/services", remote_list_services),
|
|
||||||
web.post("/api/remote/{service}/search", remote_search),
|
|
||||||
web.post("/api/remote/{service}/titles", remote_get_titles),
|
|
||||||
web.post("/api/remote/{service}/tracks", remote_get_tracks),
|
|
||||||
web.post("/api/remote/{service}/manifest", remote_get_manifest),
|
|
||||||
web.post("/api/remote/{service}/chapters", remote_get_chapters),
|
|
||||||
web.post("/api/remote/{service}/license", remote_get_license),
|
|
||||||
web.post("/api/remote/{service}/decrypt", remote_decrypt),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
"""Session serialization helpers for remote services."""
|
|
||||||
|
|
||||||
from http.cookiejar import CookieJar
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from unshackle.core.credential import Credential
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_session(session: requests.Session) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Serialize a requests.Session into a JSON-serializable dictionary.
|
|
||||||
|
|
||||||
Extracts cookies, headers, and other session data that can be
|
|
||||||
transferred to a remote client for downloading.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: The requests.Session to serialize
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing serialized session data
|
|
||||||
"""
|
|
||||||
session_data = {
|
|
||||||
"cookies": {},
|
|
||||||
"headers": {},
|
|
||||||
"proxies": session.proxies.copy() if session.proxies else {},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serialize cookies
|
|
||||||
if session.cookies:
|
|
||||||
for cookie in session.cookies:
|
|
||||||
session_data["cookies"][cookie.name] = {
|
|
||||||
"value": cookie.value,
|
|
||||||
"domain": cookie.domain,
|
|
||||||
"path": cookie.path,
|
|
||||||
"secure": cookie.secure,
|
|
||||||
"expires": cookie.expires,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serialize headers (exclude proxy-authorization for security)
|
|
||||||
if session.headers:
|
|
||||||
for key, value in session.headers.items():
|
|
||||||
# Skip proxy-related headers as they're server-specific
|
|
||||||
if key.lower() not in ["proxy-authorization"]:
|
|
||||||
session_data["headers"][key] = value
|
|
||||||
|
|
||||||
return session_data
|
|
||||||
|
|
||||||
|
|
||||||
def deserialize_session(
|
|
||||||
session_data: Dict[str, Any], target_session: Optional[requests.Session] = None
|
|
||||||
) -> requests.Session:
|
|
||||||
"""
|
|
||||||
Deserialize session data into a requests.Session.
|
|
||||||
|
|
||||||
Applies cookies, headers, and other session data from a remote server
|
|
||||||
to a local session for downloading.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_data: Dictionary containing serialized session data
|
|
||||||
target_session: Optional existing session to update (creates new if None)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
requests.Session with applied session data
|
|
||||||
"""
|
|
||||||
if target_session is None:
|
|
||||||
target_session = requests.Session()
|
|
||||||
|
|
||||||
# Apply cookies
|
|
||||||
if "cookies" in session_data:
|
|
||||||
for cookie_name, cookie_data in session_data["cookies"].items():
|
|
||||||
target_session.cookies.set(
|
|
||||||
name=cookie_name,
|
|
||||||
value=cookie_data["value"],
|
|
||||||
domain=cookie_data.get("domain"),
|
|
||||||
path=cookie_data.get("path", "/"),
|
|
||||||
secure=cookie_data.get("secure", False),
|
|
||||||
expires=cookie_data.get("expires"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply headers
|
|
||||||
if "headers" in session_data:
|
|
||||||
target_session.headers.update(session_data["headers"])
|
|
||||||
|
|
||||||
# Note: We don't apply proxies from remote as the local client
|
|
||||||
# should use its own proxy configuration
|
|
||||||
|
|
||||||
return target_session
|
|
||||||
|
|
||||||
|
|
||||||
def extract_session_tokens(session: requests.Session) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Extract authentication tokens and similar data from a session.
|
|
||||||
|
|
||||||
Looks for common authentication patterns like Bearer tokens,
|
|
||||||
API keys in headers, etc.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: The requests.Session to extract tokens from
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing extracted tokens
|
|
||||||
"""
|
|
||||||
tokens = {}
|
|
||||||
|
|
||||||
# Check for Authorization header
|
|
||||||
if "Authorization" in session.headers:
|
|
||||||
tokens["authorization"] = session.headers["Authorization"]
|
|
||||||
|
|
||||||
# Check for common API key headers
|
|
||||||
for key in ["X-API-Key", "Api-Key", "X-Auth-Token"]:
|
|
||||||
if key in session.headers:
|
|
||||||
tokens[key.lower().replace("-", "_")] = session.headers[key]
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
|
|
||||||
def apply_session_tokens(tokens: Dict[str, Any], target_session: requests.Session) -> None:
|
|
||||||
"""
|
|
||||||
Apply authentication tokens to a session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tokens: Dictionary containing tokens to apply
|
|
||||||
target_session: Session to apply tokens to
|
|
||||||
"""
|
|
||||||
# Apply Authorization header
|
|
||||||
if "authorization" in tokens:
|
|
||||||
target_session.headers["Authorization"] = tokens["authorization"]
|
|
||||||
|
|
||||||
# Apply other token headers
|
|
||||||
token_header_map = {
|
|
||||||
"x_api_key": "X-API-Key",
|
|
||||||
"api_key": "Api-Key",
|
|
||||||
"x_auth_token": "X-Auth-Token",
|
|
||||||
}
|
|
||||||
|
|
||||||
for token_key, header_name in token_header_map.items():
|
|
||||||
if token_key in tokens:
|
|
||||||
target_session.headers[header_name] = tokens[token_key]
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_cookies(cookie_jar: Optional[CookieJar]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Serialize a CookieJar into a JSON-serializable dictionary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cookie_jar: The CookieJar to serialize
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing serialized cookies
|
|
||||||
"""
|
|
||||||
if not cookie_jar:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
cookies = {}
|
|
||||||
for cookie in cookie_jar:
|
|
||||||
cookies[cookie.name] = {
|
|
||||||
"value": cookie.value,
|
|
||||||
"domain": cookie.domain,
|
|
||||||
"path": cookie.path,
|
|
||||||
"secure": cookie.secure,
|
|
||||||
"expires": cookie.expires,
|
|
||||||
}
|
|
||||||
|
|
||||||
return cookies
|
|
||||||
|
|
||||||
|
|
||||||
def deserialize_cookies(cookies_data: Dict[str, Any]) -> CookieJar:
|
|
||||||
"""
|
|
||||||
Deserialize cookies into a CookieJar.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cookies_data: Dictionary containing serialized cookies
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
CookieJar with cookies
|
|
||||||
"""
|
|
||||||
import http.cookiejar
|
|
||||||
|
|
||||||
cookie_jar = http.cookiejar.CookieJar()
|
|
||||||
|
|
||||||
for cookie_name, cookie_data in cookies_data.items():
|
|
||||||
cookie = http.cookiejar.Cookie(
|
|
||||||
version=0,
|
|
||||||
name=cookie_name,
|
|
||||||
value=cookie_data["value"],
|
|
||||||
port=None,
|
|
||||||
port_specified=False,
|
|
||||||
domain=cookie_data.get("domain", ""),
|
|
||||||
domain_specified=bool(cookie_data.get("domain")),
|
|
||||||
domain_initial_dot=cookie_data.get("domain", "").startswith("."),
|
|
||||||
path=cookie_data.get("path", "/"),
|
|
||||||
path_specified=True,
|
|
||||||
secure=cookie_data.get("secure", False),
|
|
||||||
expires=cookie_data.get("expires"),
|
|
||||||
discard=False,
|
|
||||||
comment=None,
|
|
||||||
comment_url=None,
|
|
||||||
rest={},
|
|
||||||
)
|
|
||||||
cookie_jar.set_cookie(cookie)
|
|
||||||
|
|
||||||
return cookie_jar
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_credential(credential: Optional[Credential]) -> Optional[Dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Serialize a Credential into a JSON-serializable dictionary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
credential: The Credential to serialize
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing username and password, or None
|
|
||||||
"""
|
|
||||||
if not credential:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {"username": credential.username, "password": credential.password}
|
|
||||||
|
|
||||||
|
|
||||||
def deserialize_credential(credential_data: Optional[Dict[str, str]]) -> Optional[Credential]:
|
|
||||||
"""
|
|
||||||
Deserialize credential data into a Credential object.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
credential_data: Dictionary containing username and password
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Credential object or None
|
|
||||||
"""
|
|
||||||
if not credential_data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return Credential(username=credential_data["username"], password=credential_data["password"])
|
|
||||||
@@ -1,5 +1,57 @@
|
|||||||
from .custom_remote_cdm import CustomRemoteCDM
|
"""
|
||||||
from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
|
CDM helpers and implementations.
|
||||||
from .monalisa import MonaLisaCDM
|
|
||||||
|
|
||||||
__all__ = ["DecryptLabsRemoteCDM", "CustomRemoteCDM", "MonaLisaCDM"]
|
Keep this module import-light: downstream code frequently imports helpers from
|
||||||
|
`unshackle.core.cdm.detect`, which requires importing this package first.
|
||||||
|
Some CDM implementations pull in optional/heavy dependencies, so we lazily
|
||||||
|
import them via `__getattr__` (PEP 562).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DecryptLabsRemoteCDM",
|
||||||
|
"CustomRemoteCDM",
|
||||||
|
"MonaLisaCDM",
|
||||||
|
"is_remote_cdm",
|
||||||
|
"is_local_cdm",
|
||||||
|
"cdm_location",
|
||||||
|
"is_playready_cdm",
|
||||||
|
"is_widevine_cdm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
if name == "DecryptLabsRemoteCDM":
|
||||||
|
from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
|
||||||
|
|
||||||
|
return DecryptLabsRemoteCDM
|
||||||
|
if name == "CustomRemoteCDM":
|
||||||
|
from .custom_remote_cdm import CustomRemoteCDM
|
||||||
|
|
||||||
|
return CustomRemoteCDM
|
||||||
|
if name == "MonaLisaCDM":
|
||||||
|
from .monalisa import MonaLisaCDM
|
||||||
|
|
||||||
|
return MonaLisaCDM
|
||||||
|
|
||||||
|
if name in {
|
||||||
|
"is_remote_cdm",
|
||||||
|
"is_local_cdm",
|
||||||
|
"cdm_location",
|
||||||
|
"is_playready_cdm",
|
||||||
|
"is_widevine_cdm",
|
||||||
|
}:
|
||||||
|
from .detect import cdm_location, is_local_cdm, is_playready_cdm, is_remote_cdm, is_widevine_cdm
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_remote_cdm": is_remote_cdm,
|
||||||
|
"is_local_cdm": is_local_cdm,
|
||||||
|
"cdm_location": cdm_location,
|
||||||
|
"is_playready_cdm": is_playready_cdm,
|
||||||
|
"is_widevine_cdm": is_widevine_cdm,
|
||||||
|
}[name]
|
||||||
|
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
187
unshackle/core/cdm/detect.py
Normal file
187
unshackle/core/cdm/detect.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def is_remote_cdm(cdm: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if the CDM instance is backed by a remote/service CDM.
|
||||||
|
|
||||||
|
This is useful for service logic that needs to know whether the CDM runs
|
||||||
|
locally (in-process) vs over HTTP/RPC (remote).
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cdm is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if hasattr(cdm, "is_remote_cdm"):
|
||||||
|
try:
|
||||||
|
return bool(getattr(cdm, "is_remote_cdm"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm
|
||||||
|
except Exception:
|
||||||
|
PlayReadyRemoteCdm = None
|
||||||
|
|
||||||
|
if PlayReadyRemoteCdm is not None:
|
||||||
|
try:
|
||||||
|
if isinstance(cdm, PlayReadyRemoteCdm):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pywidevine.remotecdm import RemoteCdm as WidevineRemoteCdm
|
||||||
|
except Exception:
|
||||||
|
WidevineRemoteCdm = None
|
||||||
|
|
||||||
|
if WidevineRemoteCdm is not None:
|
||||||
|
try:
|
||||||
|
if isinstance(cdm, WidevineRemoteCdm):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cls = getattr(cdm, "__class__", None)
|
||||||
|
mod = getattr(cls, "__module__", "") or ""
|
||||||
|
name = getattr(cls, "__name__", "") or ""
|
||||||
|
|
||||||
|
if mod == "unshackle.core.cdm.decrypt_labs_remote_cdm" and name == "DecryptLabsRemoteCDM":
|
||||||
|
return True
|
||||||
|
if mod == "unshackle.core.cdm.custom_remote_cdm" and name == "CustomRemoteCDM":
|
||||||
|
return True
|
||||||
|
|
||||||
|
if mod.startswith("pyplayready.remote") or mod.startswith("pywidevine.remote"):
|
||||||
|
return True
|
||||||
|
if "remote" in mod.lower() and name.lower().endswith("cdm"):
|
||||||
|
return True
|
||||||
|
if name.lower().endswith("remotecdm"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_local_cdm(cdm: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if the CDM instance is local/in-process.
|
||||||
|
|
||||||
|
Unknown CDM types return False (use `cdm_location()` if you need 3-state).
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cdm is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if is_remote_cdm(cdm):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if is_playready_cdm(cdm) or is_widevine_cdm(cdm):
|
||||||
|
return True
|
||||||
|
|
||||||
|
cls = getattr(cdm, "__class__", None)
|
||||||
|
mod = getattr(cls, "__module__", "") or ""
|
||||||
|
name = getattr(cls, "__name__", "") or ""
|
||||||
|
if mod == "unshackle.core.cdm.monalisa.monalisa_cdm" and name == "MonaLisaCDM":
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cdm_location(cdm: Any) -> str:
|
||||||
|
"""
|
||||||
|
Return one of: "local", "remote", "unknown".
|
||||||
|
"""
|
||||||
|
|
||||||
|
if is_remote_cdm(cdm):
|
||||||
|
return "remote"
|
||||||
|
if is_local_cdm(cdm):
|
||||||
|
return "local"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def is_playready_cdm(cdm: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if the given CDM should be treated as PlayReady.
|
||||||
|
|
||||||
|
This intentionally supports both:
|
||||||
|
- Local PlayReady CDMs (pyplayready.cdm.Cdm)
|
||||||
|
- Remote/wrapper CDMs (e.g. DecryptLabsRemoteCDM) that expose `is_playready`
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cdm is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if hasattr(cdm, "is_playready"):
|
||||||
|
try:
|
||||||
|
return bool(getattr(cdm, "is_playready"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||||
|
except Exception:
|
||||||
|
PlayReadyCdm = None
|
||||||
|
|
||||||
|
if PlayReadyCdm is not None:
|
||||||
|
try:
|
||||||
|
return isinstance(cdm, PlayReadyCdm)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm
|
||||||
|
except Exception:
|
||||||
|
PlayReadyRemoteCdm = None
|
||||||
|
|
||||||
|
if PlayReadyRemoteCdm is not None:
|
||||||
|
try:
|
||||||
|
return isinstance(cdm, PlayReadyRemoteCdm)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mod = getattr(getattr(cdm, "__class__", None), "__module__", "") or ""
|
||||||
|
return "pyplayready" in mod
|
||||||
|
|
||||||
|
|
||||||
|
def is_widevine_cdm(cdm: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if the given CDM should be treated as Widevine.
|
||||||
|
|
||||||
|
Note: for remote/wrapper CDMs that expose `is_playready`, Widevine is treated
|
||||||
|
as the logical opposite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cdm is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if hasattr(cdm, "is_playready"):
|
||||||
|
try:
|
||||||
|
return not bool(getattr(cdm, "is_playready"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
|
except Exception:
|
||||||
|
WidevineCdm = None
|
||||||
|
|
||||||
|
if WidevineCdm is not None:
|
||||||
|
try:
|
||||||
|
return isinstance(cdm, WidevineCdm)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pywidevine.remotecdm import RemoteCdm as WidevineRemoteCdm
|
||||||
|
except Exception:
|
||||||
|
WidevineRemoteCdm = None
|
||||||
|
|
||||||
|
if WidevineRemoteCdm is not None:
|
||||||
|
try:
|
||||||
|
return isinstance(cdm, WidevineRemoteCdm)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mod = getattr(getattr(cdm, "__class__", None), "__module__", "") or ""
|
||||||
|
return "pywidevine" in mod
|
||||||
@@ -7,8 +7,11 @@ a WebAssembly module that runs locally via wasmtime.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, Union
|
from typing import Dict, Optional, Union
|
||||||
@@ -17,6 +20,8 @@ import wasmtime
|
|||||||
|
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MonaLisaCDM:
|
class MonaLisaCDM:
|
||||||
"""
|
"""
|
||||||
@@ -128,10 +133,27 @@ class MonaLisaCDM:
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.exports["___wasm_call_ctors"](self.store)
|
self.exports["___wasm_call_ctors"](self.store)
|
||||||
self.ctx = self.exports["_monalisa_context_alloc"](self.store)
|
ctx = self.exports["_monalisa_context_alloc"](self.store)
|
||||||
|
self.ctx = ctx
|
||||||
|
|
||||||
|
# _monalisa_context_alloc is expected to return a positive pointer/handle.
|
||||||
|
# Treat 0/negative/non-int-like values as allocation failure.
|
||||||
|
try:
|
||||||
|
ctx_int = int(ctx)
|
||||||
|
except Exception:
|
||||||
|
ctx_int = None
|
||||||
|
|
||||||
|
if ctx_int is None or ctx_int <= 0:
|
||||||
|
# Ensure we don't leave a partially-initialized instance around.
|
||||||
|
self.close()
|
||||||
|
raise RuntimeError(f"Failed to allocate MonaLisa context (ctx={ctx!r})")
|
||||||
return 1
|
return 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Failed to initialize session: {e}")
|
# Clean up partial state (e.g., store/memory/instance) before propagating failure.
|
||||||
|
self.close()
|
||||||
|
if isinstance(e, RuntimeError):
|
||||||
|
raise
|
||||||
|
raise RuntimeError(f"Failed to initialize session: {e}") from e
|
||||||
|
|
||||||
def close(self, session_id: int = 1) -> None:
|
def close(self, session_id: int = 1) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -188,7 +210,9 @@ class MonaLisaCDM:
|
|||||||
# Extract DCID from license to generate KID
|
# Extract DCID from license to generate KID
|
||||||
try:
|
try:
|
||||||
decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore")
|
decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore")
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
# Avoid logging raw license content; log only safe metadata.
|
||||||
|
logger.exception("Failed to base64-decode MonaLisa license (len=%s): %s", len(license_b64), e)
|
||||||
decoded = ""
|
decoded = ""
|
||||||
|
|
||||||
m = re.search(
|
m = re.search(
|
||||||
@@ -198,7 +222,14 @@ class MonaLisaCDM:
|
|||||||
if m:
|
if m:
|
||||||
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes
|
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes
|
||||||
else:
|
else:
|
||||||
kid_bytes = uuid.UUID(int=0).bytes
|
# No DCID in the license: derive a deterministic per-license KID to avoid collisions.
|
||||||
|
try:
|
||||||
|
license_raw = base64.b64decode(license_b64)
|
||||||
|
except Exception:
|
||||||
|
license_raw = license_b64.encode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
license_hash = hashlib.sha256(license_raw).hexdigest()
|
||||||
|
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, f"monalisa:license:{license_hash}").bytes
|
||||||
|
|
||||||
return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"}
|
return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"}
|
||||||
|
|
||||||
@@ -221,21 +252,29 @@ class MonaLisaCDM:
|
|||||||
stack = 0
|
stack = 0
|
||||||
converted_args = []
|
converted_args = []
|
||||||
|
|
||||||
for arg in args:
|
try:
|
||||||
if isinstance(arg, str):
|
for arg in args:
|
||||||
if stack == 0:
|
if isinstance(arg, str):
|
||||||
stack = self.exports["stackSave"](self.store)
|
if stack == 0:
|
||||||
max_length = (len(arg) << 2) + 1
|
stack = self.exports["stackSave"](self.store)
|
||||||
ptr = self.exports["stackAlloc"](self.store, max_length)
|
max_length = (len(arg) << 2) + 1
|
||||||
self._string_to_utf8(arg, ptr, max_length)
|
ptr = self.exports["stackAlloc"](self.store, max_length)
|
||||||
converted_args.append(ptr)
|
self._string_to_utf8(arg, ptr, max_length)
|
||||||
else:
|
converted_args.append(ptr)
|
||||||
converted_args.append(arg)
|
else:
|
||||||
|
converted_args.append(arg)
|
||||||
|
|
||||||
result = self.exports[func_name](self.store, *converted_args)
|
result = self.exports[func_name](self.store, *converted_args)
|
||||||
|
finally:
|
||||||
if stack != 0:
|
# stackAlloc pointers live on the WASM stack; always restore even if the call throws.
|
||||||
self.exports["stackRestore"](self.store, stack)
|
if stack != 0:
|
||||||
|
exc = sys.exc_info()[1]
|
||||||
|
try:
|
||||||
|
self.exports["stackRestore"](self.store, stack)
|
||||||
|
except Exception:
|
||||||
|
# If we're already failing, don't mask the original exception.
|
||||||
|
if exc is None:
|
||||||
|
raise
|
||||||
|
|
||||||
if return_type is bool:
|
if return_type is bool:
|
||||||
return bool(result)
|
return bool(result)
|
||||||
@@ -243,6 +282,13 @@ class MonaLisaCDM:
|
|||||||
|
|
||||||
def _write_i32(self, addr: int, value: int) -> None:
|
def _write_i32(self, addr: int, value: int) -> None:
|
||||||
"""Write a 32-bit integer to WASM memory."""
|
"""Write a 32-bit integer to WASM memory."""
|
||||||
|
if addr % 4 != 0:
|
||||||
|
raise ValueError(f"Unaligned i32 write: addr={addr} (must be 4-byte aligned)")
|
||||||
|
|
||||||
|
data_len = self.memory.data_len(self.store)
|
||||||
|
if addr < 0 or addr + 4 > data_len:
|
||||||
|
raise IndexError(f"i32 write out of bounds: addr={addr}, mem_len={data_len}")
|
||||||
|
|
||||||
data = self.memory.data_ptr(self.store)
|
data = self.memory.data_ptr(self.store)
|
||||||
mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32))
|
mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32))
|
||||||
mem_ptr[addr >> 2] = value
|
mem_ptr[addr >> 2] = value
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import textwrap
|
import textwrap
|
||||||
@@ -54,11 +55,13 @@ class _Aria2Manager:
|
|||||||
"""Singleton manager to run one aria2c process and enqueue downloads via RPC."""
|
"""Singleton manager to run one aria2c process and enqueue downloads via RPC."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
self._logger = logging.getLogger(__name__)
|
||||||
self._proc: Optional[subprocess.Popen] = None
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
self._rpc_port: Optional[int] = None
|
self._rpc_port: Optional[int] = None
|
||||||
self._rpc_secret: Optional[str] = None
|
self._rpc_secret: Optional[str] = None
|
||||||
self._rpc_uri: Optional[str] = None
|
self._rpc_uri: Optional[str] = None
|
||||||
self._session: Session = Session()
|
self._session: Session = Session()
|
||||||
|
self._max_workers: Optional[int] = None
|
||||||
self._max_concurrent_downloads: int = 0
|
self._max_concurrent_downloads: int = 0
|
||||||
self._max_connection_per_server: int = 1
|
self._max_connection_per_server: int = 1
|
||||||
self._split_default: int = 5
|
self._split_default: int = 5
|
||||||
@@ -66,6 +69,47 @@ class _Aria2Manager:
|
|||||||
self._proxy: Optional[str] = None
|
self._proxy: Optional[str] = None
|
||||||
self._lock: threading.Lock = threading.Lock()
|
self._lock: threading.Lock = threading.Lock()
|
||||||
|
|
||||||
|
def _wait_for_rpc_ready(self, timeout_s: float = 8.0, interval_s: float = 0.1) -> None:
|
||||||
|
assert self._proc is not None
|
||||||
|
assert self._rpc_uri is not None
|
||||||
|
assert self._rpc_secret is not None
|
||||||
|
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": get_random_bytes(16).hex(),
|
||||||
|
"method": "aria2.getVersion",
|
||||||
|
"params": [f"token:{self._rpc_secret}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"aria2c exited before RPC became ready (exit code {self._proc.returncode})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
res = self._session.post(self._rpc_uri, json=payload, timeout=0.25)
|
||||||
|
data = res.json()
|
||||||
|
if isinstance(data, dict) and data.get("result") is not None:
|
||||||
|
return
|
||||||
|
except (requests.exceptions.RequestException, ValueError):
|
||||||
|
# Not ready yet (connection refused / bad response / etc.)
|
||||||
|
pass
|
||||||
|
time.sleep(interval_s)
|
||||||
|
|
||||||
|
# Timed out: ensure we don't leave a zombie/stray aria2c process behind.
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._proc.kill()
|
||||||
|
self._proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise TimeoutError(f"aria2c RPC did not become ready within {timeout_s:.1f}s")
|
||||||
|
|
||||||
def _build_args(self) -> list[str]:
|
def _build_args(self) -> list[str]:
|
||||||
args = [
|
args = [
|
||||||
"--continue=true",
|
"--continue=true",
|
||||||
@@ -95,9 +139,6 @@ class _Aria2Manager:
|
|||||||
max_workers: Optional[int],
|
max_workers: Optional[int],
|
||||||
) -> None:
|
) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._proc and self._proc.poll() is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not binaries.Aria2:
|
if not binaries.Aria2:
|
||||||
debug_logger = get_debug_logger()
|
debug_logger = get_debug_logger()
|
||||||
if debug_logger:
|
if debug_logger:
|
||||||
@@ -109,27 +150,45 @@ class _Aria2Manager:
|
|||||||
)
|
)
|
||||||
raise EnvironmentError("Aria2c executable not found...")
|
raise EnvironmentError("Aria2c executable not found...")
|
||||||
|
|
||||||
|
effective_proxy = proxy or None
|
||||||
|
|
||||||
if not max_workers:
|
if not max_workers:
|
||||||
max_workers = min(32, (os.cpu_count() or 1) + 4)
|
effective_max_workers = min(32, (os.cpu_count() or 1) + 4)
|
||||||
elif not isinstance(max_workers, int):
|
elif not isinstance(max_workers, int):
|
||||||
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
||||||
|
else:
|
||||||
|
effective_max_workers = max_workers
|
||||||
|
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
if effective_proxy != self._proxy or effective_max_workers != self._max_workers:
|
||||||
|
self._logger.warning(
|
||||||
|
"aria2c process is already running; requested proxy=%r, max_workers=%r, "
|
||||||
|
"but running process will continue with proxy=%r, max_workers=%r",
|
||||||
|
effective_proxy,
|
||||||
|
effective_max_workers,
|
||||||
|
self._proxy,
|
||||||
|
self._max_workers,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self._rpc_port = get_free_port()
|
self._rpc_port = get_free_port()
|
||||||
self._rpc_secret = get_random_bytes(16).hex()
|
self._rpc_secret = get_random_bytes(16).hex()
|
||||||
self._rpc_uri = f"http://127.0.0.1:{self._rpc_port}/jsonrpc"
|
self._rpc_uri = f"http://127.0.0.1:{self._rpc_port}/jsonrpc"
|
||||||
|
|
||||||
self._max_concurrent_downloads = int(config.aria2c.get("max_concurrent_downloads", max_workers))
|
self._max_workers = effective_max_workers
|
||||||
|
self._max_concurrent_downloads = int(
|
||||||
|
config.aria2c.get("max_concurrent_downloads", effective_max_workers)
|
||||||
|
)
|
||||||
self._max_connection_per_server = int(config.aria2c.get("max_connection_per_server", 1))
|
self._max_connection_per_server = int(config.aria2c.get("max_connection_per_server", 1))
|
||||||
self._split_default = int(config.aria2c.get("split", 5))
|
self._split_default = int(config.aria2c.get("split", 5))
|
||||||
self._file_allocation = config.aria2c.get("file_allocation", "prealloc")
|
self._file_allocation = config.aria2c.get("file_allocation", "prealloc")
|
||||||
self._proxy = proxy or None
|
self._proxy = effective_proxy
|
||||||
|
|
||||||
args = self._build_args()
|
args = self._build_args()
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
[binaries.Aria2, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
[binaries.Aria2, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||||
)
|
)
|
||||||
# Give aria2c a moment to start up and bind to the RPC port
|
self._wait_for_rpc_ready()
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rpc_uri(self) -> str:
|
def rpc_uri(self) -> str:
|
||||||
|
|||||||
@@ -192,8 +192,10 @@ def build_download_args(
|
|||||||
if ad_keyword:
|
if ad_keyword:
|
||||||
args["--ad-keyword"] = ad_keyword
|
args["--ad-keyword"] = ad_keyword
|
||||||
|
|
||||||
|
key_args = []
|
||||||
if content_keys:
|
if content_keys:
|
||||||
args["--key"] = next((f"{kid.hex}:{key.lower()}" for kid, key in content_keys.items()), None)
|
for kid, key in content_keys.items():
|
||||||
|
key_args.extend(["--key", f"{kid.hex}:{key.lower()}"])
|
||||||
|
|
||||||
decryption_config = config.decryption.lower()
|
decryption_config = config.decryption.lower()
|
||||||
engine_name = DECRYPTION_ENGINE.get(decryption_config) or "SHAKA_PACKAGER"
|
engine_name = DECRYPTION_ENGINE.get(decryption_config) or "SHAKA_PACKAGER"
|
||||||
@@ -221,6 +223,9 @@ def build_download_args(
|
|||||||
elif value is not False and value is not None:
|
elif value is not False and value is not None:
|
||||||
command.extend([flag, str(value)])
|
command.extend([flag, str(value)])
|
||||||
|
|
||||||
|
# Append all content keys (multiple --key flags supported by N_m3u8DL-RE)
|
||||||
|
command.extend(key_args)
|
||||||
|
|
||||||
if headers:
|
if headers:
|
||||||
for key, value in headers.items():
|
for key, value in headers.items():
|
||||||
if key.lower() not in ("accept-encoding", "cookie"):
|
if key.lower() not in ("accept-encoding", "cookie"):
|
||||||
|
|||||||
@@ -260,11 +260,18 @@ def requests(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
yield dict(total=len(urls))
|
# If we're downloading more than one URL, treat them as "segments" for progress purposes.
|
||||||
|
# For single-URL downloads we want per-chunk progress (and the inner `download()` will yield
|
||||||
|
# a chunk-based `total`), so don't set a segment total of 1 here.
|
||||||
|
segmented_batch = len(urls) > 1
|
||||||
|
if segmented_batch:
|
||||||
|
yield dict(total=len(urls))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||||
for future in as_completed(pool.submit(download, session=session, segmented=True, **url) for url in urls):
|
for future in as_completed(
|
||||||
|
pool.submit(download, session=session, segmented=segmented_batch, **url) for url in urls
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
yield from future.result()
|
yield from future.result()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ segment decryption (ML-Worker binary + AES-ECB).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -17,6 +18,8 @@ from uuid import UUID
|
|||||||
from Cryptodome.Cipher import AES
|
from Cryptodome.Cipher import AES
|
||||||
from Cryptodome.Util.Padding import unpad
|
from Cryptodome.Util.Padding import unpad
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MonaLisa:
|
class MonaLisa:
|
||||||
"""
|
"""
|
||||||
@@ -142,7 +145,16 @@ class MonaLisa:
|
|||||||
The raw PSSH value as a base64 string.
|
The raw PSSH value as a base64 string.
|
||||||
"""
|
"""
|
||||||
if isinstance(self._ticket, bytes):
|
if isinstance(self._ticket, bytes):
|
||||||
return self._ticket.decode("utf-8")
|
try:
|
||||||
|
return self._ticket.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# Tickets are typically base64, so ASCII is a reasonable fallback.
|
||||||
|
try:
|
||||||
|
return self._ticket.decode("ascii")
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Ticket bytes must be UTF-8 text or ASCII base64; got undecodable bytes (len={len(self._ticket)})"
|
||||||
|
) from e
|
||||||
return self._ticket
|
return self._ticket
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -222,19 +234,21 @@ class MonaLisa:
|
|||||||
raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}")
|
raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}")
|
||||||
|
|
||||||
# Stage 1: ML-Worker decryption
|
# Stage 1: ML-Worker decryption
|
||||||
cmd = [str(worker_path), self._key, str(bbts_path), str(ents_path)]
|
cmd = [str(worker_path), str(self._key), str(bbts_path), str(ents_path)]
|
||||||
|
|
||||||
startupinfo = None
|
startupinfo = None
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
startupinfo = subprocess.STARTUPINFO()
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
|
||||||
|
worker_timeout_s = 60
|
||||||
process = subprocess.run(
|
process = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
startupinfo=startupinfo,
|
startupinfo=startupinfo,
|
||||||
|
timeout=worker_timeout_s,
|
||||||
)
|
)
|
||||||
|
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
@@ -260,6 +274,11 @@ class MonaLisa:
|
|||||||
|
|
||||||
except MonaLisa.Exceptions.DecryptionFailed:
|
except MonaLisa.Exceptions.DecryptionFailed:
|
||||||
raise
|
raise
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
log.error("ML-Worker timed out after %ss for %s", worker_timeout_s, segment_path.name)
|
||||||
|
raise MonaLisa.Exceptions.DecryptionFailed(
|
||||||
|
f"ML-Worker timed out after {worker_timeout_s}s for {segment_path.name}"
|
||||||
|
) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise MonaLisa.Exceptions.DecryptionFailed(f"Failed to decrypt segment {segment_path.name}: {e}")
|
raise MonaLisa.Exceptions.DecryptionFailed(f"Failed to decrypt segment {segment_path.name}: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import requests
|
|||||||
from curl_cffi.requests import Session as CurlSession
|
from curl_cffi.requests import Session as CurlSession
|
||||||
from langcodes import Language, tag_is_valid
|
from langcodes import Language, tag_is_valid
|
||||||
from lxml.etree import Element, ElementTree
|
from lxml.etree import Element, ElementTree
|
||||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
|
||||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
from pywidevine.pssh import PSSH
|
from pywidevine.pssh import PSSH
|
||||||
from requests import Session
|
from requests import Session
|
||||||
|
|
||||||
|
from unshackle.core.cdm.detect import is_playready_cdm
|
||||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||||
from unshackle.core.downloaders import requests as requests_downloader
|
from unshackle.core.downloaders import requests as requests_downloader
|
||||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||||
@@ -477,7 +477,7 @@ class DASH:
|
|||||||
track.data["dash"]["segment_durations"] = segment_durations
|
track.data["dash"]["segment_durations"] = segment_durations
|
||||||
|
|
||||||
if not track.drm and init_data and isinstance(track, (Video, Audio)):
|
if not track.drm and init_data and isinstance(track, (Video, Audio)):
|
||||||
prefers_playready = isinstance(cdm, PlayReadyCdm) or (hasattr(cdm, "is_playready") and cdm.is_playready)
|
prefers_playready = is_playready_cdm(cdm)
|
||||||
if prefers_playready:
|
if prefers_playready:
|
||||||
try:
|
try:
|
||||||
track.drm = [PlayReady.from_init_data(init_data)]
|
track.drm = [PlayReady.from_init_data(init_data)]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from pywidevine.pssh import PSSH as WV_PSSH
|
|||||||
from requests import Session
|
from requests import Session
|
||||||
|
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
|
from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
||||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||||
from unshackle.core.downloaders import requests as requests_downloader
|
from unshackle.core.downloaders import requests as requests_downloader
|
||||||
from unshackle.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine
|
from unshackle.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine
|
||||||
@@ -115,9 +116,14 @@ class HLS:
|
|||||||
|
|
||||||
for playlist in self.manifest.playlists:
|
for playlist in self.manifest.playlists:
|
||||||
audio_group = playlist.stream_info.audio
|
audio_group = playlist.stream_info.audio
|
||||||
if audio_group:
|
audio_codec: Optional[Audio.Codec] = None
|
||||||
audio_codec = Audio.Codec.from_codecs(playlist.stream_info.codecs)
|
if audio_group and playlist.stream_info.codecs:
|
||||||
audio_codecs_by_group_id[audio_group] = audio_codec
|
try:
|
||||||
|
audio_codec = Audio.Codec.from_codecs(playlist.stream_info.codecs)
|
||||||
|
except ValueError:
|
||||||
|
audio_codec = None
|
||||||
|
if audio_codec:
|
||||||
|
audio_codecs_by_group_id[audio_group] = audio_codec
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# TODO: Any better way to figure out the primary track type?
|
# TODO: Any better way to figure out the primary track type?
|
||||||
@@ -225,6 +231,39 @@ class HLS:
|
|||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _finalize_n_m3u8dl_re_output(*, track: AnyTrack, save_dir: Path, save_path: Path) -> Path:
|
||||||
|
"""
|
||||||
|
Finalize output from N_m3u8DL-RE.
|
||||||
|
|
||||||
|
We call N_m3u8DL-RE with `--save-name track.id`, so the final file should be `{track.id}.*` under `save_dir`.
|
||||||
|
This moves that output to `save_path` (preserving the real suffix) and, for subtitles, updates `track.codec`
|
||||||
|
to match the produced file extension.
|
||||||
|
"""
|
||||||
|
matches = [p for p in save_dir.rglob(f"{track.id}.*") if p.is_file()]
|
||||||
|
if not matches:
|
||||||
|
raise FileNotFoundError(f"No output files produced by N_m3u8DL-RE for save-name={track.id} in: {save_dir}")
|
||||||
|
|
||||||
|
primary = max(matches, key=lambda p: p.stat().st_size)
|
||||||
|
|
||||||
|
final_save_path = save_path.with_suffix(primary.suffix) if primary.suffix else save_path
|
||||||
|
|
||||||
|
final_save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if primary.absolute() != final_save_path.absolute():
|
||||||
|
final_save_path.unlink(missing_ok=True)
|
||||||
|
shutil.move(str(primary), str(final_save_path))
|
||||||
|
|
||||||
|
if isinstance(track, Subtitle):
|
||||||
|
ext = final_save_path.suffix.lower().lstrip(".")
|
||||||
|
try:
|
||||||
|
track.codec = Subtitle.Codec.from_mime(ext)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
shutil.rmtree(save_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
return final_save_path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def download_track(
|
def download_track(
|
||||||
track: AnyTrack,
|
track: AnyTrack,
|
||||||
@@ -255,7 +294,7 @@ class HLS:
|
|||||||
else:
|
else:
|
||||||
# Get the playlist text and handle both session types
|
# Get the playlist text and handle both session types
|
||||||
response = session.get(track.url)
|
response = session.get(track.url)
|
||||||
if isinstance(response, requests.Response):
|
if isinstance(response, requests.Response) or isinstance(response, CurlResponse):
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
|
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -317,8 +356,16 @@ class HLS:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
if not initial_drm_licensed and session_drm and isinstance(session_drm, MonaLisa):
|
if not initial_drm_licensed and session_drm and isinstance(session_drm, MonaLisa):
|
||||||
if license_widevine:
|
try:
|
||||||
|
if not license_widevine:
|
||||||
|
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||||
|
progress(downloaded="LICENSING")
|
||||||
license_widevine(session_drm)
|
license_widevine(session_drm)
|
||||||
|
progress(downloaded="[yellow]LICENSED")
|
||||||
|
except Exception: # noqa
|
||||||
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||||
|
progress(downloaded="[red]FAILED")
|
||||||
|
raise
|
||||||
|
|
||||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||||
progress(downloaded="[yellow]SKIPPED")
|
progress(downloaded="[yellow]SKIPPED")
|
||||||
@@ -420,222 +467,228 @@ class HLS:
|
|||||||
for control_file in segment_save_dir.glob("*.aria2__temp"):
|
for control_file in segment_save_dir.glob("*.aria2__temp"):
|
||||||
control_file.unlink()
|
control_file.unlink()
|
||||||
|
|
||||||
if not skip_merge:
|
if skip_merge:
|
||||||
progress(total=total_segments, completed=0, downloaded="Merging")
|
final_save_path = HLS._finalize_n_m3u8dl_re_output(track=track, save_dir=save_dir, save_path=save_path)
|
||||||
|
progress(downloaded="Downloaded")
|
||||||
|
track.path = final_save_path
|
||||||
|
events.emit(events.Types.TRACK_DOWNLOADED, track=track)
|
||||||
|
return
|
||||||
|
|
||||||
name_len = len(str(total_segments))
|
progress(total=total_segments, completed=0, downloaded="Merging")
|
||||||
discon_i = 0
|
|
||||||
range_offset = 0
|
|
||||||
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
|
|
||||||
if session_drm:
|
|
||||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = (initial_drm_key, session_drm)
|
|
||||||
else:
|
|
||||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = None
|
|
||||||
|
|
||||||
i = -1
|
name_len = len(str(total_segments))
|
||||||
for real_i, segment in enumerate(master.segments):
|
discon_i = 0
|
||||||
if segment not in unwanted_segments:
|
range_offset = 0
|
||||||
i += 1
|
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
|
||||||
|
if session_drm:
|
||||||
|
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = (initial_drm_key, session_drm)
|
||||||
|
else:
|
||||||
|
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = None
|
||||||
|
|
||||||
is_last_segment = (real_i + 1) == len(master.segments)
|
i = -1
|
||||||
|
for real_i, segment in enumerate(master.segments):
|
||||||
|
if segment not in unwanted_segments:
|
||||||
|
i += 1
|
||||||
|
|
||||||
def merge(to: Path, via: list[Path], delete: bool = False, include_map_data: bool = False):
|
is_last_segment = (real_i + 1) == len(master.segments)
|
||||||
"""
|
|
||||||
Merge all files to a given path, optionally including map data.
|
|
||||||
|
|
||||||
Parameters:
|
def merge(to: Path, via: list[Path], delete: bool = False, include_map_data: bool = False):
|
||||||
to: The output file with all merged data.
|
"""
|
||||||
via: List of files to merge, in sequence.
|
Merge all files to a given path, optionally including map data.
|
||||||
delete: Delete the file once it's been merged.
|
|
||||||
include_map_data: Whether to include the init map data.
|
|
||||||
"""
|
|
||||||
with open(to, "wb") as x:
|
|
||||||
if include_map_data and map_data and map_data[1]:
|
|
||||||
x.write(map_data[1])
|
|
||||||
for file in via:
|
|
||||||
x.write(file.read_bytes())
|
|
||||||
x.flush()
|
|
||||||
if delete:
|
|
||||||
file.unlink()
|
|
||||||
|
|
||||||
def decrypt(include_this_segment: bool) -> Path:
|
Parameters:
|
||||||
"""
|
to: The output file with all merged data.
|
||||||
Decrypt all segments that uses the currently set DRM.
|
via: List of files to merge, in sequence.
|
||||||
|
delete: Delete the file once it's been merged.
|
||||||
|
include_map_data: Whether to include the init map data.
|
||||||
|
"""
|
||||||
|
with open(to, "wb") as x:
|
||||||
|
if include_map_data and map_data and map_data[1]:
|
||||||
|
x.write(map_data[1])
|
||||||
|
for file in via:
|
||||||
|
x.write(file.read_bytes())
|
||||||
|
x.flush()
|
||||||
|
if delete:
|
||||||
|
file.unlink()
|
||||||
|
|
||||||
All segments that will be decrypted with this DRM will be merged together
|
def decrypt(include_this_segment: bool) -> Path:
|
||||||
in sequence, prefixed with the init data (if any), and then deleted. Once
|
"""
|
||||||
merged they will be decrypted. The merged and decrypted file names state
|
Decrypt all segments that uses the currently set DRM.
|
||||||
the range of segments that were used.
|
|
||||||
|
|
||||||
Parameters:
|
All segments that will be decrypted with this DRM will be merged together
|
||||||
include_this_segment: Whether to include the current segment in the
|
in sequence, prefixed with the init data (if any), and then deleted. Once
|
||||||
list of segments to merge and decrypt. This should be False if
|
merged they will be decrypted. The merged and decrypted file names state
|
||||||
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
the range of segments that were used.
|
||||||
last segment.
|
|
||||||
|
|
||||||
Returns the decrypted path.
|
Parameters:
|
||||||
"""
|
include_this_segment: Whether to include the current segment in the
|
||||||
drm = encryption_data[1]
|
list of segments to merge and decrypt. This should be False if
|
||||||
first_segment_i = next(
|
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
||||||
int(file.stem) for file in sorted(segment_save_dir.iterdir()) if file.stem.isdigit()
|
last segment.
|
||||||
)
|
|
||||||
last_segment_i = max(0, i - int(not include_this_segment))
|
|
||||||
range_len = (last_segment_i - first_segment_i) + 1
|
|
||||||
|
|
||||||
segment_range = f"{str(first_segment_i).zfill(name_len)}-{str(last_segment_i).zfill(name_len)}"
|
Returns the decrypted path.
|
||||||
merged_path = (
|
"""
|
||||||
segment_save_dir / f"{segment_range}{get_extension(master.segments[last_segment_i].uri)}"
|
drm = encryption_data[1]
|
||||||
)
|
first_segment_i = next(
|
||||||
decrypted_path = segment_save_dir / f"{merged_path.stem}_decrypted{merged_path.suffix}"
|
int(file.stem) for file in sorted(segment_save_dir.iterdir()) if file.stem.isdigit()
|
||||||
|
)
|
||||||
|
last_segment_i = max(0, i - int(not include_this_segment))
|
||||||
|
range_len = (last_segment_i - first_segment_i) + 1
|
||||||
|
|
||||||
files = [
|
segment_range = f"{str(first_segment_i).zfill(name_len)}-{str(last_segment_i).zfill(name_len)}"
|
||||||
file
|
merged_path = segment_save_dir / f"{segment_range}{get_extension(master.segments[last_segment_i].uri)}"
|
||||||
for file in sorted(segment_save_dir.iterdir())
|
decrypted_path = segment_save_dir / f"{merged_path.stem}_decrypted{merged_path.suffix}"
|
||||||
if file.stem.isdigit() and first_segment_i <= int(file.stem) <= last_segment_i
|
|
||||||
]
|
|
||||||
if not files:
|
|
||||||
raise ValueError(f"None of the segment files for {segment_range} exist...")
|
|
||||||
elif len(files) != range_len:
|
|
||||||
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
|
|
||||||
|
|
||||||
if isinstance(drm, (Widevine, PlayReady)):
|
files = [
|
||||||
# with widevine we can merge all segments and decrypt once
|
file
|
||||||
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
for file in sorted(segment_save_dir.iterdir())
|
||||||
drm.decrypt(merged_path)
|
if file.stem.isdigit() and first_segment_i <= int(file.stem) <= last_segment_i
|
||||||
merged_path.rename(decrypted_path)
|
]
|
||||||
else:
|
if not files:
|
||||||
# with other drm we must decrypt separately and then merge them
|
raise ValueError(f"None of the segment files for {segment_range} exist...")
|
||||||
# for aes this is because each segment likely has 16-byte padding
|
elif len(files) != range_len:
|
||||||
for file in files:
|
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
|
||||||
drm.decrypt(file)
|
|
||||||
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
|
||||||
|
|
||||||
events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=drm, segment=decrypted_path)
|
if isinstance(drm, (Widevine, PlayReady)):
|
||||||
|
# with widevine we can merge all segments and decrypt once
|
||||||
|
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
||||||
|
drm.decrypt(merged_path)
|
||||||
|
merged_path.rename(decrypted_path)
|
||||||
|
else:
|
||||||
|
# with other drm we must decrypt separately and then merge them
|
||||||
|
# for aes this is because each segment likely has 16-byte padding
|
||||||
|
for file in files:
|
||||||
|
drm.decrypt(file)
|
||||||
|
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
||||||
|
|
||||||
return decrypted_path
|
events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=drm, segment=decrypted_path)
|
||||||
|
|
||||||
def merge_discontinuity(include_this_segment: bool, include_map_data: bool = True):
|
return decrypted_path
|
||||||
"""
|
|
||||||
Merge all segments of the discontinuity.
|
|
||||||
|
|
||||||
All segment files for this discontinuity must already be downloaded and
|
def merge_discontinuity(include_this_segment: bool, include_map_data: bool = True):
|
||||||
already decrypted (if it needs to be decrypted).
|
"""
|
||||||
|
Merge all segments of the discontinuity.
|
||||||
|
|
||||||
Parameters:
|
All segment files for this discontinuity must already be downloaded and
|
||||||
include_this_segment: Whether to include the current segment in the
|
already decrypted (if it needs to be decrypted).
|
||||||
list of segments to merge and decrypt. This should be False if
|
|
||||||
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
|
||||||
last segment.
|
|
||||||
include_map_data: Whether to prepend the init map data before the
|
|
||||||
segment files when merging.
|
|
||||||
"""
|
|
||||||
last_segment_i = max(0, i - int(not include_this_segment))
|
|
||||||
|
|
||||||
files = [
|
Parameters:
|
||||||
file
|
include_this_segment: Whether to include the current segment in the
|
||||||
for file in sorted(segment_save_dir.iterdir())
|
list of segments to merge and decrypt. This should be False if
|
||||||
if int(file.stem.replace("_decrypted", "").split("-")[-1]) <= last_segment_i
|
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
||||||
]
|
last segment.
|
||||||
if files:
|
include_map_data: Whether to prepend the init map data before the
|
||||||
to_dir = segment_save_dir.parent
|
segment files when merging.
|
||||||
to_path = to_dir / f"{str(discon_i).zfill(name_len)}{files[-1].suffix}"
|
"""
|
||||||
merge(to=to_path, via=files, delete=True, include_map_data=include_map_data)
|
last_segment_i = max(0, i - int(not include_this_segment))
|
||||||
|
|
||||||
if segment not in unwanted_segments:
|
files = [
|
||||||
if isinstance(track, Subtitle):
|
file
|
||||||
segment_file_ext = get_extension(segment.uri)
|
for file in sorted(segment_save_dir.iterdir())
|
||||||
segment_file_path = segment_save_dir / f"{str(i).zfill(name_len)}{segment_file_ext}"
|
if int(file.stem.replace("_decrypted", "").split("-")[-1]) <= last_segment_i
|
||||||
segment_data = try_ensure_utf8(segment_file_path.read_bytes())
|
]
|
||||||
if track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML):
|
if files:
|
||||||
segment_data = (
|
to_dir = segment_save_dir.parent
|
||||||
segment_data.decode("utf8")
|
to_path = to_dir / f"{str(discon_i).zfill(name_len)}{files[-1].suffix}"
|
||||||
.replace("‎", html.unescape("‎"))
|
merge(to=to_path, via=files, delete=True, include_map_data=include_map_data)
|
||||||
.replace("‏", html.unescape("‏"))
|
|
||||||
.encode("utf8")
|
|
||||||
)
|
|
||||||
segment_file_path.write_bytes(segment_data)
|
|
||||||
|
|
||||||
if segment.discontinuity and i != 0:
|
if segment not in unwanted_segments:
|
||||||
if encryption_data:
|
if isinstance(track, Subtitle):
|
||||||
decrypt(include_this_segment=False)
|
segment_file_ext = get_extension(segment.uri)
|
||||||
merge_discontinuity(
|
segment_file_path = segment_save_dir / f"{str(i).zfill(name_len)}{segment_file_ext}"
|
||||||
include_this_segment=False, include_map_data=not encryption_data or not encryption_data[1]
|
segment_data = try_ensure_utf8(segment_file_path.read_bytes())
|
||||||
|
if track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML):
|
||||||
|
segment_data = (
|
||||||
|
segment_data.decode("utf8")
|
||||||
|
.replace("‎", html.unescape("‎"))
|
||||||
|
.replace("‏", html.unescape("‏"))
|
||||||
|
.encode("utf8")
|
||||||
)
|
)
|
||||||
|
segment_file_path.write_bytes(segment_data)
|
||||||
|
|
||||||
discon_i += 1
|
if segment.discontinuity and i != 0:
|
||||||
range_offset = 0 # TODO: Should this be reset or not?
|
|
||||||
map_data = None
|
|
||||||
if encryption_data:
|
|
||||||
encryption_data = (encryption_data[0], encryption_data[1])
|
|
||||||
|
|
||||||
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
|
|
||||||
if segment.init_section.byterange:
|
|
||||||
init_byte_range = HLS.calculate_byte_range(segment.init_section.byterange, range_offset)
|
|
||||||
range_offset = init_byte_range.split("-")[0]
|
|
||||||
init_range_header = {"Range": f"bytes={init_byte_range}"}
|
|
||||||
else:
|
|
||||||
init_range_header = {}
|
|
||||||
|
|
||||||
# Handle both session types for init section request
|
|
||||||
res = session.get(
|
|
||||||
url=urljoin(segment.init_section.base_uri, segment.init_section.uri),
|
|
||||||
headers=init_range_header,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check response based on session type
|
|
||||||
if isinstance(res, requests.Response):
|
|
||||||
res.raise_for_status()
|
|
||||||
init_content = res.content
|
|
||||||
else:
|
|
||||||
raise TypeError(
|
|
||||||
f"Expected response to be requests.Response or curl_cffi.Response, not {type(res)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
map_data = (segment.init_section, init_content)
|
|
||||||
|
|
||||||
segment_keys = getattr(segment, "keys", None)
|
|
||||||
if segment_keys:
|
|
||||||
if cdm:
|
|
||||||
cdm_segment_keys = HLS.filter_keys_for_cdm(segment_keys, cdm)
|
|
||||||
key = HLS.get_supported_key(cdm_segment_keys) if cdm_segment_keys else HLS.get_supported_key(segment_keys)
|
|
||||||
else:
|
|
||||||
key = HLS.get_supported_key(segment_keys)
|
|
||||||
if encryption_data and encryption_data[0] != key and i != 0 and segment not in unwanted_segments:
|
|
||||||
decrypt(include_this_segment=False)
|
|
||||||
|
|
||||||
if key is None:
|
|
||||||
encryption_data = None
|
|
||||||
elif not encryption_data or encryption_data[0] != key:
|
|
||||||
drm = HLS.get_drm(key, session)
|
|
||||||
if isinstance(drm, (Widevine, PlayReady)):
|
|
||||||
try:
|
|
||||||
if map_data:
|
|
||||||
track_kid = track.get_key_id(map_data[1])
|
|
||||||
else:
|
|
||||||
track_kid = None
|
|
||||||
if not track_kid:
|
|
||||||
track_kid = drm.kid
|
|
||||||
progress(downloaded="LICENSING")
|
|
||||||
license_widevine(drm, track_kid=track_kid)
|
|
||||||
progress(downloaded="[yellow]LICENSED")
|
|
||||||
except Exception: # noqa
|
|
||||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
|
||||||
progress(downloaded="[red]FAILED")
|
|
||||||
raise
|
|
||||||
encryption_data = (key, drm)
|
|
||||||
|
|
||||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
|
||||||
continue
|
|
||||||
|
|
||||||
if is_last_segment:
|
|
||||||
# required as it won't end with EXT-X-DISCONTINUITY nor a new key
|
|
||||||
if encryption_data:
|
if encryption_data:
|
||||||
decrypt(include_this_segment=True)
|
decrypt(include_this_segment=False)
|
||||||
merge_discontinuity(
|
merge_discontinuity(
|
||||||
include_this_segment=True, include_map_data=not encryption_data or not encryption_data[1]
|
include_this_segment=False, include_map_data=not encryption_data or not encryption_data[1]
|
||||||
)
|
)
|
||||||
|
|
||||||
progress(advance=1)
|
discon_i += 1
|
||||||
|
range_offset = 0 # TODO: Should this be reset or not?
|
||||||
|
map_data = None
|
||||||
|
|
||||||
|
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
|
||||||
|
if segment.init_section.byterange:
|
||||||
|
init_byte_range = HLS.calculate_byte_range(segment.init_section.byterange, range_offset)
|
||||||
|
range_offset = int(init_byte_range.split("-")[0])
|
||||||
|
init_range_header = {"Range": f"bytes={init_byte_range}"}
|
||||||
|
else:
|
||||||
|
init_range_header = {}
|
||||||
|
|
||||||
|
# Handle both session types for init section request
|
||||||
|
res = session.get(
|
||||||
|
url=urljoin(segment.init_section.base_uri, segment.init_section.uri),
|
||||||
|
headers=init_range_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check response based on session type
|
||||||
|
if isinstance(res, requests.Response) or isinstance(res, CurlResponse):
|
||||||
|
res.raise_for_status()
|
||||||
|
init_content = res.content
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"Expected response to be requests.Response or curl_cffi.Response, not {type(res)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
map_data = (segment.init_section, init_content)
|
||||||
|
|
||||||
|
segment_keys = getattr(segment, "keys", None)
|
||||||
|
if segment_keys:
|
||||||
|
if cdm:
|
||||||
|
cdm_segment_keys = HLS.filter_keys_for_cdm(segment_keys, cdm)
|
||||||
|
key = (
|
||||||
|
HLS.get_supported_key(cdm_segment_keys)
|
||||||
|
if cdm_segment_keys
|
||||||
|
else HLS.get_supported_key(segment_keys)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key = HLS.get_supported_key(segment_keys)
|
||||||
|
if encryption_data and encryption_data[0] != key and i != 0 and segment not in unwanted_segments:
|
||||||
|
decrypt(include_this_segment=False)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
encryption_data = None
|
||||||
|
elif not encryption_data or encryption_data[0] != key:
|
||||||
|
drm = HLS.get_drm(key, session)
|
||||||
|
if isinstance(drm, (Widevine, PlayReady)):
|
||||||
|
try:
|
||||||
|
if map_data:
|
||||||
|
track_kid = track.get_key_id(map_data[1])
|
||||||
|
else:
|
||||||
|
track_kid = None
|
||||||
|
if not track_kid:
|
||||||
|
track_kid = drm.kid
|
||||||
|
progress(downloaded="LICENSING")
|
||||||
|
license_widevine(drm, track_kid=track_kid)
|
||||||
|
progress(downloaded="[yellow]LICENSED")
|
||||||
|
except Exception: # noqa
|
||||||
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||||
|
progress(downloaded="[red]FAILED")
|
||||||
|
raise
|
||||||
|
encryption_data = (key, drm)
|
||||||
|
|
||||||
|
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_last_segment:
|
||||||
|
# required as it won't end with EXT-X-DISCONTINUITY nor a new key
|
||||||
|
if encryption_data:
|
||||||
|
decrypt(include_this_segment=True)
|
||||||
|
merge_discontinuity(
|
||||||
|
include_this_segment=True, include_map_data=not encryption_data or not encryption_data[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
progress(advance=1)
|
||||||
|
|
||||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||||
return
|
return
|
||||||
@@ -865,15 +918,10 @@ class HLS:
|
|||||||
"""
|
"""
|
||||||
playready_urn = f"urn:uuid:{PR_PSSH.SYSTEM_ID}"
|
playready_urn = f"urn:uuid:{PR_PSSH.SYSTEM_ID}"
|
||||||
playready_keyformats = {playready_urn, "com.microsoft.playready"}
|
playready_keyformats = {playready_urn, "com.microsoft.playready"}
|
||||||
if isinstance(cdm, WidevineCdm):
|
if is_widevine_cdm(cdm):
|
||||||
return [k for k in keys if k.keyformat and k.keyformat.lower() == WidevineCdm.urn]
|
return [k for k in keys if k.keyformat and k.keyformat.lower() == WidevineCdm.urn]
|
||||||
elif isinstance(cdm, PlayReadyCdm):
|
elif is_playready_cdm(cdm):
|
||||||
return [k for k in keys if k.keyformat and k.keyformat.lower() in playready_keyformats]
|
return [k for k in keys if k.keyformat and k.keyformat.lower() in playready_keyformats]
|
||||||
elif hasattr(cdm, "is_playready"):
|
|
||||||
if cdm.is_playready:
|
|
||||||
return [k for k in keys if k.keyformat and k.keyformat.lower() in playready_keyformats]
|
|
||||||
else:
|
|
||||||
return [k for k in keys if k.keyformat and k.keyformat.lower() == WidevineCdm.urn]
|
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import atexit
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -750,7 +752,8 @@ class Gluetun(Proxy):
|
|||||||
|
|
||||||
# Debug log environment variables (redact sensitive values)
|
# Debug log environment variables (redact sensitive values)
|
||||||
if debug_logger:
|
if debug_logger:
|
||||||
safe_env = {k: ("***" if "KEY" in k or "PASSWORD" in k else v) for k, v in env_vars.items()}
|
redact_markers = ("KEY", "PASSWORD", "PASS", "TOKEN", "SECRET", "USER")
|
||||||
|
safe_env = {k: ("***" if any(m in k for m in redact_markers) else v) for k, v in env_vars.items()}
|
||||||
debug_logger.log(
|
debug_logger.log(
|
||||||
level="DEBUG",
|
level="DEBUG",
|
||||||
operation="gluetun_env_vars",
|
operation="gluetun_env_vars",
|
||||||
@@ -771,23 +774,62 @@ class Gluetun(Proxy):
|
|||||||
f"127.0.0.1:{port}:8888/tcp",
|
f"127.0.0.1:{port}:8888/tcp",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add environment variables
|
# Avoid exposing credentials in process listings by using --env-file instead of many "-e KEY=VALUE".
|
||||||
for key, value in env_vars.items():
|
env_file_path: str | None = None
|
||||||
cmd.extend(["-e", f"{key}={value}"])
|
|
||||||
|
|
||||||
# Add Gluetun image
|
|
||||||
cmd.append("qmcgaw/gluetun:latest")
|
|
||||||
|
|
||||||
# Execute docker run
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
fd, env_file_path = tempfile.mkstemp(prefix=f"unshackle-{container_name}-", suffix=".env")
|
||||||
cmd,
|
try:
|
||||||
capture_output=True,
|
# Best-effort restrictive permissions.
|
||||||
text=True,
|
if os.name != "nt":
|
||||||
timeout=30,
|
if hasattr(os, "fchmod"):
|
||||||
encoding="utf-8",
|
os.fchmod(fd, 0o600)
|
||||||
errors="replace",
|
else:
|
||||||
)
|
os.chmod(env_file_path, 0o600)
|
||||||
|
else:
|
||||||
|
os.chmod(env_file_path, stat.S_IREAD | stat.S_IWRITE)
|
||||||
|
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
||||||
|
for key, value in env_vars.items():
|
||||||
|
if "=" in key:
|
||||||
|
raise ValueError(f"Invalid env var name for docker env-file: {key!r}")
|
||||||
|
v = "" if value is None else str(value)
|
||||||
|
if "\n" in v or "\r" in v:
|
||||||
|
raise ValueError(f"Invalid env var value (contains newline) for {key!r}")
|
||||||
|
f.write(f"{key}={v}\n")
|
||||||
|
except Exception:
|
||||||
|
# If we fail before fdopen closes the descriptor, make sure it's not leaked.
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
cmd.extend(["--env-file", env_file_path])
|
||||||
|
|
||||||
|
# Add Gluetun image
|
||||||
|
cmd.append(gluetun_image)
|
||||||
|
|
||||||
|
# Execute docker run
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if debug_logger:
|
||||||
|
debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="gluetun_container_create_timeout",
|
||||||
|
message=f"Docker run timed out for {container_name}",
|
||||||
|
context={"container_name": container_name},
|
||||||
|
success=False,
|
||||||
|
duration_ms=(time.time() - start_time) * 1000,
|
||||||
|
)
|
||||||
|
raise RuntimeError("Docker run command timed out")
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
error_msg = result.stderr or "unknown error"
|
error_msg = result.stderr or "unknown error"
|
||||||
@@ -826,29 +868,51 @@ class Gluetun(Proxy):
|
|||||||
success=True,
|
success=True,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
except subprocess.TimeoutExpired:
|
if env_file_path:
|
||||||
if debug_logger:
|
# Best-effort "secure delete": overwrite then unlink (not guaranteed on all filesystems).
|
||||||
debug_logger.log(
|
try:
|
||||||
level="ERROR",
|
with open(env_file_path, "r+b") as f:
|
||||||
operation="gluetun_container_create_timeout",
|
try:
|
||||||
message=f"Docker run timed out for {container_name}",
|
f.seek(0, os.SEEK_END)
|
||||||
context={"container_name": container_name},
|
length = f.tell()
|
||||||
success=False,
|
f.seek(0)
|
||||||
duration_ms=(time.time() - start_time) * 1000,
|
if length > 0:
|
||||||
)
|
f.write(b"\x00" * length)
|
||||||
raise RuntimeError("Docker run command timed out")
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.remove(env_file_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _is_container_running(self, container_name: str) -> bool:
|
def _is_container_running(self, container_name: str) -> bool:
|
||||||
"""Check if a Docker container is running."""
|
"""Check if a Docker container is running."""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Names}}"],
|
[
|
||||||
|
"docker",
|
||||||
|
"ps",
|
||||||
|
"--filter",
|
||||||
|
f"name=^{re.escape(container_name)}$",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
return result.returncode == 0 and container_name in result.stdout
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
names = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
|
||||||
|
return any(name == container_name for name in names)
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1132,98 +1196,104 @@ class Gluetun(Proxy):
|
|||||||
|
|
||||||
# Create a session with the proxy configured
|
# Create a session with the proxy configured
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.proxies = {"http": proxy_url, "https": proxy_url}
|
try:
|
||||||
|
session.proxies = {"http": proxy_url, "https": proxy_url}
|
||||||
|
|
||||||
# Retry with exponential backoff
|
# Retry with exponential backoff
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
# Get external IP through the proxy using shared utility
|
# Get external IP through the proxy using shared utility
|
||||||
ip_info = get_ip_info(session)
|
ip_info = get_ip_info(session)
|
||||||
|
|
||||||
if ip_info:
|
if ip_info:
|
||||||
actual_country = ip_info.get("country", "").upper()
|
actual_country = ip_info.get("country", "").upper()
|
||||||
|
|
||||||
# Check if country matches (if we have an expected country)
|
# Check if country matches (if we have an expected country)
|
||||||
# ipinfo.io returns country codes (CA), but we may have full names (Canada)
|
# ipinfo.io returns country codes (CA), but we may have full names (Canada)
|
||||||
# Normalize both to country codes for comparison using shared utility
|
# Normalize both to country codes for comparison using shared utility
|
||||||
if expected_country:
|
if expected_country:
|
||||||
# Convert expected country name to code if it's a full name
|
# Convert expected country name to code if it's a full name
|
||||||
expected_code = get_country_code(expected_country) or expected_country
|
expected_code = get_country_code(expected_country) or expected_country
|
||||||
expected_code = expected_code.upper()
|
expected_code = expected_code.upper()
|
||||||
|
|
||||||
if actual_country != expected_code:
|
if actual_country != expected_code:
|
||||||
duration_ms = (time.time() - start_time) * 1000
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
if debug_logger:
|
if debug_logger:
|
||||||
debug_logger.log(
|
debug_logger.log(
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
operation="gluetun_verify_mismatch",
|
operation="gluetun_verify_mismatch",
|
||||||
message=f"Region mismatch for {query_key}",
|
message=f"Region mismatch for {query_key}",
|
||||||
context={
|
context={
|
||||||
"query_key": query_key,
|
"query_key": query_key,
|
||||||
"expected_country": expected_code,
|
"expected_country": expected_code,
|
||||||
"actual_country": actual_country,
|
"actual_country": actual_country,
|
||||||
"ip": ip_info.get("ip"),
|
"ip": ip_info.get("ip"),
|
||||||
"city": ip_info.get("city"),
|
"city": ip_info.get("city"),
|
||||||
"org": ip_info.get("org"),
|
"org": ip_info.get("org"),
|
||||||
},
|
},
|
||||||
success=False,
|
success=False,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
)
|
)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Region mismatch for {container['provider']}:{container['region']}: "
|
f"Region mismatch for {container['provider']}:{container['region']}: "
|
||||||
f"Expected '{expected_code}' but got '{actual_country}' "
|
f"Expected '{expected_code}' but got '{actual_country}' "
|
||||||
f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})"
|
f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verification successful - store IP info in container record
|
# Verification successful - store IP info in container record
|
||||||
if query_key in self.active_containers:
|
if query_key in self.active_containers:
|
||||||
self.active_containers[query_key]["public_ip"] = ip_info.get("ip")
|
self.active_containers[query_key]["public_ip"] = ip_info.get("ip")
|
||||||
self.active_containers[query_key]["ip_country"] = actual_country
|
self.active_containers[query_key]["ip_country"] = actual_country
|
||||||
self.active_containers[query_key]["ip_city"] = ip_info.get("city")
|
self.active_containers[query_key]["ip_city"] = ip_info.get("city")
|
||||||
self.active_containers[query_key]["ip_org"] = ip_info.get("org")
|
self.active_containers[query_key]["ip_org"] = ip_info.get("org")
|
||||||
|
|
||||||
duration_ms = (time.time() - start_time) * 1000
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
if debug_logger:
|
||||||
|
debug_logger.log(
|
||||||
|
level="INFO",
|
||||||
|
operation="gluetun_verify_success",
|
||||||
|
message=f"VPN IP verified for: {query_key}",
|
||||||
|
context={
|
||||||
|
"query_key": query_key,
|
||||||
|
"ip": ip_info.get("ip"),
|
||||||
|
"country": actual_country,
|
||||||
|
"city": ip_info.get("city"),
|
||||||
|
"org": ip_info.get("org"),
|
||||||
|
"attempts": attempt + 1,
|
||||||
|
},
|
||||||
|
success=True,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ip_info was None, retry
|
||||||
|
last_error = "Failed to get IP info from ipinfo.io"
|
||||||
|
|
||||||
|
except RuntimeError:
|
||||||
|
raise # Re-raise region mismatch errors immediately
|
||||||
|
except Exception as e:
|
||||||
|
last_error = str(e)
|
||||||
if debug_logger:
|
if debug_logger:
|
||||||
debug_logger.log(
|
debug_logger.log(
|
||||||
level="INFO",
|
level="DEBUG",
|
||||||
operation="gluetun_verify_success",
|
operation="gluetun_verify_retry",
|
||||||
message=f"VPN IP verified for: {query_key}",
|
message=f"Verification attempt {attempt + 1} failed, retrying",
|
||||||
context={
|
context={
|
||||||
"query_key": query_key,
|
"query_key": query_key,
|
||||||
"ip": ip_info.get("ip"),
|
"attempt": attempt + 1,
|
||||||
"country": actual_country,
|
"error": last_error,
|
||||||
"city": ip_info.get("city"),
|
|
||||||
"org": ip_info.get("org"),
|
|
||||||
"attempts": attempt + 1,
|
|
||||||
},
|
},
|
||||||
success=True,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# ip_info was None, retry
|
# Wait before retry (exponential backoff)
|
||||||
last_error = "Failed to get IP info from ipinfo.io"
|
if attempt < max_retries - 1:
|
||||||
|
wait_time = 2**attempt # 1, 2, 4 seconds
|
||||||
except RuntimeError:
|
time.sleep(wait_time)
|
||||||
raise # Re-raise region mismatch errors immediately
|
finally:
|
||||||
except Exception as e:
|
try:
|
||||||
last_error = str(e)
|
session.close()
|
||||||
if debug_logger:
|
except Exception:
|
||||||
debug_logger.log(
|
pass
|
||||||
level="DEBUG",
|
|
||||||
operation="gluetun_verify_retry",
|
|
||||||
message=f"Verification attempt {attempt + 1} failed, retrying",
|
|
||||||
context={
|
|
||||||
"query_key": query_key,
|
|
||||||
"attempt": attempt + 1,
|
|
||||||
"error": last_error,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait before retry (exponential backoff)
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
wait_time = 2**attempt # 1, 2, 4 seconds
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
# All retries exhausted
|
# All retries exhausted
|
||||||
duration_ms = (time.time() - start_time) * 1000
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
|||||||
@@ -142,12 +142,17 @@ class SurfsharkVPN(Proxy):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get connection names from filtered servers
|
# Get connection names from filtered servers
|
||||||
connection_names = [x["connectionName"] for x in servers]
|
if not servers:
|
||||||
|
raise ValueError(f"Could not get random server for country '{country_id}': no servers found.")
|
||||||
|
|
||||||
try:
|
# Only include servers that actually have a connection name to avoid KeyError.
|
||||||
return random.choice(connection_names)
|
connection_names = [x["connectionName"] for x in servers if "connectionName" in x]
|
||||||
except (IndexError, KeyError):
|
if not connection_names:
|
||||||
raise ValueError(f"Could not get random server for country '{country_id}'.")
|
raise ValueError(
|
||||||
|
f"Could not get random server for country '{country_id}': no servers with connectionName found."
|
||||||
|
)
|
||||||
|
|
||||||
|
return random.choice(connection_names)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_countries() -> list[dict]:
|
def get_countries() -> list[dict]:
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class WindscribeVPN(Proxy):
|
|||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
- Country code: "us", "ca", "gb"
|
- Country code: "us", "ca", "gb"
|
||||||
|
- Specific server: "sg007", "us150"
|
||||||
- City selection: "us:seattle", "ca:toronto"
|
- City selection: "us:seattle", "ca:toronto"
|
||||||
"""
|
"""
|
||||||
query = query.lower()
|
query = query.lower()
|
||||||
@@ -61,10 +62,20 @@ class WindscribeVPN(Proxy):
|
|||||||
server_map_key = f"{query}:{city}" if city else query
|
server_map_key = f"{query}:{city}" if city else query
|
||||||
if server_map_key in self.server_map:
|
if server_map_key in self.server_map:
|
||||||
hostname = self.server_map[server_map_key]
|
hostname = self.server_map[server_map_key]
|
||||||
elif query in self.server_map and not city:
|
elif query in self.server_map:
|
||||||
hostname = self.server_map[query]
|
hostname = self.server_map[query]
|
||||||
else:
|
else:
|
||||||
if re.match(r"^[a-z]+$", query):
|
server_match = re.match(r"^([a-z]{2})(\d+)$", query)
|
||||||
|
if server_match:
|
||||||
|
# Specific server selection, e.g., sg007, us150
|
||||||
|
country_code, server_num = server_match.groups()
|
||||||
|
hostname = self.get_specific_server(country_code, server_num)
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError(
|
||||||
|
f"No WindscribeVPN server found matching '{query}'. "
|
||||||
|
f"Check the server number or use just '{country_code}' for a random server."
|
||||||
|
)
|
||||||
|
elif re.match(r"^[a-z]+$", query):
|
||||||
hostname = self.get_random_server(query, city)
|
hostname = self.get_random_server(query, city)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"The query provided is unsupported and unrecognized: {query}")
|
raise ValueError(f"The query provided is unsupported and unrecognized: {query}")
|
||||||
@@ -75,6 +86,38 @@ class WindscribeVPN(Proxy):
|
|||||||
hostname = hostname.split(':')[0]
|
hostname = hostname.split(':')[0]
|
||||||
return f"https://{self.username}:{self.password}@{hostname}:443"
|
return f"https://{self.username}:{self.password}@{hostname}:443"
|
||||||
|
|
||||||
|
def get_specific_server(self, country_code: str, server_num: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Find a specific server by country code and server number.
|
||||||
|
|
||||||
|
Matches against hostnames like "sg-007.totallyacdn.com" for query "sg007".
|
||||||
|
Tries both the raw number and zero-padded variants.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
country_code: Two-letter country code (e.g., "sg", "us")
|
||||||
|
server_num: Server number as string (e.g., "007", "7", "150")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The matching hostname, or None if not found.
|
||||||
|
"""
|
||||||
|
num_stripped = server_num.lstrip("0") or "0"
|
||||||
|
candidates = {
|
||||||
|
f"{country_code}-{server_num}.",
|
||||||
|
f"{country_code}-{num_stripped}.",
|
||||||
|
f"{country_code}-{server_num.zfill(3)}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
for location in self.countries:
|
||||||
|
if location.get("country_code", "").lower() != country_code:
|
||||||
|
continue
|
||||||
|
for group in location.get("groups", []):
|
||||||
|
for host in group.get("hosts", []):
|
||||||
|
hostname = host.get("hostname", "")
|
||||||
|
if any(hostname.startswith(prefix) for prefix in candidates):
|
||||||
|
return hostname
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def get_random_server(self, country_code: str, city: Optional[str] = None) -> Optional[str]:
|
def get_random_server(self, country_code: str, city: Optional[str] = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Get a random server hostname for a country, optionally filtered by city.
|
Get a random server hostname for a country, optionally filtered by city.
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
from collections.abc import Generator
|
from collections.abc import Callable, Generator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from http.cookiejar import CookieJar
|
from http.cookiejar import CookieJar
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import m3u8
|
import m3u8
|
||||||
@@ -24,9 +25,65 @@ from unshackle.core.search_result import SearchResult
|
|||||||
from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy
|
from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy
|
||||||
from unshackle.core.titles import Title_T, Titles_T
|
from unshackle.core.titles import Title_T, Titles_T
|
||||||
from unshackle.core.tracks import Chapters, Tracks
|
from unshackle.core.tracks import Chapters, Tracks
|
||||||
|
from unshackle.core.tracks.video import Video
|
||||||
from unshackle.core.utilities import get_cached_ip_info, get_ip_info
|
from unshackle.core.utilities import get_cached_ip_info, get_ip_info
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackRequest:
|
||||||
|
"""Holds what the user requested for video codec and range selection.
|
||||||
|
|
||||||
|
Services read from this instead of ctx.parent.params for vcodec/range.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
codecs: Requested codecs from CLI. Empty list means no filter (accept any).
|
||||||
|
ranges: Requested ranges from CLI. Defaults to [SDR].
|
||||||
|
"""
|
||||||
|
|
||||||
|
codecs: list[Video.Codec] = field(default_factory=list)
|
||||||
|
ranges: list[Video.Range] = field(default_factory=lambda: [Video.Range.SDR])
|
||||||
|
best_available: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- http://user:pass@host:8080 -> http://REDACTED@host:8080
|
||||||
|
- socks5h://user@host:1080 -> socks5h://REDACTED@host:1080
|
||||||
|
"""
|
||||||
|
if uri is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(uri, str):
|
||||||
|
return str(uri)
|
||||||
|
if not uri:
|
||||||
|
return uri
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
|
||||||
|
# Handle schemeless proxies like "user:pass@host:port"
|
||||||
|
if not parsed.scheme and not parsed.netloc and "@" in uri and "://" not in uri:
|
||||||
|
# Parse as netloc using a dummy scheme, then strip scheme back out.
|
||||||
|
dummy = urlparse(f"http://{uri}")
|
||||||
|
netloc = dummy.netloc
|
||||||
|
if "@" in netloc:
|
||||||
|
netloc = f"REDACTED@{netloc.split('@', 1)[1]}"
|
||||||
|
# urlparse("http://...") sets path to "" for typical netloc-only strings; keep it just in case.
|
||||||
|
return f"{netloc}{dummy.path}"
|
||||||
|
|
||||||
|
netloc = parsed.netloc
|
||||||
|
if "@" in netloc:
|
||||||
|
netloc = f"REDACTED@{netloc.split('@', 1)[1]}"
|
||||||
|
|
||||||
|
return urlunparse(parsed._replace(netloc=netloc))
|
||||||
|
except Exception:
|
||||||
|
if "@" in uri:
|
||||||
|
return f"REDACTED@{uri.split('@', 1)[1]}"
|
||||||
|
return uri
|
||||||
|
|
||||||
|
|
||||||
class Service(metaclass=ABCMeta):
|
class Service(metaclass=ABCMeta):
|
||||||
"""The Service Base Class."""
|
"""The Service Base Class."""
|
||||||
|
|
||||||
@@ -50,6 +107,16 @@ class Service(metaclass=ABCMeta):
|
|||||||
self.credential = None # Will be set in authenticate()
|
self.credential = None # Will be set in authenticate()
|
||||||
self.current_region = None # Will be set based on proxy/geolocation
|
self.current_region = None # Will be set based on proxy/geolocation
|
||||||
|
|
||||||
|
# Set track request from CLI params - services can read/override in their __init__
|
||||||
|
vcodec = ctx.parent.params.get("vcodec") if ctx.parent else None
|
||||||
|
range_ = ctx.parent.params.get("range_") if ctx.parent else None
|
||||||
|
best_available = ctx.parent.params.get("best_available", False) if ctx.parent else False
|
||||||
|
self.track_request = TrackRequest(
|
||||||
|
codecs=list(vcodec) if vcodec else [],
|
||||||
|
ranges=list(range_) if range_ else [Video.Range.SDR],
|
||||||
|
best_available=bool(best_available),
|
||||||
|
)
|
||||||
|
|
||||||
if not ctx.parent or not ctx.parent.params.get("no_proxy"):
|
if not ctx.parent or not ctx.parent.params.get("no_proxy"):
|
||||||
if ctx.parent:
|
if ctx.parent:
|
||||||
proxy = ctx.parent.params["proxy"]
|
proxy = ctx.parent.params["proxy"]
|
||||||
@@ -75,7 +142,9 @@ class Service(metaclass=ABCMeta):
|
|||||||
# Check if there's a mapping for this query
|
# Check if there's a mapping for this query
|
||||||
mapped_value = proxy_map.get(full_proxy_key)
|
mapped_value = proxy_map.get(full_proxy_key)
|
||||||
if mapped_value:
|
if mapped_value:
|
||||||
self.log.info(f"Found service-specific proxy mapping: {full_proxy_key} -> {mapped_value}")
|
self.log.info(
|
||||||
|
f"Found service-specific proxy mapping: {full_proxy_key} -> {sanitize_proxy_for_log(mapped_value)}"
|
||||||
|
)
|
||||||
# Query the proxy provider with the mapped value
|
# Query the proxy provider with the mapped value
|
||||||
if proxy_provider_name:
|
if proxy_provider_name:
|
||||||
# Specific provider requested
|
# Specific provider requested
|
||||||
@@ -87,9 +156,13 @@ class Service(metaclass=ABCMeta):
|
|||||||
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
|
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
|
||||||
if mapped_proxy_uri:
|
if mapped_proxy_uri:
|
||||||
proxy = mapped_proxy_uri
|
proxy = mapped_proxy_uri
|
||||||
self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}")
|
self.log.info(
|
||||||
|
f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.log.warning(f"Failed to get proxy for mapped value '{mapped_value}', using default")
|
self.log.warning(
|
||||||
|
f"Failed to get proxy for mapped value '{sanitize_proxy_for_log(mapped_value)}', using default"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy")
|
self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy")
|
||||||
else:
|
else:
|
||||||
@@ -98,10 +171,14 @@ class Service(metaclass=ABCMeta):
|
|||||||
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
|
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
|
||||||
if mapped_proxy_uri:
|
if mapped_proxy_uri:
|
||||||
proxy = mapped_proxy_uri
|
proxy = mapped_proxy_uri
|
||||||
self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}")
|
self.log.info(
|
||||||
|
f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
self.log.warning(f"No provider could resolve mapped value '{mapped_value}', using default")
|
self.log.warning(
|
||||||
|
f"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default"
|
||||||
|
)
|
||||||
|
|
||||||
if not proxy:
|
if not proxy:
|
||||||
# don't override the explicit proxy set by the user, even if they may be geoblocked
|
# don't override the explicit proxy set by the user, even if they may be geoblocked
|
||||||
@@ -156,6 +233,76 @@ class Service(metaclass=ABCMeta):
|
|||||||
self.log.debug(f"Failed to get cached IP info: {e}")
|
self.log.debug(f"Failed to get cached IP info: {e}")
|
||||||
self.current_region = None
|
self.current_region = None
|
||||||
|
|
||||||
|
def _get_tracks_for_variants(
|
||||||
|
self,
|
||||||
|
title: Title_T,
|
||||||
|
fetch_fn: Callable[..., Tracks],
|
||||||
|
) -> Tracks:
|
||||||
|
"""Call fetch_fn for each codec/range combo in track_request, merge results.
|
||||||
|
|
||||||
|
Services that need separate API calls per codec/range combo can use this
|
||||||
|
helper from their get_tracks() implementation.
|
||||||
|
|
||||||
|
The fetch_fn signature should be: (title, codec, range_) -> Tracks
|
||||||
|
|
||||||
|
For HYBRID range, fetch_fn is called with HDR10 and DV separately and
|
||||||
|
the DV video tracks are merged into the HDR10 result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: The title being processed.
|
||||||
|
fetch_fn: A callable that fetches tracks for a specific codec/range.
|
||||||
|
"""
|
||||||
|
all_tracks = Tracks()
|
||||||
|
first = True
|
||||||
|
|
||||||
|
codecs = self.track_request.codecs or [None]
|
||||||
|
ranges = self.track_request.ranges or [Video.Range.SDR]
|
||||||
|
|
||||||
|
for range_val in ranges:
|
||||||
|
if range_val == Video.Range.HYBRID:
|
||||||
|
# HYBRID: fetch HDR10 first (full tracks), then DV (video only)
|
||||||
|
for codec_val in codecs:
|
||||||
|
try:
|
||||||
|
hdr_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10)
|
||||||
|
except (ValueError, SystemExit) as e:
|
||||||
|
if self.track_request.best_available:
|
||||||
|
self.log.warning(f" - HDR10 not available for HYBRID, skipping ({e})")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
if first:
|
||||||
|
all_tracks.add(hdr_tracks, warn_only=True)
|
||||||
|
first = False
|
||||||
|
else:
|
||||||
|
for video in hdr_tracks.videos:
|
||||||
|
all_tracks.add(video, warn_only=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV)
|
||||||
|
for video in dv_tracks.videos:
|
||||||
|
all_tracks.add(video, warn_only=True)
|
||||||
|
except (ValueError, SystemExit):
|
||||||
|
self.log.info(" - No DolbyVision manifest available for HYBRID")
|
||||||
|
else:
|
||||||
|
for codec_val in codecs:
|
||||||
|
try:
|
||||||
|
tracks = fetch_fn(title, codec=codec_val, range_=range_val)
|
||||||
|
except (ValueError, SystemExit) as e:
|
||||||
|
if self.track_request.best_available:
|
||||||
|
codec_name = codec_val.name if codec_val else "default"
|
||||||
|
self.log.warning(
|
||||||
|
f" - {range_val.name}/{codec_name} not available, skipping ({e})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
if first:
|
||||||
|
all_tracks.add(tracks, warn_only=True)
|
||||||
|
first = False
|
||||||
|
else:
|
||||||
|
for video in tracks.videos:
|
||||||
|
all_tracks.add(video, warn_only=True)
|
||||||
|
|
||||||
|
return all_tracks
|
||||||
|
|
||||||
# Optional Abstract functions
|
# Optional Abstract functions
|
||||||
# The following functions may be implemented by the Service.
|
# The following functions may be implemented by the Service.
|
||||||
# Otherwise, the base service code (if any) of the function will be executed on call.
|
# Otherwise, the base service code (if any) of the function will be executed on call.
|
||||||
@@ -173,7 +320,7 @@ class Service(metaclass=ABCMeta):
|
|||||||
session.mount(
|
session.mount(
|
||||||
"https://",
|
"https://",
|
||||||
HTTPAdapter(
|
HTTPAdapter(
|
||||||
max_retries=Retry(total=15, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
|
max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
|
||||||
pool_block=True,
|
pool_block=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -412,4 +559,4 @@ class Service(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("Service",)
|
__all__ = ("Service", "TrackRequest")
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class MaxRetriesError(exceptions.RequestException):
|
|||||||
class CurlSession(Session):
|
class CurlSession(Session):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
max_retries: int = 10,
|
max_retries: int = 5,
|
||||||
backoff_factor: float = 0.2,
|
backoff_factor: float = 0.2,
|
||||||
max_backoff: float = 60.0,
|
max_backoff: float = 60.0,
|
||||||
status_forcelist: list[int] | None = None,
|
status_forcelist: list[int] | None = None,
|
||||||
@@ -150,7 +150,7 @@ def session(
|
|||||||
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
|
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
|
||||||
fingerprint preset name (e.g. "okhttp4").
|
fingerprint preset name (e.g. "okhttp4").
|
||||||
Uses the configured default from curl_impersonate.browser if not specified.
|
Uses the configured default from curl_impersonate.browser if not specified.
|
||||||
Available presets: okhttp4
|
Available presets: okhttp4, okhttp5
|
||||||
See https://github.com/lexiforest/curl_cffi#sessions for browser options.
|
See https://github.com/lexiforest/curl_cffi#sessions for browser options.
|
||||||
ja3: Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats").
|
ja3: Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats").
|
||||||
When provided, curl_cffi will use this exact TLS fingerprint instead of the browser's default.
|
When provided, curl_cffi will use this exact TLS fingerprint instead of the browser's default.
|
||||||
@@ -172,7 +172,7 @@ def session(
|
|||||||
- cert: Client certificate (str or tuple)
|
- cert: Client certificate (str or tuple)
|
||||||
|
|
||||||
Extra arguments for retry handler:
|
Extra arguments for retry handler:
|
||||||
- max_retries: Maximum number of retries (int, default 10)
|
- max_retries: Maximum number of retries (int, default 5)
|
||||||
- backoff_factor: Backoff factor (float, default 0.2)
|
- backoff_factor: Backoff factor (float, default 0.2)
|
||||||
- max_backoff: Maximum backoff time (float, default 60.0)
|
- max_backoff: Maximum backoff time (float, default 60.0)
|
||||||
- status_forcelist: List of status codes to force retry (list, default [429, 500, 502, 503, 504])
|
- status_forcelist: List of status codes to force retry (list, default [429, 500, 502, 503, 504])
|
||||||
|
|||||||
@@ -286,4 +286,4 @@ class Series(SortedKeyList, ABC):
|
|||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("Episode", "Series")
|
__all__ = ("Episode", "Series")
|
||||||
@@ -67,109 +67,122 @@ class Movie(Title):
|
|||||||
primary_audio_track = sorted_audio[0]
|
primary_audio_track = sorted_audio[0]
|
||||||
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
||||||
|
|
||||||
|
def _get_resolution_token(track: Any) -> str:
|
||||||
|
if not track or not getattr(track, "height", None):
|
||||||
|
return ""
|
||||||
|
resolution = track.height
|
||||||
|
try:
|
||||||
|
dar = getattr(track, "other_display_aspect_ratio", None) or []
|
||||||
|
if dar and dar[0]:
|
||||||
|
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
|
||||||
|
if len(aspect_ratio) == 1:
|
||||||
|
aspect_ratio.append(1)
|
||||||
|
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
||||||
|
resolution = int(track.width * (9 / 16))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
scan_suffix = "p"
|
||||||
|
scan_type = getattr(track, "scan_type", None)
|
||||||
|
if scan_type and str(scan_type).lower() == "interlaced":
|
||||||
|
scan_suffix = "i"
|
||||||
|
return f"{resolution}{scan_suffix}"
|
||||||
|
|
||||||
# Name (Year)
|
# Name (Year)
|
||||||
name = str(self).replace("$", "S") # e.g., Arli$$
|
name = str(self).replace("$", "S") # e.g., Arli$$
|
||||||
|
|
||||||
if config.scene_naming:
|
if getattr(config, "repack", False):
|
||||||
# Resolution
|
name += " REPACK"
|
||||||
if primary_video_track:
|
|
||||||
resolution = primary_video_track.height
|
|
||||||
aspect_ratio = [
|
|
||||||
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
|
|
||||||
]
|
|
||||||
if len(aspect_ratio) == 1:
|
|
||||||
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
|
||||||
aspect_ratio.append(1)
|
|
||||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
|
||||||
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
|
||||||
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
|
||||||
# otherwise the track's height value is fine.
|
|
||||||
# We are assuming this title is some weird aspect ratio so most
|
|
||||||
# likely a movie or HD source, so it's most likely widescreen so
|
|
||||||
# 16:9 canvas makes the most sense.
|
|
||||||
resolution = int(primary_video_track.width * (9 / 16))
|
|
||||||
# Determine scan type suffix - default to "p", use "i" only if explicitly interlaced
|
|
||||||
scan_suffix = "p"
|
|
||||||
scan_type = getattr(primary_video_track, 'scan_type', None)
|
|
||||||
if scan_type and str(scan_type).lower() == "interlaced":
|
|
||||||
scan_suffix = "i"
|
|
||||||
name += f" {resolution}{scan_suffix}"
|
|
||||||
|
|
||||||
# Service (use track source if available)
|
if primary_video_track:
|
||||||
if show_service:
|
resolution_token = _get_resolution_token(primary_video_track)
|
||||||
source_name = None
|
if resolution_token:
|
||||||
if self.tracks:
|
name += f" {resolution_token}"
|
||||||
first_track = next(iter(self.tracks), None)
|
|
||||||
if first_track and hasattr(first_track, "source") and first_track.source:
|
|
||||||
source_name = first_track.source
|
|
||||||
name += f" {source_name or self.service.__name__}"
|
|
||||||
|
|
||||||
# 'WEB-DL'
|
# Service (use track source if available)
|
||||||
name += " WEB-DL"
|
if show_service:
|
||||||
|
source_name = None
|
||||||
|
if self.tracks:
|
||||||
|
first_track = next(iter(self.tracks), None)
|
||||||
|
if first_track and hasattr(first_track, "source") and first_track.source:
|
||||||
|
source_name = first_track.source
|
||||||
|
name += f" {source_name or self.service.__name__}"
|
||||||
|
|
||||||
# DUAL
|
# 'WEB-DL'
|
||||||
if unique_audio_languages == 2:
|
name += " WEB-DL"
|
||||||
name += " DUAL"
|
|
||||||
|
|
||||||
# MULTi
|
# DUAL
|
||||||
if unique_audio_languages > 2:
|
if unique_audio_languages == 2:
|
||||||
name += " MULTi"
|
name += " DUAL"
|
||||||
|
|
||||||
# Audio Codec + Channels (+ feature)
|
# MULTi
|
||||||
if primary_audio_track:
|
if unique_audio_languages > 2:
|
||||||
codec = primary_audio_track.format
|
name += " MULTi"
|
||||||
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
|
|
||||||
if channel_layout:
|
|
||||||
channels = float(
|
|
||||||
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
|
|
||||||
channels = float(channel_count)
|
|
||||||
|
|
||||||
features = primary_audio_track.format_additionalfeatures or ""
|
# Audio Codec + Channels (+ feature)
|
||||||
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
if primary_audio_track:
|
||||||
if "JOC" in features or primary_audio_track.joc:
|
codec = primary_audio_track.format
|
||||||
name += " Atmos"
|
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
|
||||||
|
if channel_layout:
|
||||||
# Video (dynamic range + hfr +) Codec
|
channels = float(
|
||||||
if primary_video_track:
|
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
|
||||||
codec = primary_video_track.format
|
|
||||||
hdr_format = primary_video_track.hdr_format_commercial
|
|
||||||
hdr_format_full = primary_video_track.hdr_format or ""
|
|
||||||
trc = (
|
|
||||||
primary_video_track.transfer_characteristics
|
|
||||||
or primary_video_track.transfer_characteristics_original
|
|
||||||
or ""
|
|
||||||
)
|
)
|
||||||
frame_rate = float(primary_video_track.frame_rate)
|
else:
|
||||||
|
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
|
||||||
|
channels = float(channel_count)
|
||||||
|
|
||||||
# Primary HDR format detection
|
features = primary_audio_track.format_additionalfeatures or ""
|
||||||
if hdr_format:
|
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
||||||
if hdr_format_full.startswith("Dolby Vision"):
|
if "JOC" in features or primary_audio_track.joc:
|
||||||
name += " DV"
|
name += " Atmos"
|
||||||
if any(
|
|
||||||
indicator in (hdr_format_full + " " + hdr_format)
|
|
||||||
for indicator in ["HDR10", "SMPTE ST 2086"]
|
|
||||||
):
|
|
||||||
name += " HDR"
|
|
||||||
else:
|
|
||||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
|
||||||
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
|
|
||||||
name += " HLG"
|
|
||||||
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
|
|
||||||
name += " HDR"
|
|
||||||
if frame_rate > 30:
|
|
||||||
name += " HFR"
|
|
||||||
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
|
||||||
|
|
||||||
if config.tag:
|
# Video (dynamic range + hfr +) Codec
|
||||||
name += f"-{config.tag}"
|
if primary_video_track:
|
||||||
|
codec = primary_video_track.format
|
||||||
|
hdr_format = primary_video_track.hdr_format_commercial
|
||||||
|
hdr_format_full = primary_video_track.hdr_format or ""
|
||||||
|
trc = (
|
||||||
|
primary_video_track.transfer_characteristics
|
||||||
|
or primary_video_track.transfer_characteristics_original
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
frame_rate = float(primary_video_track.frame_rate)
|
||||||
|
|
||||||
return sanitize_filename(name)
|
def _append_token(current: str, token: Optional[str]) -> str:
|
||||||
else:
|
token = (token or "").strip()
|
||||||
# Simple naming style without technical details - use spaces instead of dots
|
current = current.rstrip()
|
||||||
return sanitize_filename(name, " ")
|
if not token:
|
||||||
|
return current
|
||||||
|
if current.endswith(f" {token}"):
|
||||||
|
return current
|
||||||
|
return f"{current} {token}"
|
||||||
|
|
||||||
|
# Primary HDR format detection
|
||||||
|
if hdr_format:
|
||||||
|
if hdr_format_full.startswith("Dolby Vision"):
|
||||||
|
name = _append_token(name, "DV")
|
||||||
|
if any(
|
||||||
|
indicator in (hdr_format_full + " " + hdr_format)
|
||||||
|
for indicator in ["HDR10", "SMPTE ST 2086"]
|
||||||
|
):
|
||||||
|
name = _append_token(name, "HDR")
|
||||||
|
elif "HDR Vivid" in hdr_format:
|
||||||
|
name = _append_token(name, "HDR")
|
||||||
|
else:
|
||||||
|
dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or ""
|
||||||
|
name = _append_token(name, dynamic_range)
|
||||||
|
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
|
||||||
|
name += " HLG"
|
||||||
|
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
|
||||||
|
name += " HDR"
|
||||||
|
if frame_rate > 30:
|
||||||
|
name += " HFR"
|
||||||
|
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
||||||
|
|
||||||
|
if config.tag:
|
||||||
|
name += f"-{config.tag}"
|
||||||
|
|
||||||
|
return sanitize_filename(name, "." if config.scene_naming else " ")
|
||||||
|
|
||||||
|
|
||||||
class Movies(SortedKeyList, ABC):
|
class Movies(SortedKeyList, ABC):
|
||||||
|
|||||||
@@ -100,31 +100,30 @@ class Song(Title):
|
|||||||
# NN. Song Name
|
# NN. Song Name
|
||||||
name = str(self).split(" / ")[1]
|
name = str(self).split(" / ")[1]
|
||||||
|
|
||||||
if config.scene_naming:
|
if getattr(config, "repack", False):
|
||||||
# Service (use track source if available)
|
name += " REPACK"
|
||||||
if show_service:
|
|
||||||
source_name = None
|
|
||||||
if self.tracks:
|
|
||||||
first_track = next(iter(self.tracks), None)
|
|
||||||
if first_track and hasattr(first_track, "source") and first_track.source:
|
|
||||||
source_name = first_track.source
|
|
||||||
name += f" {source_name or self.service.__name__}"
|
|
||||||
|
|
||||||
# 'WEB-DL'
|
# Service (use track source if available)
|
||||||
name += " WEB-DL"
|
if show_service:
|
||||||
|
source_name = None
|
||||||
|
if self.tracks:
|
||||||
|
first_track = next(iter(self.tracks), None)
|
||||||
|
if first_track and hasattr(first_track, "source") and first_track.source:
|
||||||
|
source_name = first_track.source
|
||||||
|
name += f" {source_name or self.service.__name__}"
|
||||||
|
|
||||||
# Audio Codec + Channels (+ feature)
|
# 'WEB-DL'
|
||||||
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
name += " WEB-DL"
|
||||||
if "JOC" in features or audio_track.joc:
|
|
||||||
name += " Atmos"
|
|
||||||
|
|
||||||
if config.tag:
|
# Audio Codec + Channels (+ feature)
|
||||||
name += f"-{config.tag}"
|
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
||||||
|
if "JOC" in features or audio_track.joc:
|
||||||
|
name += " Atmos"
|
||||||
|
|
||||||
return sanitize_filename(name, " ")
|
if config.tag:
|
||||||
else:
|
name += f"-{config.tag}"
|
||||||
# Simple naming style without technical details
|
|
||||||
return sanitize_filename(name, " ")
|
return sanitize_filename(name, " ")
|
||||||
|
|
||||||
|
|
||||||
class Album(SortedKeyList, ABC):
|
class Album(SortedKeyList, ABC):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -10,6 +11,7 @@ from zlib import crc32
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
|
from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY
|
||||||
|
|
||||||
|
|
||||||
class Attachment:
|
class Attachment:
|
||||||
@@ -43,6 +45,8 @@ class Attachment:
|
|||||||
if path is None and url is None:
|
if path is None and url is None:
|
||||||
raise ValueError("Either path or url must be provided.")
|
raise ValueError("Either path or url must be provided.")
|
||||||
|
|
||||||
|
self.url = url
|
||||||
|
|
||||||
if url:
|
if url:
|
||||||
if not isinstance(url, str):
|
if not isinstance(url, str):
|
||||||
raise ValueError("The attachment URL must be a string.")
|
raise ValueError("The attachment URL must be a string.")
|
||||||
@@ -53,45 +57,60 @@ class Attachment:
|
|||||||
|
|
||||||
# Use provided name for the file if available
|
# Use provided name for the file if available
|
||||||
if name:
|
if name:
|
||||||
file_name = f"{name.replace(' ', '_')}{os.path.splitext(file_name)[1]}"
|
safe_name = re.sub(r'[<>:"/\\|?*]', "", name).replace(" ", "_")
|
||||||
|
file_name = f"{safe_name}{os.path.splitext(file_name)[1]}"
|
||||||
|
|
||||||
download_path = config.directories.temp / file_name
|
download_path = config.directories.temp / file_name
|
||||||
|
|
||||||
# Download the file
|
# Download the file unless we're in license-only mode
|
||||||
try:
|
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||||
session = session or requests.Session()
|
path = None
|
||||||
response = session.get(url, stream=True)
|
else:
|
||||||
response.raise_for_status()
|
try:
|
||||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
if session is None:
|
||||||
download_path.parent.mkdir(parents=True, exist_ok=True)
|
with requests.Session() as session:
|
||||||
|
response = session.get(url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
else:
|
||||||
|
response = session.get(url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||||
|
download_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with open(download_path, "wb") as f:
|
with open(download_path, "wb") as f:
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
path = download_path
|
path = download_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Failed to download attachment from URL: {e}")
|
raise ValueError(f"Failed to download attachment from URL: {e}")
|
||||||
|
|
||||||
if not isinstance(path, (str, Path)):
|
if path is not None and not isinstance(path, (str, Path)):
|
||||||
raise ValueError("The attachment path must be provided.")
|
raise ValueError(
|
||||||
|
f"Invalid attachment path type: expected str or Path, got {type(path).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
path = Path(path)
|
if path is not None:
|
||||||
if not path.exists():
|
path = Path(path)
|
||||||
raise ValueError("The attachment file does not exist.")
|
if not path.exists():
|
||||||
|
raise ValueError("The attachment file does not exist.")
|
||||||
|
|
||||||
name = (name or path.stem).strip()
|
if path is not None:
|
||||||
|
name = (name or path.stem).strip()
|
||||||
|
else:
|
||||||
|
name = (name or Path(file_name).stem).strip()
|
||||||
mime_type = (mime_type or "").strip() or None
|
mime_type = (mime_type or "").strip() or None
|
||||||
description = (description or "").strip() or None
|
description = (description or "").strip() or None
|
||||||
|
|
||||||
if not mime_type:
|
if not mime_type:
|
||||||
|
suffix = path.suffix.lower() if path is not None else Path(file_name).suffix.lower()
|
||||||
mime_type = {
|
mime_type = {
|
||||||
".ttf": "application/x-truetype-font",
|
".ttf": "application/x-truetype-font",
|
||||||
".otf": "application/vnd.ms-opentype",
|
".otf": "application/vnd.ms-opentype",
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
}.get(path.suffix.lower(), mimetypes.guess_type(path)[0])
|
}.get(suffix, mimetypes.guess_type(file_name if path is None else path)[0])
|
||||||
if not mime_type:
|
if not mime_type:
|
||||||
raise ValueError("The attachment mime-type could not be automatically detected.")
|
raise ValueError("The attachment mime-type could not be automatically detected.")
|
||||||
|
|
||||||
@@ -111,13 +130,18 @@ class Attachment:
|
|||||||
@property
|
@property
|
||||||
def id(self) -> str:
|
def id(self) -> str:
|
||||||
"""Compute an ID from the attachment data."""
|
"""Compute an ID from the attachment data."""
|
||||||
checksum = crc32(self.path.read_bytes())
|
if self.path and self.path.exists():
|
||||||
|
checksum = crc32(self.path.read_bytes())
|
||||||
|
elif self.url:
|
||||||
|
checksum = crc32(self.url.encode("utf8"))
|
||||||
|
else:
|
||||||
|
checksum = crc32(self.name.encode("utf8"))
|
||||||
return hex(checksum)
|
return hex(checksum)
|
||||||
|
|
||||||
def delete(self) -> None:
|
def delete(self) -> None:
|
||||||
if self.path:
|
if self.path and self.path.exists():
|
||||||
self.path.unlink()
|
self.path.unlink()
|
||||||
self.path = None
|
self.path = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_url(
|
def from_url(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,14 +10,16 @@ from pathlib import Path
|
|||||||
from rich.padding import Padding
|
from rich.padding import Padding
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
|
|
||||||
from unshackle.core.binaries import FFMPEG, DoviTool, HDR10PlusTool
|
from unshackle.core.binaries import FFMPEG, DoviTool, FFProbe, HDR10PlusTool
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.console import console
|
from unshackle.core.console import console
|
||||||
|
from unshackle.core.utilities import get_debug_logger
|
||||||
|
|
||||||
|
|
||||||
class Hybrid:
|
class Hybrid:
|
||||||
def __init__(self, videos, source) -> None:
|
def __init__(self, videos, source) -> None:
|
||||||
self.log = logging.getLogger("hybrid")
|
self.log = logging.getLogger("hybrid")
|
||||||
|
self.debug_logger = get_debug_logger()
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks.
|
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks.
|
||||||
@@ -41,6 +45,19 @@ class Hybrid:
|
|||||||
|
|
||||||
console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2)))
|
console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2)))
|
||||||
|
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="hybrid_init",
|
||||||
|
message="Starting HDR10+DV hybrid processing",
|
||||||
|
context={
|
||||||
|
"source": source,
|
||||||
|
"resolution": self.resolution,
|
||||||
|
"video_count": len(videos),
|
||||||
|
"video_ranges": [str(v.range) for v in videos],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
for video in self.videos:
|
for video in self.videos:
|
||||||
if not video.path or not os.path.exists(video.path):
|
if not video.path or not os.path.exists(video.path):
|
||||||
raise ValueError(f"Video track {video.id} was not downloaded before injection.")
|
raise ValueError(f"Video track {video.id} was not downloaded before injection.")
|
||||||
@@ -50,18 +67,18 @@ class Hybrid:
|
|||||||
has_hdr10 = any(video.range == Video.Range.HDR10 for video in self.videos)
|
has_hdr10 = any(video.range == Video.Range.HDR10 for video in self.videos)
|
||||||
has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos)
|
has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos)
|
||||||
|
|
||||||
if not has_hdr10:
|
if not has_hdr10 and not has_hdr10p:
|
||||||
raise ValueError("No HDR10 track available for hybrid processing.")
|
raise ValueError("No HDR10 or HDR10+ track available for hybrid processing.")
|
||||||
|
|
||||||
# If we have HDR10+ but no DV, we can convert HDR10+ to DV
|
# If we have HDR10+ but no DV, we can convert HDR10+ to DV
|
||||||
if not has_dv and has_hdr10p:
|
if not has_dv and has_hdr10p:
|
||||||
self.log.info("✓ No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
|
console.status("No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
|
||||||
self.hdr10plus_to_dv = True
|
self.hdr10plus_to_dv = True
|
||||||
elif not has_dv:
|
elif not has_dv:
|
||||||
raise ValueError("No DV track available and no HDR10+ to convert.")
|
raise ValueError("No DV track available and no HDR10+ to convert.")
|
||||||
|
|
||||||
if os.path.isfile(config.directories.temp / self.hevc_file):
|
if os.path.isfile(config.directories.temp / self.hevc_file):
|
||||||
self.log.info("✓ Already Injected")
|
console.status("Already Injected")
|
||||||
return
|
return
|
||||||
|
|
||||||
for video in videos:
|
for video in videos:
|
||||||
@@ -89,14 +106,34 @@ class Hybrid:
|
|||||||
self.extract_rpu(dv_video)
|
self.extract_rpu(dv_video)
|
||||||
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
|
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
|
||||||
self.rpu_file = "RPU_UNT.bin"
|
self.rpu_file = "RPU_UNT.bin"
|
||||||
self.level_6()
|
|
||||||
# Mode 3 conversion already done during extraction when not untouched
|
# Mode 3 conversion already done during extraction when not untouched
|
||||||
elif os.path.isfile(config.directories.temp / "RPU.bin"):
|
elif os.path.isfile(config.directories.temp / "RPU.bin"):
|
||||||
# RPU already extracted with mode 3
|
# RPU already extracted with mode 3
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Edit L6 with actual luminance values from RPU, then L5 active area
|
||||||
|
self.level_6()
|
||||||
|
base_video = next(
|
||||||
|
(v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)), None
|
||||||
|
)
|
||||||
|
if base_video and base_video.path:
|
||||||
|
self.level_5(base_video.path)
|
||||||
|
|
||||||
self.injecting()
|
self.injecting()
|
||||||
|
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="INFO",
|
||||||
|
operation="hybrid_complete",
|
||||||
|
message="Injection Completed",
|
||||||
|
context={
|
||||||
|
"hdr_type": self.hdr_type,
|
||||||
|
"resolution": self.resolution,
|
||||||
|
"hdr10plus_to_dv": self.hdr10plus_to_dv,
|
||||||
|
"rpu_file": self.rpu_file,
|
||||||
|
"output_file": self.hevc_file,
|
||||||
|
},
|
||||||
|
)
|
||||||
self.log.info("✓ Injection Completed")
|
self.log.info("✓ Injection Completed")
|
||||||
if self.source == ("itunes" or "appletvplus"):
|
if self.source == ("itunes" or "appletvplus"):
|
||||||
Path.unlink(config.directories.temp / "hdr10.mkv")
|
Path.unlink(config.directories.temp / "hdr10.mkv")
|
||||||
@@ -104,6 +141,10 @@ class Hybrid:
|
|||||||
Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True)
|
Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True)
|
||||||
Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True)
|
Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True)
|
||||||
Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True)
|
Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True)
|
||||||
|
Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True)
|
||||||
|
Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True)
|
||||||
|
Path.unlink(config.directories.temp / "L5.json", missing_ok=True)
|
||||||
|
Path.unlink(config.directories.temp / "L6.json", missing_ok=True)
|
||||||
|
|
||||||
def ffmpeg_simple(self, save_path, output):
|
def ffmpeg_simple(self, save_path, output):
|
||||||
"""Simple ffmpeg execution without progress tracking"""
|
"""Simple ffmpeg execution without progress tracking"""
|
||||||
@@ -121,20 +162,41 @@ class Hybrid:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
return p.returncode
|
return p
|
||||||
|
|
||||||
def extract_stream(self, save_path, type_):
|
def extract_stream(self, save_path, type_):
|
||||||
output = Path(config.directories.temp / f"{type_}.hevc")
|
output = Path(config.directories.temp / f"{type_}.hevc")
|
||||||
|
|
||||||
with console.status(f"Extracting {type_} stream...", spinner="dots"):
|
with console.status(f"Extracting {type_} stream...", spinner="dots"):
|
||||||
returncode = self.ffmpeg_simple(save_path, output)
|
result = self.ffmpeg_simple(save_path, output)
|
||||||
|
|
||||||
if returncode:
|
if result.returncode:
|
||||||
output.unlink(missing_ok=True)
|
output.unlink(missing_ok=True)
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_extract_stream",
|
||||||
|
message=f"Failed extracting {type_} stream",
|
||||||
|
context={
|
||||||
|
"type": type_,
|
||||||
|
"input": str(save_path),
|
||||||
|
"output": str(output),
|
||||||
|
"returncode": result.returncode,
|
||||||
|
"stderr": (result.stderr or b"").decode(errors="replace"),
|
||||||
|
"stdout": (result.stdout or b"").decode(errors="replace"),
|
||||||
|
},
|
||||||
|
)
|
||||||
self.log.error(f"x Failed extracting {type_} stream")
|
self.log.error(f"x Failed extracting {type_} stream")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
self.log.info(f"Extracted {type_} stream")
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="hybrid_extract_stream",
|
||||||
|
message=f"Extracted {type_} stream",
|
||||||
|
context={"type": type_, "input": str(save_path), "output": str(output)},
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
def extract_rpu(self, video, untouched=False):
|
def extract_rpu(self, video, untouched=False):
|
||||||
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
|
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
|
||||||
@@ -161,58 +223,326 @@ class Hybrid:
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rpu_name = "RPU" if not untouched else "RPU_UNT"
|
||||||
if rpu_extraction.returncode:
|
if rpu_extraction.returncode:
|
||||||
Path.unlink(config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin")
|
Path.unlink(config.directories.temp / f"{rpu_name}.bin")
|
||||||
|
stderr_text = rpu_extraction.stderr.decode(errors="replace") if rpu_extraction.stderr else ""
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_extract_rpu",
|
||||||
|
message=f"Failed extracting{' untouched ' if untouched else ' '}RPU",
|
||||||
|
context={
|
||||||
|
"untouched": untouched,
|
||||||
|
"returncode": rpu_extraction.returncode,
|
||||||
|
"stderr": stderr_text,
|
||||||
|
"args": [str(a) for a in extraction_args],
|
||||||
|
},
|
||||||
|
)
|
||||||
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
|
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
|
||||||
self.extract_rpu(video, untouched=True)
|
self.extract_rpu(video, untouched=True)
|
||||||
elif b"Invalid PPS index" in rpu_extraction.stderr:
|
elif b"Invalid PPS index" in rpu_extraction.stderr:
|
||||||
raise ValueError("Dolby Vision VideoTrack seems to be corrupt")
|
raise ValueError("Dolby Vision VideoTrack seems to be corrupt")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
|
raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
|
||||||
|
elif self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="hybrid_extract_rpu",
|
||||||
|
message=f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream",
|
||||||
|
context={"untouched": untouched, "output": f"{rpu_name}.bin"},
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
self.log.info(f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
|
def level_5(self, input_video):
|
||||||
|
"""Generate Level 5 active area metadata via crop detection on the HDR10 stream.
|
||||||
|
|
||||||
def level_6(self):
|
This resolves mismatches where DV has no black bars but HDR10 does (or vice versa)
|
||||||
"""Edit RPU Level 6 values"""
|
by telling the display the correct active area.
|
||||||
with open(config.directories.temp / "L6.json", "w+") as level6_file:
|
"""
|
||||||
level6 = {
|
if os.path.isfile(config.directories.temp / "RPU_L5.bin"):
|
||||||
"cm_version": "V29",
|
return
|
||||||
"length": 0,
|
|
||||||
"level6": {
|
|
||||||
"max_display_mastering_luminance": 1000,
|
|
||||||
"min_display_mastering_luminance": 1,
|
|
||||||
"max_content_light_level": 0,
|
|
||||||
"max_frame_average_light_level": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
json.dump(level6, level6_file, indent=3)
|
ffprobe_bin = str(FFProbe) if FFProbe else "ffprobe"
|
||||||
|
ffmpeg_bin = str(FFMPEG) if FFMPEG else "ffmpeg"
|
||||||
|
|
||||||
if not os.path.isfile(config.directories.temp / "RPU_L6.bin"):
|
# Get video duration for random sampling
|
||||||
with console.status("Editing RPU Level 6 values...", spinner="dots"):
|
with console.status("Detecting active area (crop detection)...", spinner="dots"):
|
||||||
level6 = subprocess.run(
|
result_duration = subprocess.run(
|
||||||
|
[ffprobe_bin, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(input_video)],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result_duration.returncode != 0:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="WARNING",
|
||||||
|
operation="hybrid_level5",
|
||||||
|
message="Could not probe video duration",
|
||||||
|
context={"returncode": result_duration.returncode, "stderr": (result_duration.stderr or "")},
|
||||||
|
)
|
||||||
|
self.log.warning("Could not probe video duration, skipping L5 crop detection")
|
||||||
|
return
|
||||||
|
|
||||||
|
duration_info = json.loads(result_duration.stdout)
|
||||||
|
duration = float(duration_info["format"]["duration"])
|
||||||
|
|
||||||
|
# Get video resolution for proper border calculation
|
||||||
|
result_streams = subprocess.run(
|
||||||
|
[
|
||||||
|
ffprobe_bin,
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=width,height",
|
||||||
|
"-of",
|
||||||
|
"json",
|
||||||
|
str(input_video),
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result_streams.returncode != 0:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="WARNING",
|
||||||
|
operation="hybrid_level5",
|
||||||
|
message="Could not probe video resolution",
|
||||||
|
context={"returncode": result_streams.returncode, "stderr": (result_streams.stderr or "")},
|
||||||
|
)
|
||||||
|
self.log.warning("Could not probe video resolution, skipping L5 crop detection")
|
||||||
|
return
|
||||||
|
|
||||||
|
stream_info = json.loads(result_streams.stdout)
|
||||||
|
original_width = int(stream_info["streams"][0]["width"])
|
||||||
|
original_height = int(stream_info["streams"][0]["height"])
|
||||||
|
|
||||||
|
# Sample 10 random timestamps and run cropdetect on each
|
||||||
|
random_times = sorted(random.uniform(0, duration) for _ in range(10))
|
||||||
|
|
||||||
|
crop_results = []
|
||||||
|
for t in random_times:
|
||||||
|
result_cropdetect = subprocess.run(
|
||||||
[
|
[
|
||||||
str(DoviTool),
|
ffmpeg_bin,
|
||||||
"editor",
|
"-y",
|
||||||
|
"-nostdin",
|
||||||
|
"-loglevel",
|
||||||
|
"info",
|
||||||
|
"-ss",
|
||||||
|
f"{t:.2f}",
|
||||||
"-i",
|
"-i",
|
||||||
config.directories.temp / self.rpu_file,
|
str(input_video),
|
||||||
"-j",
|
"-vf",
|
||||||
config.directories.temp / "L6.json",
|
"cropdetect=round=2",
|
||||||
"-o",
|
"-vframes",
|
||||||
config.directories.temp / "RPU_L6.bin",
|
"10",
|
||||||
|
"-f",
|
||||||
|
"null",
|
||||||
|
"-",
|
||||||
],
|
],
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if level6.returncode:
|
# cropdetect outputs crop=w:h:x:y
|
||||||
Path.unlink(config.directories.temp / "RPU_L6.bin")
|
crop_match = re.search(
|
||||||
raise ValueError("Failed editing RPU Level 6 values")
|
r"crop=(\d+):(\d+):(\d+):(\d+)",
|
||||||
|
(result_cropdetect.stdout or "") + (result_cropdetect.stderr or ""),
|
||||||
|
)
|
||||||
|
if crop_match:
|
||||||
|
w, h = int(crop_match.group(1)), int(crop_match.group(2))
|
||||||
|
x, y = int(crop_match.group(3)), int(crop_match.group(4))
|
||||||
|
# Calculate actual border sizes from crop geometry
|
||||||
|
left = x
|
||||||
|
top = y
|
||||||
|
right = original_width - w - x
|
||||||
|
bottom = original_height - h - y
|
||||||
|
crop_results.append((left, top, right, bottom))
|
||||||
|
|
||||||
self.log.info("Edited RPU Level 6 values")
|
if not crop_results:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="WARNING",
|
||||||
|
operation="hybrid_level5",
|
||||||
|
message="No crop data detected, skipping L5",
|
||||||
|
context={"samples": len(random_times)},
|
||||||
|
)
|
||||||
|
self.log.warning("No crop data detected, skipping L5")
|
||||||
|
return
|
||||||
|
|
||||||
# Update rpu_file to use the edited version
|
# Find the most common crop values
|
||||||
self.rpu_file = "RPU_L6.bin"
|
crop_counts = {}
|
||||||
|
for crop in crop_results:
|
||||||
|
crop_counts[crop] = crop_counts.get(crop, 0) + 1
|
||||||
|
most_common = max(crop_counts, key=crop_counts.get)
|
||||||
|
left, top, right, bottom = most_common
|
||||||
|
|
||||||
|
# If all borders are 0 there's nothing to correct
|
||||||
|
if left == 0 and top == 0 and right == 0 and bottom == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
l5_json = {
|
||||||
|
"active_area": {
|
||||||
|
"crop": False,
|
||||||
|
"presets": [{"id": 0, "left": left, "right": right, "top": top, "bottom": bottom}],
|
||||||
|
"edits": {"all": 0},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
l5_path = config.directories.temp / "L5.json"
|
||||||
|
with open(l5_path, "w") as f:
|
||||||
|
json.dump(l5_json, f, indent=4)
|
||||||
|
|
||||||
|
with console.status("Editing RPU Level 5 active area...", spinner="dots"):
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
str(DoviTool),
|
||||||
|
"editor",
|
||||||
|
"-i",
|
||||||
|
str(config.directories.temp / self.rpu_file),
|
||||||
|
"-j",
|
||||||
|
str(l5_path),
|
||||||
|
"-o",
|
||||||
|
str(config.directories.temp / "RPU_L5.bin"),
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_level5",
|
||||||
|
message="Failed editing RPU Level 5 values",
|
||||||
|
context={"returncode": result.returncode, "stderr": (result.stderr or b"").decode(errors="replace")},
|
||||||
|
)
|
||||||
|
Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True)
|
||||||
|
raise ValueError("Failed editing RPU Level 5 values")
|
||||||
|
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="hybrid_level5",
|
||||||
|
message="Edited RPU Level 5 active area",
|
||||||
|
context={"crop": {"left": left, "right": right, "top": top, "bottom": bottom}, "samples": len(crop_results)},
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
self.rpu_file = "RPU_L5.bin"
|
||||||
|
|
||||||
|
def level_6(self):
|
||||||
|
"""Edit RPU Level 6 values using actual luminance data from the RPU."""
|
||||||
|
if os.path.isfile(config.directories.temp / "RPU_L6.bin"):
|
||||||
|
return
|
||||||
|
|
||||||
|
with console.status("Reading RPU luminance metadata...", spinner="dots"):
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(DoviTool), "info", "-i", str(config.directories.temp / self.rpu_file), "-s"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_level6",
|
||||||
|
message="Failed reading RPU metadata for Level 6 values",
|
||||||
|
context={"returncode": result.returncode, "stderr": (result.stderr or "")},
|
||||||
|
)
|
||||||
|
raise ValueError("Failed reading RPU metadata for Level 6 values")
|
||||||
|
|
||||||
|
max_cll = None
|
||||||
|
max_fall = None
|
||||||
|
max_mdl = None
|
||||||
|
min_mdl = None
|
||||||
|
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "RPU content light level (L1):" in line:
|
||||||
|
parts = line.split("MaxCLL:")[1].split(",")
|
||||||
|
max_cll = int(float(parts[0].strip().split()[0]))
|
||||||
|
if len(parts) > 1 and "MaxFALL:" in parts[1]:
|
||||||
|
max_fall = int(float(parts[1].split("MaxFALL:")[1].strip().split()[0]))
|
||||||
|
elif "RPU mastering display:" in line:
|
||||||
|
mastering = line.split(":", 1)[1].strip()
|
||||||
|
min_lum, max_lum = mastering.split("/")[0], mastering.split("/")[1].split(" ")[0]
|
||||||
|
min_mdl = int(float(min_lum) * 10000)
|
||||||
|
max_mdl = int(float(max_lum))
|
||||||
|
|
||||||
|
if any(v is None for v in (max_cll, max_fall, max_mdl, min_mdl)):
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_level6",
|
||||||
|
message="Could not extract Level 6 luminance data from RPU",
|
||||||
|
context={"max_cll": max_cll, "max_fall": max_fall, "max_mdl": max_mdl, "min_mdl": min_mdl},
|
||||||
|
)
|
||||||
|
raise ValueError("Could not extract Level 6 luminance data from RPU")
|
||||||
|
|
||||||
|
level6_data = {
|
||||||
|
"level6": {
|
||||||
|
"remove_cmv4": False,
|
||||||
|
"remove_mapping": False,
|
||||||
|
"max_display_mastering_luminance": max_mdl,
|
||||||
|
"min_display_mastering_luminance": min_mdl,
|
||||||
|
"max_content_light_level": max_cll,
|
||||||
|
"max_frame_average_light_level": max_fall,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
l6_path = config.directories.temp / "L6.json"
|
||||||
|
with open(l6_path, "w") as f:
|
||||||
|
json.dump(level6_data, f, indent=4)
|
||||||
|
|
||||||
|
with console.status("Editing RPU Level 6 values...", spinner="dots"):
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
str(DoviTool),
|
||||||
|
"editor",
|
||||||
|
"-i",
|
||||||
|
str(config.directories.temp / self.rpu_file),
|
||||||
|
"-j",
|
||||||
|
str(l6_path),
|
||||||
|
"-o",
|
||||||
|
str(config.directories.temp / "RPU_L6.bin"),
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_level6",
|
||||||
|
message="Failed editing RPU Level 6 values",
|
||||||
|
context={"returncode": result.returncode, "stderr": (result.stderr or b"").decode(errors="replace")},
|
||||||
|
)
|
||||||
|
Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True)
|
||||||
|
raise ValueError("Failed editing RPU Level 6 values")
|
||||||
|
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="hybrid_level6",
|
||||||
|
message="Edited RPU Level 6 luminance values",
|
||||||
|
context={
|
||||||
|
"max_cll": max_cll,
|
||||||
|
"max_fall": max_fall,
|
||||||
|
"max_mdl": max_mdl,
|
||||||
|
"min_mdl": min_mdl,
|
||||||
|
},
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
self.rpu_file = "RPU_L6.bin"
|
||||||
|
|
||||||
def injecting(self):
|
def injecting(self):
|
||||||
if os.path.isfile(config.directories.temp / self.hevc_file):
|
if os.path.isfile(config.directories.temp / self.hevc_file):
|
||||||
@@ -228,12 +558,6 @@ class Hybrid:
|
|||||||
config.directories.temp / self.rpu_file,
|
config.directories.temp / self.rpu_file,
|
||||||
]
|
]
|
||||||
|
|
||||||
# If we converted from HDR10+, optionally remove HDR10+ metadata during injection
|
|
||||||
# Default to removing HDR10+ metadata since we're converting to DV
|
|
||||||
if self.hdr10plus_to_dv:
|
|
||||||
inject_cmd.append("--drop-hdr10plus")
|
|
||||||
self.log.info(" - Removing HDR10+ metadata during injection")
|
|
||||||
|
|
||||||
inject_cmd.extend(["-o", config.directories.temp / self.hevc_file])
|
inject_cmd.extend(["-o", config.directories.temp / self.hevc_file])
|
||||||
|
|
||||||
inject = subprocess.run(
|
inject = subprocess.run(
|
||||||
@@ -243,10 +567,29 @@ class Hybrid:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if inject.returncode:
|
if inject.returncode:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_inject_rpu",
|
||||||
|
message="Failed injecting Dolby Vision metadata into HDR10 stream",
|
||||||
|
context={
|
||||||
|
"returncode": inject.returncode,
|
||||||
|
"stderr": (inject.stderr or b"").decode(errors="replace"),
|
||||||
|
"stdout": (inject.stdout or b"").decode(errors="replace"),
|
||||||
|
"cmd": [str(a) for a in inject_cmd],
|
||||||
|
},
|
||||||
|
)
|
||||||
Path.unlink(config.directories.temp / self.hevc_file)
|
Path.unlink(config.directories.temp / self.hevc_file)
|
||||||
raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream")
|
raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream")
|
||||||
|
|
||||||
self.log.info(f"Injected Dolby Vision metadata into {self.hdr_type} stream")
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="hybrid_inject_rpu",
|
||||||
|
message=f"Injected Dolby Vision metadata into {self.hdr_type} stream",
|
||||||
|
context={"hdr_type": self.hdr_type, "rpu_file": self.rpu_file, "output": self.hevc_file, "drop_hdr10plus": self.hdr10plus_to_dv},
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
def extract_hdr10plus(self, _video):
|
def extract_hdr10plus(self, _video):
|
||||||
"""Extract HDR10+ metadata from the video stream"""
|
"""Extract HDR10+ metadata from the video stream"""
|
||||||
@@ -271,13 +614,39 @@ class Hybrid:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if extraction.returncode:
|
if extraction.returncode:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_extract_hdr10plus",
|
||||||
|
message="Failed extracting HDR10+ metadata",
|
||||||
|
context={
|
||||||
|
"returncode": extraction.returncode,
|
||||||
|
"stderr": (extraction.stderr or b"").decode(errors="replace"),
|
||||||
|
"stdout": (extraction.stdout or b"").decode(errors="replace"),
|
||||||
|
},
|
||||||
|
)
|
||||||
raise ValueError("Failed extracting HDR10+ metadata")
|
raise ValueError("Failed extracting HDR10+ metadata")
|
||||||
|
|
||||||
# Check if the extracted file has content
|
# Check if the extracted file has content
|
||||||
if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0:
|
file_size = os.path.getsize(config.directories.temp / self.hdr10plus_file)
|
||||||
|
if file_size == 0:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_extract_hdr10plus",
|
||||||
|
message="No HDR10+ metadata found in the stream",
|
||||||
|
context={"file_size": 0},
|
||||||
|
)
|
||||||
raise ValueError("No HDR10+ metadata found in the stream")
|
raise ValueError("No HDR10+ metadata found in the stream")
|
||||||
|
|
||||||
self.log.info("Extracted HDR10+ metadata")
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="hybrid_extract_hdr10plus",
|
||||||
|
message="Extracted HDR10+ metadata",
|
||||||
|
context={"output": self.hdr10plus_file, "file_size": file_size},
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
def convert_hdr10plus_to_dv(self):
|
def convert_hdr10plus_to_dv(self):
|
||||||
"""Convert HDR10+ metadata to Dolby Vision RPU"""
|
"""Convert HDR10+ metadata to Dolby Vision RPU"""
|
||||||
@@ -317,10 +686,26 @@ class Hybrid:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if conversion.returncode:
|
if conversion.returncode:
|
||||||
|
if self.debug_logger:
|
||||||
|
self.debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="hybrid_convert_hdr10plus",
|
||||||
|
message="Failed converting HDR10+ to Dolby Vision",
|
||||||
|
context={
|
||||||
|
"returncode": conversion.returncode,
|
||||||
|
"stderr": (conversion.stderr or b"").decode(errors="replace"),
|
||||||
|
"stdout": (conversion.stdout or b"").decode(errors="replace"),
|
||||||
|
},
|
||||||
|
)
|
||||||
raise ValueError("Failed converting HDR10+ to Dolby Vision")
|
raise ValueError("Failed converting HDR10+ to Dolby Vision")
|
||||||
|
|
||||||
self.log.info("Converted HDR10+ metadata to Dolby Vision")
|
if self.debug_logger:
|
||||||
self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8")
|
self.debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="hybrid_convert_hdr10plus",
|
||||||
|
message="Converted HDR10+ metadata to Dolby Vision Profile 8",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Clean up temporary files
|
# Clean up temporary files
|
||||||
Path.unlink(config.directories.temp / "extra.json")
|
Path.unlink(config.directories.temp / "extra.json")
|
||||||
|
|||||||
@@ -15,17 +15,16 @@ from zlib import crc32
|
|||||||
|
|
||||||
from curl_cffi.requests import Session as CurlSession
|
from curl_cffi.requests import Session as CurlSession
|
||||||
from langcodes import Language
|
from langcodes import Language
|
||||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
|
||||||
from requests import Session
|
from requests import Session
|
||||||
|
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
|
from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
|
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
|
||||||
from unshackle.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
from unshackle.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
||||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||||
from unshackle.core.events import events
|
from unshackle.core.events import events
|
||||||
from unshackle.core.utilities import get_boxes, get_extension, try_ensure_utf8
|
from unshackle.core.utilities import get_boxes, try_ensure_utf8
|
||||||
from unshackle.core.utils.subprocess import ffprobe
|
from unshackle.core.utils.subprocess import ffprobe
|
||||||
|
|
||||||
|
|
||||||
@@ -211,23 +210,12 @@ class Track:
|
|||||||
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
||||||
if track_type == "Subtitle":
|
if track_type == "Subtitle":
|
||||||
save_path = save_path.with_suffix(f".{self.codec.extension}")
|
save_path = save_path.with_suffix(f".{self.codec.extension}")
|
||||||
# n_m3u8dl_re doesn't support directly downloading subtitles from URLs
|
|
||||||
# or when the subtitle has a direct file extension
|
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
||||||
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
self.descriptor == self.Descriptor.URL
|
||||||
self.descriptor == self.Descriptor.URL
|
or track_type in ("Subtitle", "Attachment")
|
||||||
or get_extension(self.url)
|
):
|
||||||
in {
|
self.downloader = requests
|
||||||
".srt",
|
|
||||||
".vtt",
|
|
||||||
".ttml",
|
|
||||||
".ssa",
|
|
||||||
".ass",
|
|
||||||
".stpp",
|
|
||||||
".wvtt",
|
|
||||||
".xml",
|
|
||||||
}
|
|
||||||
):
|
|
||||||
self.downloader = requests
|
|
||||||
|
|
||||||
if self.descriptor != self.Descriptor.URL:
|
if self.descriptor != self.Descriptor.URL:
|
||||||
save_dir = save_path.with_name(save_path.name + "_segments")
|
save_dir = save_path.with_name(save_path.name + "_segments")
|
||||||
@@ -297,7 +285,7 @@ class Track:
|
|||||||
if not self.drm and track_type in ("Video", "Audio"):
|
if not self.drm and track_type in ("Video", "Audio"):
|
||||||
# the service might not have explicitly defined the `drm` property
|
# the service might not have explicitly defined the `drm` property
|
||||||
# try find DRM information from the init data of URL based on CDM type
|
# try find DRM information from the init data of URL based on CDM type
|
||||||
if isinstance(cdm, PlayReadyCdm):
|
if is_playready_cdm(cdm):
|
||||||
try:
|
try:
|
||||||
self.drm = [PlayReady.from_track(self, session)]
|
self.drm = [PlayReady.from_track(self, session)]
|
||||||
except PlayReady.Exceptions.PSSHNotFound:
|
except PlayReady.Exceptions.PSSHNotFound:
|
||||||
@@ -451,23 +439,14 @@ class Track:
|
|||||||
if not self.drm:
|
if not self.drm:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(cdm, WidevineCdm):
|
if is_widevine_cdm(cdm):
|
||||||
for drm in self.drm:
|
for drm in self.drm:
|
||||||
if isinstance(drm, Widevine):
|
if isinstance(drm, Widevine):
|
||||||
return drm
|
return drm
|
||||||
elif isinstance(cdm, PlayReadyCdm):
|
elif is_playready_cdm(cdm):
|
||||||
for drm in self.drm:
|
for drm in self.drm:
|
||||||
if isinstance(drm, PlayReady):
|
if isinstance(drm, PlayReady):
|
||||||
return drm
|
return drm
|
||||||
elif hasattr(cdm, "is_playready"):
|
|
||||||
if cdm.is_playready:
|
|
||||||
for drm in self.drm:
|
|
||||||
if isinstance(drm, PlayReady):
|
|
||||||
return drm
|
|
||||||
else:
|
|
||||||
for drm in self.drm:
|
|
||||||
if isinstance(drm, Widevine):
|
|
||||||
return drm
|
|
||||||
|
|
||||||
return self.drm[0]
|
return self.drm[0]
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class Tracks:
|
|||||||
|
|
||||||
return rep
|
return rep
|
||||||
|
|
||||||
def tree(self, add_progress: bool = False) -> tuple[Tree, list[partial]]:
|
def tree(self, add_progress: bool = False) -> tuple[Tree, list[Callable[..., None]]]:
|
||||||
all_tracks = [*list(self), *self.chapters, *self.attachments]
|
all_tracks = [*list(self), *self.chapters, *self.attachments]
|
||||||
|
|
||||||
progress_callables = []
|
progress_callables = []
|
||||||
@@ -121,7 +121,29 @@ class Tracks:
|
|||||||
speed_estimate_period=10,
|
speed_estimate_period=10,
|
||||||
)
|
)
|
||||||
task = progress.add_task("", downloaded="-")
|
task = progress.add_task("", downloaded="-")
|
||||||
progress_callables.append(partial(progress.update, task_id=task))
|
state = {"total": 100.0}
|
||||||
|
|
||||||
|
def update_track_progress(
|
||||||
|
task_id: int = task,
|
||||||
|
_state: dict[str, float] = state,
|
||||||
|
_progress: Progress = progress,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Ensure terminal status states render as a fully completed bar.
|
||||||
|
|
||||||
|
Some downloaders can report completed slightly below total
|
||||||
|
before emitting the final "Downloaded" state.
|
||||||
|
"""
|
||||||
|
if "total" in kwargs and kwargs["total"] is not None:
|
||||||
|
_state["total"] = kwargs["total"]
|
||||||
|
|
||||||
|
downloaded_state = kwargs.get("downloaded")
|
||||||
|
if downloaded_state in {"Downloaded", "Decrypted", "[yellow]SKIPPED"}:
|
||||||
|
kwargs["completed"] = _state["total"]
|
||||||
|
_progress.update(task_id=task_id, **kwargs)
|
||||||
|
|
||||||
|
progress_callables.append(update_track_progress)
|
||||||
track_table = Table.grid()
|
track_table = Table.grid()
|
||||||
track_table.add_row(str(track)[6:], style="text2")
|
track_table.add_row(str(track)[6:], style="text2")
|
||||||
track_table.add_row(progress)
|
track_table.add_row(progress)
|
||||||
@@ -199,13 +221,15 @@ class Tracks:
|
|||||||
self.videos.sort(key=lambda x: not is_close_match(language, [x.language]))
|
self.videos.sort(key=lambda x: not is_close_match(language, [x.language]))
|
||||||
|
|
||||||
def sort_audio(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
def sort_audio(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||||
"""Sort audio tracks by bitrate, descriptive, and optionally language."""
|
"""Sort audio tracks by bitrate, Atmos, descriptive, and optionally language."""
|
||||||
if not self.audio:
|
if not self.audio:
|
||||||
return
|
return
|
||||||
# descriptive
|
# bitrate (highest first)
|
||||||
self.audio.sort(key=lambda x: x.descriptive)
|
|
||||||
# bitrate (within each descriptive group)
|
|
||||||
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
||||||
|
# Atmos tracks first (prioritize over higher bitrate non-Atmos)
|
||||||
|
self.audio.sort(key=lambda x: not x.atmos)
|
||||||
|
# descriptive tracks last
|
||||||
|
self.audio.sort(key=lambda x: x.descriptive)
|
||||||
# language
|
# language
|
||||||
for language in reversed(by_language or []):
|
for language in reversed(by_language or []):
|
||||||
if str(language) in ("all", "best"):
|
if str(language) in ("all", "best"):
|
||||||
@@ -254,23 +278,30 @@ class Tracks:
|
|||||||
self.subtitles = list(filter(x, self.subtitles))
|
self.subtitles = list(filter(x, self.subtitles))
|
||||||
|
|
||||||
def select_hybrid(self, tracks, quality):
|
def select_hybrid(self, tracks, quality):
|
||||||
hdr10_tracks = [
|
# Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata)
|
||||||
v
|
base_ranges = (Video.Range.HDR10P, Video.Range.HDR10)
|
||||||
for v in tracks
|
base_tracks = []
|
||||||
if v.range == Video.Range.HDR10 and (v.height in quality or int(v.width * 9 / 16) in quality)
|
for range_type in base_ranges:
|
||||||
]
|
base_tracks = [
|
||||||
hdr10 = []
|
v
|
||||||
|
for v in tracks
|
||||||
|
if v.range == range_type and (v.height in quality or int(v.width * 9 / 16) in quality)
|
||||||
|
]
|
||||||
|
if base_tracks:
|
||||||
|
break
|
||||||
|
|
||||||
|
base_selected = []
|
||||||
for res in quality:
|
for res in quality:
|
||||||
candidates = [v for v in hdr10_tracks if v.height == res or int(v.width * 9 / 16) == res]
|
candidates = [v for v in base_tracks if v.height == res or int(v.width * 9 / 16) == res]
|
||||||
if candidates:
|
if candidates:
|
||||||
best = max(candidates, key=lambda v: v.bitrate) # assumes .bitrate exists
|
best = max(candidates, key=lambda v: v.bitrate)
|
||||||
hdr10.append(best)
|
base_selected.append(best)
|
||||||
|
|
||||||
dv_tracks = [v for v in tracks if v.range == Video.Range.DV]
|
dv_tracks = [v for v in tracks if v.range == Video.Range.DV]
|
||||||
lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None
|
lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None
|
||||||
|
|
||||||
def select(x):
|
def select(x):
|
||||||
if x in hdr10:
|
if x in base_selected:
|
||||||
return True
|
return True
|
||||||
if lowest_dv and x is lowest_dv:
|
if lowest_dv and x is lowest_dv:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ class VideoCodecChoice(click.Choice):
|
|||||||
self.fail(f"'{value}' is not a valid video codec", param, ctx)
|
self.fail(f"'{value}' is not a valid video codec", param, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleVideoCodecChoice(VideoCodecChoice):
|
||||||
|
"""
|
||||||
|
A multiple-value variant of VideoCodecChoice that accepts comma-separated codecs.
|
||||||
|
|
||||||
|
Accepts both enum names and values, e.g.: ``-v hevc,avc`` or ``-v H.264,H.265``
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "multiple_video_codec_choice"
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None
|
||||||
|
) -> list[Any]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
values = value
|
||||||
|
elif isinstance(value, str):
|
||||||
|
values = value.split(",")
|
||||||
|
else:
|
||||||
|
self.fail(f"{value!r} is not a supported value.", param, ctx)
|
||||||
|
|
||||||
|
chosen_values: list[Any] = []
|
||||||
|
for v in values:
|
||||||
|
chosen_values.append(super().convert(v.strip(), param, ctx))
|
||||||
|
return chosen_values
|
||||||
|
|
||||||
|
|
||||||
class SubtitleCodecChoice(click.Choice):
|
class SubtitleCodecChoice(click.Choice):
|
||||||
"""
|
"""
|
||||||
A custom Choice type for subtitle codecs that accepts both enum names, values, and common aliases.
|
A custom Choice type for subtitle codecs that accepts both enum names, values, and common aliases.
|
||||||
|
|||||||
310
unshackle/core/utils/selector.py
Normal file
310
unshackle/core/utils/selector.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Group
|
||||||
|
from rich.live import Live
|
||||||
|
from rich.padding import Padding
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from unshackle.core.console import console
|
||||||
|
|
||||||
|
IS_WINDOWS = sys.platform == "win32"
|
||||||
|
if IS_WINDOWS:
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
|
||||||
|
class Selector:
|
||||||
|
"""
|
||||||
|
A custom interactive selector class using the Rich library.
|
||||||
|
Allows for multi-selection of items with pagination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
options: list[str],
|
||||||
|
cursor_style: str = "pink",
|
||||||
|
text_style: str = "text",
|
||||||
|
page_size: int = 8,
|
||||||
|
minimal_count: int = 0,
|
||||||
|
dependencies: dict[int, list[int]] = None,
|
||||||
|
prefixes: list[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the Selector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: List of strings to select from.
|
||||||
|
cursor_style: Rich style for the highlighted cursor item.
|
||||||
|
text_style: Rich style for normal items.
|
||||||
|
page_size: Number of items to show per page.
|
||||||
|
minimal_count: Minimum number of items that must be selected.
|
||||||
|
dependencies: Dictionary mapping parent index to list of child indices.
|
||||||
|
"""
|
||||||
|
self.options = options
|
||||||
|
self.cursor_style = cursor_style
|
||||||
|
self.text_style = text_style
|
||||||
|
self.page_size = page_size
|
||||||
|
self.minimal_count = minimal_count
|
||||||
|
self.dependencies = dependencies or {}
|
||||||
|
|
||||||
|
self.cursor_index = 0
|
||||||
|
self.selected_indices = set()
|
||||||
|
self.scroll_offset = 0
|
||||||
|
|
||||||
|
def get_renderable(self):
|
||||||
|
"""
|
||||||
|
Constructs and returns the renderable object (Table + Info) for the current state.
|
||||||
|
"""
|
||||||
|
table = Table(show_header=False, show_edge=False, box=None, pad_edge=False, padding=(0, 1, 0, 0))
|
||||||
|
table.add_column("Indicator", justify="right", no_wrap=True)
|
||||||
|
table.add_column("Option", overflow="ellipsis", no_wrap=True)
|
||||||
|
|
||||||
|
for i in range(self.page_size):
|
||||||
|
idx = self.scroll_offset + i
|
||||||
|
|
||||||
|
if idx < len(self.options):
|
||||||
|
option = self.options[idx]
|
||||||
|
is_cursor = idx == self.cursor_index
|
||||||
|
is_selected = idx in self.selected_indices
|
||||||
|
|
||||||
|
symbol = "[X]" if is_selected else "[ ]"
|
||||||
|
style = self.cursor_style if is_cursor else self.text_style
|
||||||
|
indicator_text = Text(f"{symbol}", style=style)
|
||||||
|
|
||||||
|
content_text = Text.from_markup(option)
|
||||||
|
content_text.style = style
|
||||||
|
|
||||||
|
table.add_row(indicator_text, content_text)
|
||||||
|
else:
|
||||||
|
table.add_row(Text(" "), Text(" "))
|
||||||
|
|
||||||
|
total_pages = (len(self.options) + self.page_size - 1) // self.page_size
|
||||||
|
current_page = (self.scroll_offset // self.page_size) + 1
|
||||||
|
|
||||||
|
info_text = Text(
|
||||||
|
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})",
|
||||||
|
style="gray",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Padding(Group(table, info_text), (0, 5))
|
||||||
|
|
||||||
|
def move_cursor(self, delta: int):
|
||||||
|
"""
|
||||||
|
Moves the cursor up or down by the specified delta.
|
||||||
|
Updates the scroll offset if the cursor moves out of the current view.
|
||||||
|
"""
|
||||||
|
self.cursor_index = (self.cursor_index + delta) % len(self.options)
|
||||||
|
new_page_idx = self.cursor_index // self.page_size
|
||||||
|
self.scroll_offset = new_page_idx * self.page_size
|
||||||
|
|
||||||
|
def change_page(self, delta: int):
|
||||||
|
"""
|
||||||
|
Changes the current page view by the specified delta (previous/next page).
|
||||||
|
Also moves the cursor to the first item of the new page.
|
||||||
|
"""
|
||||||
|
current_page = self.scroll_offset // self.page_size
|
||||||
|
total_pages = (len(self.options) + self.page_size - 1) // self.page_size
|
||||||
|
new_page = current_page + delta
|
||||||
|
|
||||||
|
if 0 <= new_page < total_pages:
|
||||||
|
self.scroll_offset = new_page * self.page_size
|
||||||
|
first_idx_of_page = self.scroll_offset
|
||||||
|
if first_idx_of_page < len(self.options):
|
||||||
|
self.cursor_index = first_idx_of_page
|
||||||
|
else:
|
||||||
|
self.cursor_index = len(self.options) - 1
|
||||||
|
|
||||||
|
def toggle_selection(self):
|
||||||
|
"""
|
||||||
|
Toggles the selection state of the item currently under the cursor.
|
||||||
|
Propagates selection to children if defined in dependencies.
|
||||||
|
"""
|
||||||
|
target_indices = {self.cursor_index}
|
||||||
|
|
||||||
|
if self.cursor_index in self.dependencies:
|
||||||
|
target_indices.update(self.dependencies[self.cursor_index])
|
||||||
|
|
||||||
|
should_select = self.cursor_index not in self.selected_indices
|
||||||
|
|
||||||
|
if should_select:
|
||||||
|
self.selected_indices.update(target_indices)
|
||||||
|
else:
|
||||||
|
self.selected_indices.difference_update(target_indices)
|
||||||
|
|
||||||
|
def toggle_all(self):
|
||||||
|
"""
|
||||||
|
Toggles the selection of all items.
|
||||||
|
If all are selected, clears selection. Otherwise, selects all.
|
||||||
|
"""
|
||||||
|
if len(self.selected_indices) == len(self.options):
|
||||||
|
self.selected_indices.clear()
|
||||||
|
else:
|
||||||
|
self.selected_indices = set(range(len(self.options)))
|
||||||
|
|
||||||
|
def get_input_windows(self):
|
||||||
|
"""
|
||||||
|
Captures and parses keyboard input on Windows systems using msvcrt.
|
||||||
|
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
||||||
|
"""
|
||||||
|
key = msvcrt.getch()
|
||||||
|
if key == b"\x03" or key == b"\x1b":
|
||||||
|
return "CANCEL"
|
||||||
|
if key == b"\xe0" or key == b"\x00":
|
||||||
|
try:
|
||||||
|
key = msvcrt.getch()
|
||||||
|
if key == b"H":
|
||||||
|
return "UP"
|
||||||
|
if key == b"P":
|
||||||
|
return "DOWN"
|
||||||
|
if key == b"K":
|
||||||
|
return "LEFT"
|
||||||
|
if key == b"M":
|
||||||
|
return "RIGHT"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
char = key.decode("utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if char in ("\r", "\n"):
|
||||||
|
return "ENTER"
|
||||||
|
if char == " ":
|
||||||
|
return "SPACE"
|
||||||
|
if char in ("q", "Q"):
|
||||||
|
return "QUIT"
|
||||||
|
if char in ("a", "A"):
|
||||||
|
return "ALL"
|
||||||
|
if char in ("w", "W", "k", "K"):
|
||||||
|
return "UP"
|
||||||
|
if char in ("s", "S", "j", "J"):
|
||||||
|
return "DOWN"
|
||||||
|
if char in ("h", "H"):
|
||||||
|
return "LEFT"
|
||||||
|
if char in ("d", "D", "l", "L"):
|
||||||
|
return "RIGHT"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_input_unix(self):
|
||||||
|
"""
|
||||||
|
Captures and parses keyboard input on Unix/Linux systems using click.getchar().
|
||||||
|
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
||||||
|
"""
|
||||||
|
char = click.getchar()
|
||||||
|
if char == "\x03":
|
||||||
|
return "CANCEL"
|
||||||
|
mapping = {
|
||||||
|
"\x1b[A": "UP",
|
||||||
|
"\x1b[B": "DOWN",
|
||||||
|
"\x1b[C": "RIGHT",
|
||||||
|
"\x1b[D": "LEFT",
|
||||||
|
}
|
||||||
|
if char in mapping:
|
||||||
|
return mapping[char]
|
||||||
|
if char == "\x1b":
|
||||||
|
try:
|
||||||
|
next1 = click.getchar()
|
||||||
|
if next1 in ("[", "O"):
|
||||||
|
next2 = click.getchar()
|
||||||
|
if next2 == "A":
|
||||||
|
return "UP"
|
||||||
|
if next2 == "B":
|
||||||
|
return "DOWN"
|
||||||
|
if next2 == "C":
|
||||||
|
return "RIGHT"
|
||||||
|
if next2 == "D":
|
||||||
|
return "LEFT"
|
||||||
|
return "CANCEL"
|
||||||
|
except Exception:
|
||||||
|
return "CANCEL"
|
||||||
|
|
||||||
|
if char in ("\r", "\n"):
|
||||||
|
return "ENTER"
|
||||||
|
if char == " ":
|
||||||
|
return "SPACE"
|
||||||
|
if char in ("q", "Q"):
|
||||||
|
return "QUIT"
|
||||||
|
if char in ("a", "A"):
|
||||||
|
return "ALL"
|
||||||
|
if char in ("w", "W", "k", "K"):
|
||||||
|
return "UP"
|
||||||
|
if char in ("s", "S", "j", "J"):
|
||||||
|
return "DOWN"
|
||||||
|
if char in ("h", "H"):
|
||||||
|
return "LEFT"
|
||||||
|
if char in ("d", "D", "l", "L"):
|
||||||
|
return "RIGHT"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self) -> list[int]:
|
||||||
|
"""
|
||||||
|
Starts the main event loop for the selector.
|
||||||
|
Renders the UI and processes input until confirmed or cancelled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[int]: A sorted list of selected indices.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live:
|
||||||
|
while True:
|
||||||
|
live.update(self.get_renderable(), refresh=True)
|
||||||
|
if IS_WINDOWS:
|
||||||
|
action = self.get_input_windows()
|
||||||
|
else:
|
||||||
|
action = self.get_input_unix()
|
||||||
|
|
||||||
|
if action == "UP":
|
||||||
|
self.move_cursor(-1)
|
||||||
|
elif action == "DOWN":
|
||||||
|
self.move_cursor(1)
|
||||||
|
elif action == "LEFT":
|
||||||
|
self.change_page(-1)
|
||||||
|
elif action == "RIGHT":
|
||||||
|
self.change_page(1)
|
||||||
|
elif action == "SPACE":
|
||||||
|
self.toggle_selection()
|
||||||
|
elif action == "ALL":
|
||||||
|
self.toggle_all()
|
||||||
|
elif action in ("ENTER", "QUIT"):
|
||||||
|
if len(self.selected_indices) >= self.minimal_count:
|
||||||
|
return sorted(list(self.selected_indices))
|
||||||
|
elif action == "CANCEL":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def select_multiple(
|
||||||
|
options: list[str],
|
||||||
|
minimal_count: int = 1,
|
||||||
|
page_size: int = 8,
|
||||||
|
return_indices: bool = True,
|
||||||
|
cursor_style: str = "pink",
|
||||||
|
**kwargs,
|
||||||
|
) -> list[int]:
|
||||||
|
"""
|
||||||
|
Drop-in replacement using custom Selector with global console.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: List of options to display.
|
||||||
|
minimal_count: Minimum number of selections required.
|
||||||
|
page_size: Number of items per page.
|
||||||
|
return_indices: If True, returns indices; otherwise returns the option strings.
|
||||||
|
cursor_style: Style color for the cursor.
|
||||||
|
"""
|
||||||
|
selector = Selector(
|
||||||
|
options=options,
|
||||||
|
cursor_style=cursor_style,
|
||||||
|
text_style="text",
|
||||||
|
page_size=page_size,
|
||||||
|
minimal_count=minimal_count,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_indices = selector.run()
|
||||||
|
|
||||||
|
if return_indices:
|
||||||
|
return selected_indices
|
||||||
|
return [options[i] for i in selected_indices]
|
||||||
@@ -168,6 +168,16 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]]
|
|||||||
duplicate_index: list[int] = []
|
duplicate_index: list[int] = []
|
||||||
captions = vtt.get_captions(lang)
|
captions = vtt.get_captions(lang)
|
||||||
|
|
||||||
|
# Some providers can produce "segment_index" values that are
|
||||||
|
# outside the provided segment_durations list after normalization/merge.
|
||||||
|
# This used to crash with IndexError and abort the entire download.
|
||||||
|
if segment_durations and captions:
|
||||||
|
max_idx = max(getattr(c, "segment_index", 0) for c in captions)
|
||||||
|
if max_idx >= len(segment_durations):
|
||||||
|
# Pad with the last known duration (or 0 if empty) so indexing is safe.
|
||||||
|
pad_val = segment_durations[-1] if segment_durations else 0
|
||||||
|
segment_durations = segment_durations + [pad_val] * (max_idx - len(segment_durations) + 1)
|
||||||
|
|
||||||
if captions[0].segment_index == 0:
|
if captions[0].segment_index == 0:
|
||||||
first_segment_mpegts = captions[0].mpegts
|
first_segment_mpegts = captions[0].mpegts
|
||||||
else:
|
else:
|
||||||
@@ -179,6 +189,9 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]]
|
|||||||
# calculate the timestamp from SegmentTemplate/SegmentList duration.
|
# calculate the timestamp from SegmentTemplate/SegmentList duration.
|
||||||
likely_dash = first_segment_mpegts == 0 and caption.mpegts == 0
|
likely_dash = first_segment_mpegts == 0 and caption.mpegts == 0
|
||||||
if likely_dash and segment_durations:
|
if likely_dash and segment_durations:
|
||||||
|
# Defensive: segment_index can still be out of range if captions are malformed.
|
||||||
|
if caption.segment_index < 0 or caption.segment_index >= len(segment_durations):
|
||||||
|
continue
|
||||||
duration = segment_durations[caption.segment_index]
|
duration = segment_durations[caption.segment_index]
|
||||||
caption.mpegts = MPEG_TIMESCALE * (duration / timescale)
|
caption.mpegts = MPEG_TIMESCALE * (duration / timescale)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from langcodes import Language
|
|||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.credential import Credential
|
from unshackle.core.credential import Credential
|
||||||
from unshackle.core.manifests import DASH
|
from unshackle.core.manifests import DASH
|
||||||
|
# from unshackle.core.manifests import HLS
|
||||||
from unshackle.core.search_result import SearchResult
|
from unshackle.core.search_result import SearchResult
|
||||||
from unshackle.core.service import Service
|
from unshackle.core.service import Service
|
||||||
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||||
@@ -35,6 +36,12 @@ class EXAMPLE(Service):
|
|||||||
GEOFENCE = ("US", "UK")
|
GEOFENCE = ("US", "UK")
|
||||||
NO_SUBTITLES = True
|
NO_SUBTITLES = True
|
||||||
|
|
||||||
|
VIDEO_RANGE_MAP = {
|
||||||
|
"SDR": "sdr",
|
||||||
|
"HDR10": "hdr10",
|
||||||
|
"DV": "dolby_vision",
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@click.command(name="EXAMPLE", short_help="https://domain.com")
|
@click.command(name="EXAMPLE", short_help="https://domain.com")
|
||||||
@click.argument("title", type=str)
|
@click.argument("title", type=str)
|
||||||
@@ -52,17 +59,23 @@ class EXAMPLE(Service):
|
|||||||
self.device = device
|
self.device = device
|
||||||
self.cdm = ctx.obj.cdm
|
self.cdm = ctx.obj.cdm
|
||||||
|
|
||||||
# Get range parameter for HDR support
|
# self.track_request is set by Service.__init__() from CLI params
|
||||||
range_param = ctx.parent.params.get("range_")
|
# Contains: codecs (list[Video.Codec]), ranges (list[Video.Range]), best_available (bool)
|
||||||
self.range = range_param[0].name if range_param else "SDR"
|
|
||||||
|
# Override codec for HDR ranges (HDR requires HEVC)
|
||||||
|
if any(r != Video.Range.SDR for r in self.track_request.ranges):
|
||||||
|
self.track_request.codecs = [Video.Codec.HEVC]
|
||||||
|
|
||||||
|
# Override for L3 CDM limitations
|
||||||
|
if self.cdm and self.cdm.security_level == 3:
|
||||||
|
self.track_request.codecs = [Video.Codec.AVC]
|
||||||
|
self.track_request.ranges = [Video.Range.SDR]
|
||||||
|
|
||||||
if self.config is None:
|
if self.config is None:
|
||||||
raise Exception("Config is missing!")
|
raise Exception("Config is missing!")
|
||||||
else:
|
|
||||||
profile_name = ctx.parent.params.get("profile")
|
profile_name = ctx.parent.params.get("profile")
|
||||||
if profile_name is None:
|
self.profile = profile_name or "default"
|
||||||
profile_name = "default"
|
|
||||||
self.profile = profile_name
|
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||||
super().authenticate(cookies, credential)
|
super().authenticate(cookies, credential)
|
||||||
@@ -165,78 +178,83 @@ class EXAMPLE(Service):
|
|||||||
)
|
)
|
||||||
return Series(episodes)
|
return Series(episodes)
|
||||||
|
|
||||||
|
# DASH Example: Service requires separate API calls per codec/range.
|
||||||
|
# Uses _get_tracks_for_variants() which iterates codecs x ranges,
|
||||||
|
# handles HYBRID (HDR10+DV), and best_available fallback.
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
# Handle HYBRID mode by fetching both HDR10 and DV tracks separately
|
def _fetch_variant(
|
||||||
if self.range == "HYBRID" and self.cdm.security_level != 3:
|
title: Title_T,
|
||||||
tracks = Tracks()
|
codec: Optional[Video.Codec],
|
||||||
|
range_: Video.Range,
|
||||||
|
) -> Tracks:
|
||||||
|
vcodec_str = "H265" if codec == Video.Codec.HEVC else "H264"
|
||||||
|
range_str = range_.name
|
||||||
|
video_format = self.VIDEO_RANGE_MAP.get(range_str, "sdr")
|
||||||
|
|
||||||
# Get HDR10 tracks
|
self.log.info(f" + Fetching {vcodec_str} {range_str} manifest")
|
||||||
hdr10_tracks = self._get_tracks_for_range(title, "HDR10")
|
tracks = self._fetch_dash_manifest(title, vcodec=vcodec_str, video_format=video_format)
|
||||||
tracks.add(hdr10_tracks, warn_only=True)
|
|
||||||
|
|
||||||
# Get DV tracks
|
expected_range = {
|
||||||
dv_tracks = self._get_tracks_for_range(title, "DV")
|
"HDR10": Video.Range.HDR10,
|
||||||
tracks.add(dv_tracks, warn_only=True)
|
"DV": Video.Range.DV,
|
||||||
|
}.get(range_str)
|
||||||
|
if expected_range and not any(v.range == expected_range for v in tracks.videos):
|
||||||
|
raise ValueError(f"{range_str} requested but no {range_str} tracks available")
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
else:
|
|
||||||
# Normal single-range behavior
|
|
||||||
return self._get_tracks_for_range(title, self.range)
|
|
||||||
|
|
||||||
def _get_tracks_for_range(self, title: Title_T, range_override: str = None) -> Tracks:
|
return self._get_tracks_for_variants(title, _fetch_variant)
|
||||||
# Use range_override if provided, otherwise use self.range
|
|
||||||
current_range = range_override if range_override else self.range
|
|
||||||
|
|
||||||
# Build API request parameters
|
# HLS Example: Service returns all codecs/ranges in one master playlist.
|
||||||
params = {
|
# No need for _get_tracks_for_variants, dl.py filters by user selection.
|
||||||
"token": self.token,
|
#
|
||||||
"guid": title.id,
|
# def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
}
|
# playback = self.session.get(
|
||||||
|
# url=self.config["endpoints"]["playback"].format(title_id=title.id),
|
||||||
data = {
|
# params={"token": self.token},
|
||||||
"type": self.config["client"][self.device]["type"],
|
# ).json()
|
||||||
}
|
# return HLS.from_url(
|
||||||
|
# url=playback["manifest_url"],
|
||||||
# Add range-specific parameters
|
# session=self.session,
|
||||||
if current_range == "HDR10":
|
# ).to_tracks(title.language)
|
||||||
data["video_format"] = "hdr10"
|
|
||||||
elif current_range == "DV":
|
|
||||||
data["video_format"] = "dolby_vision"
|
|
||||||
else:
|
|
||||||
data["video_format"] = "sdr"
|
|
||||||
|
|
||||||
# Only request high-quality HDR content with L1 CDM
|
|
||||||
if current_range in ("HDR10", "DV") and self.cdm.security_level == 3:
|
|
||||||
# L3 CDM - skip HDR content
|
|
||||||
return Tracks()
|
|
||||||
|
|
||||||
|
def _fetch_dash_manifest(
|
||||||
|
self,
|
||||||
|
title: Title_T,
|
||||||
|
vcodec: str = "H264",
|
||||||
|
video_format: str = "sdr",
|
||||||
|
) -> Tracks:
|
||||||
streams = self.session.post(
|
streams = self.session.post(
|
||||||
url=self.config["endpoints"]["streams"],
|
url=self.config["endpoints"]["streams"],
|
||||||
params=params,
|
params={
|
||||||
data=data,
|
"token": self.token,
|
||||||
|
"guid": title.id,
|
||||||
|
},
|
||||||
|
data={
|
||||||
|
"type": self.config["client"][self.device]["type"],
|
||||||
|
"video_format": video_format,
|
||||||
|
"video_codec": vcodec,
|
||||||
|
},
|
||||||
).json()["media"]
|
).json()["media"]
|
||||||
|
|
||||||
self.license = {
|
self.license_data = {
|
||||||
"url": streams["drm"]["url"],
|
"url": streams["drm"]["url"],
|
||||||
"data": streams["drm"]["data"],
|
"data": streams["drm"]["data"],
|
||||||
"session": streams["drm"]["session"],
|
"session": streams["drm"]["session"],
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest_url = streams["url"].split("?")[0]
|
manifest_url = streams["url"].split("?")[0]
|
||||||
|
|
||||||
self.log.debug(f"Manifest URL: {manifest_url}")
|
self.log.debug(f"Manifest URL: {manifest_url}")
|
||||||
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
|
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
|
||||||
|
|
||||||
# Set range attributes on video tracks
|
range_enum = {
|
||||||
|
"hdr10": Video.Range.HDR10,
|
||||||
|
"dolby_vision": Video.Range.DV,
|
||||||
|
}.get(video_format, Video.Range.SDR)
|
||||||
for video in tracks.videos:
|
for video in tracks.videos:
|
||||||
if current_range == "HDR10":
|
video.range = range_enum
|
||||||
video.range = Video.Range.HDR10
|
|
||||||
elif current_range == "DV":
|
|
||||||
video.range = Video.Range.DV
|
|
||||||
else:
|
|
||||||
video.range = Video.Range.SDR
|
|
||||||
|
|
||||||
# Remove DRM-free ("clear") audio tracks
|
|
||||||
tracks.audio = [
|
tracks.audio = [
|
||||||
track for track in tracks.audio if "clear" not in track.data["dash"]["representation"].get("id")
|
track for track in tracks.audio if "clear" not in track.data["dash"]["representation"].get("id")
|
||||||
]
|
]
|
||||||
@@ -257,14 +275,14 @@ class EXAMPLE(Service):
|
|||||||
url=subtitle["url"],
|
url=subtitle["url"],
|
||||||
codec=Subtitle.Codec.from_mime("vtt"),
|
codec=Subtitle.Codec.from_mime("vtt"),
|
||||||
language=Language.get(subtitle["language"]),
|
language=Language.get(subtitle["language"]),
|
||||||
# cc=True if '(cc)' in subtitle['name'] else False,
|
|
||||||
sdh=True,
|
sdh=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self.movie:
|
if not self.movie:
|
||||||
title.data["chapters"] = self.session.get(
|
title.data["chapters"] = self.session.get(
|
||||||
url=self.config["endpoints"]["metadata"].format(title_id=title.id), params={"token": self.token}
|
url=self.config["endpoints"]["metadata"].format(title_id=title.id),
|
||||||
|
params={"token": self.token},
|
||||||
).json()["chapters"]
|
).json()["chapters"]
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
@@ -283,12 +301,9 @@ class EXAMPLE(Service):
|
|||||||
return chapters
|
return chapters
|
||||||
|
|
||||||
def get_widevine_service_certificate(self, **_: any) -> str:
|
def get_widevine_service_certificate(self, **_: any) -> str:
|
||||||
"""Return the Widevine service certificate from config, if available."""
|
|
||||||
return self.config.get("certificate")
|
return self.config.get("certificate")
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||||
"""Retrieve a PlayReady license for a given track."""
|
|
||||||
|
|
||||||
license_url = self.config["endpoints"].get("playready_license")
|
license_url = self.config["endpoints"].get("playready_license")
|
||||||
if not license_url:
|
if not license_url:
|
||||||
raise ValueError("PlayReady license endpoint not configured")
|
raise ValueError("PlayReady license endpoint not configured")
|
||||||
@@ -304,7 +319,7 @@ class EXAMPLE(Service):
|
|||||||
return response.content
|
return response.content
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
license_url = self.license.get("url") or self.config["endpoints"].get("widevine_license")
|
license_url = self.license_data.get("url") or self.config["endpoints"].get("widevine_license")
|
||||||
if not license_url:
|
if not license_url:
|
||||||
raise ValueError("Widevine license endpoint not configured")
|
raise ValueError("Widevine license endpoint not configured")
|
||||||
|
|
||||||
@@ -312,11 +327,11 @@ class EXAMPLE(Service):
|
|||||||
url=license_url,
|
url=license_url,
|
||||||
data=challenge,
|
data=challenge,
|
||||||
params={
|
params={
|
||||||
"session": self.license.get("session"),
|
"session": self.license_data.get("session"),
|
||||||
"userId": self.user_id,
|
"userId": self.user_id,
|
||||||
},
|
},
|
||||||
headers={
|
headers={
|
||||||
"dt-custom-data": self.license.get("data"),
|
"dt-custom-data": self.license_data.get("data"),
|
||||||
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -275,12 +275,12 @@ remote_cdm:
|
|||||||
|
|
||||||
# PyPlayReady RemoteCdm - connects to an unshackle serve instance
|
# PyPlayReady RemoteCdm - connects to an unshackle serve instance
|
||||||
- name: "playready_remote"
|
- name: "playready_remote"
|
||||||
Device Type: PLAYREADY
|
device_name: "my_prd_device" # Device name on the serve instance
|
||||||
System ID: 0
|
device_type: PLAYREADY
|
||||||
Security Level: 3000 # 2000 for SL2000, 3000 for SL3000
|
system_id: 0
|
||||||
Host: "http://127.0.0.1:8786/playready" # Include /playready path
|
security_level: 3000 # 2000 for SL2000, 3000 for SL3000
|
||||||
Secret: "your-api-secret-key"
|
host: "http://127.0.0.1:8786/playready" # Include /playready path
|
||||||
Device Name: "my_prd_device" # Device name on the serve instance
|
secret: "your-api-secret-key"
|
||||||
|
|
||||||
# Key Vaults store your obtained Content Encryption Keys (CEKs)
|
# Key Vaults store your obtained Content Encryption Keys (CEKs)
|
||||||
# Use 'no_push: true' to prevent a vault from receiving pushed keys
|
# Use 'no_push: true' to prevent a vault from receiving pushed keys
|
||||||
|
|||||||
Reference in New Issue
Block a user