Merge branch 'dev' into service.py
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@ unshackle/PRDs/
|
||||
temp/
|
||||
logs/
|
||||
services/
|
||||
/.[^/]*/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -6,6 +6,31 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
This changelog is automatically generated using [git-cliff](https://git-cliff.org).
|
||||
|
||||
## [3.1.0] - 2026-02-23
|
||||
|
||||
### Features
|
||||
|
||||
- *hybrid*: Add L5 active area and dynamic L6 luminance metadata
|
||||
- *debug*: Add JSONL debug logging to decryption, muxing, and all downloaders
|
||||
- *debug*: Log binary tool versions at session start
|
||||
- *dl*: Add --repack flag to insert REPACK tag in output filenames
|
||||
- *core*: Add TrackRequest system for multi-codec/multi-range support
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- *n_m3u8dl_re*: Pass all content keys for DualKey DRM decryption
|
||||
- *hybrid*: Skip bitrate filter for DV tracks in HYBRID mode
|
||||
- *attachment*: Sanitize filenames with illegal Windows characters
|
||||
- *hybrid*: Accept HDR10+ tracks as valid base layer for HYBRID mode
|
||||
- *dl*: Allow selection of audio tracks for 'all' languages in addition to 'best'
|
||||
- *dl*: Overwrite existing files on re-download and use atomic replace
|
||||
- *dl*: Handle cross-device moves when temp and downloads differ
|
||||
|
||||
### Changes
|
||||
|
||||
- *hybrid*: Replace log.info with console status and add JSONL debug logging
|
||||
- *dl*: Remove legacy multi-fetch loop for unmigrated services
|
||||
|
||||
## [3.0.0] - 2026-02-15
|
||||
|
||||
### Features
|
||||
@@ -103,6 +128,10 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
|
||||
|
||||
- *api*: Remove remote services
|
||||
|
||||
### Chore
|
||||
|
||||
- *release*: [**breaking**] Bump version to 3.0.0
|
||||
|
||||
## [2.3.0] - 2026-01-18
|
||||
|
||||
### Features
|
||||
@@ -482,6 +511,7 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
|
||||
- Reorganize Planned Features section in README for clarity
|
||||
- Improve track selection logic in dl.py
|
||||
|
||||
[3.1.0]: https://github.com/unshackle-dl/unshackle/compare/3.0.0..3.1.0
|
||||
[3.0.0]: https://github.com/unshackle-dl/unshackle/compare/2.3.0..3.0.0
|
||||
[2.3.0]: https://github.com/unshackle-dl/unshackle/compare/2.2.0..2.3.0
|
||||
[2.2.0]: https://github.com/unshackle-dl/unshackle/compare/2.1.0..2.2.0
|
||||
|
||||
@@ -54,7 +54,9 @@ Options:
|
||||
- `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>
|
||||
|
||||
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:
|
||||
|
||||
@@ -72,10 +74,12 @@ The `default` entry is optional. If omitted, `requests` will be used for service
|
||||
|
||||
## 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`
|
||||
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`
|
||||
Keyword to identify and potentially skip advertisement segments. Default: `None`
|
||||
- `use_proxy`
|
||||
@@ -83,6 +87,9 @@ Configuration for N_m3u8DL-RE downloader. This downloader is particularly useful
|
||||
- `retry_count`
|
||||
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,
|
||||
|
||||
```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)
|
||||
|
||||
Choose what software to use to decrypt DRM-protected content throughout unshackle where needed.
|
||||
|
||||
@@ -41,6 +41,29 @@ DSNP:
|
||||
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])
|
||||
@@ -113,8 +136,10 @@ remote_cdm:
|
||||
- Support for both Widevine and PlayReady
|
||||
- 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
|
||||
but are used for internal device identification.
|
||||
**Note:** The `device_type` field determines whether the CDM operates in PlayReady or Widevine mode.
|
||||
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
|
||||
|
||||
@@ -171,7 +196,7 @@ remote_cdm:
|
||||
header_name: X-API-Key
|
||||
key: YOUR_SECRET_KEY
|
||||
custom_headers:
|
||||
User-Agent: Unshackle/2.0.0
|
||||
User-Agent: Unshackle/3.1.0
|
||||
X-Client-Version: "1.0"
|
||||
|
||||
# Endpoint configuration
|
||||
@@ -224,6 +249,7 @@ remote_cdm:
|
||||
- `header` - Custom header authentication
|
||||
- `basic` - HTTP Basic authentication
|
||||
- `body` - Credentials in request body
|
||||
- `query` - Authentication added to query string parameters
|
||||
|
||||
### 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 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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -104,6 +104,13 @@ providers:
|
||||
credentials:
|
||||
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:
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
```yaml
|
||||
@@ -133,17 +180,19 @@ proxy_providers:
|
||||
base_port: 8888 # Starting port (default: 8888)
|
||||
auto_cleanup: true # Remove containers on exit (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_password: password
|
||||
auth_password: password # Proxy auth (optional)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Container Reuse**: First request takes 10-30s; subsequent requests are instant
|
||||
- **IP Verification**: Automatically verifies VPN exit IP matches requested region
|
||||
- **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 (configurable via `verify_ip`)
|
||||
- **Concurrent Sessions**: Multiple downloads share the same container
|
||||
- **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
|
||||
|
||||
@@ -174,8 +223,9 @@ docker logs unshackle-gluetun-nordvpn-us
|
||||
|
||||
Common issues:
|
||||
- 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
|
||||
- Container startup timeout (default 60 seconds)
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ system where required.
|
||||
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.
|
||||
|
||||
You can also select servers by city using the format `--proxy nordvpn:us:seattle` or `--proxy nordvpn:ca:calgary`.
|
||||
|
||||
For example,
|
||||
|
||||
```yaml
|
||||
@@ -48,8 +50,8 @@ server_map:
|
||||
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.
|
||||
|
||||
Once set, you can also specifically opt in to use a NordVPN proxy by specifying `--proxy=gb` or such.
|
||||
You can even set a specific server number this way, e.g., `--proxy=gb2366`.
|
||||
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 nordvpn:gb2366`.
|
||||
|
||||
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).
|
||||
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
|
||||
username: your_surfshark_service_username # https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
||||
password: your_surfshark_service_password # service credentials, not account password
|
||||
@@ -67,16 +71,13 @@ server_map:
|
||||
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.
|
||||
No configuration is needed under `proxy_providers`. Hola is loaded automatically when the `hola-proxy` binary
|
||||
is detected.
|
||||
|
||||
```yaml
|
||||
proxy_providers:
|
||||
hola: {}
|
||||
```
|
||||
|
||||
Once configured, use `--proxy hola:us` or similar to connect through Hola.
|
||||
Once available, use `--proxy hola:us` or similar to connect through Hola.
|
||||
|
||||
### windscribevpn (dict)
|
||||
|
||||
@@ -105,32 +106,28 @@ proxy_providers:
|
||||
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
|
||||
system where required.
|
||||
|
||||
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,
|
||||
Docker-managed VPN proxy supporting 50+ VPN providers via Gluetun. See [GLUETUN.md](GLUETUN.md) for full
|
||||
configuration and usage details.
|
||||
|
||||
```yaml
|
||||
nordvpn:
|
||||
username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format
|
||||
password: wXVHmht22hhRKUEQ32PQVjCZ
|
||||
server_map:
|
||||
us: 12 # force US server #12 for US proxies
|
||||
proxy_providers:
|
||||
gluetun:
|
||||
providers:
|
||||
windscribe:
|
||||
vpn_type: openvpn
|
||||
credentials:
|
||||
username: "YOUR_OPENVPN_USERNAME"
|
||||
password: "YOUR_OPENVPN_PASSWORD"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Note that `gb` is used instead of `uk` to be more consistent across regional systems.
|
||||
Usage: `--proxy gluetun:windscribe:us`
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
I recommend using,
|
||||
|
||||
```yaml
|
||||
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)
|
||||
|
||||
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 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,
|
||||
|
||||
```yaml
|
||||
downloads: "D:/Downloads/unshackle"
|
||||
temp: "D:/Temp/unshackle"
|
||||
directories:
|
||||
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.
|
||||
|
||||
@@ -4,12 +4,12 @@ This document covers service-specific configuration, authentication, and metadat
|
||||
|
||||
## services (dict)
|
||||
|
||||
Configuration data for each Service. The Service will have the data within this section merged into the `config.yaml`
|
||||
before provided to the Service class.
|
||||
Configuration data for each Service. The Service will have the data within this section merged into the per-service
|
||||
`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,
|
||||
device attributes, and so on. A `config.yaml` file is typically shared and not meant to be modified, so use this for
|
||||
any sensitive configuration data.
|
||||
device attributes, and so on. A per-service `config.yaml` file is typically shared and not meant to be modified,
|
||||
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
|
||||
a dictionary.
|
||||
@@ -23,6 +23,29 @@ NOW:
|
||||
# ... 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])
|
||||
@@ -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)`.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
- `conversion_method`: How to convert subtitles between formats. Default: `pysubs2`.
|
||||
- `auto`: Use subby for WebVTT/SAMI, standard for others.
|
||||
- `conversion_method`: How to convert subtitles between formats. Default: `auto`.
|
||||
- `auto`: Smart routing - use subby for WebVTT/SAMI, pycaption for others.
|
||||
- `subby`: Always use subby with CommonIssuesFixer.
|
||||
- `subtitleedit`: Prefer SubtitleEdit when available; otherwise fallback to standard conversion.
|
||||
- `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`.
|
||||
|
||||
- `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:
|
||||
|
||||
```yaml
|
||||
subtitle:
|
||||
conversion_method: pysubs2
|
||||
conversion_method: auto
|
||||
sdh_method: auto
|
||||
strip_sdh: true
|
||||
convert_before_strip: true
|
||||
preserve_formatting: true
|
||||
output_mode: mux
|
||||
sidecar_format: srt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "unshackle"
|
||||
version = "3.0.0"
|
||||
version = "3.1.0"
|
||||
description = "Modular Movie, TV, and Music Archival Software."
|
||||
authors = [{ name = "unshackle team" }]
|
||||
requires-python = ">=3.10,<3.13"
|
||||
|
||||
@@ -64,7 +64,8 @@ from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger
|
||||
is_close_match, suggest_font_packages, time_elapsed_since)
|
||||
from unshackle.core.utils import tags
|
||||
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.selector import select_multiple
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
@@ -288,9 +289,9 @@ class dl:
|
||||
@click.option(
|
||||
"-v",
|
||||
"--vcodec",
|
||||
type=VideoCodecChoice(Video.Codec),
|
||||
default=None,
|
||||
help="Video Codec to download, defaults to any codec.",
|
||||
type=MultipleVideoCodecChoice(Video.Codec),
|
||||
default=[],
|
||||
help="Video Codec(s) to download, defaults to any codec.",
|
||||
)
|
||||
@click.option(
|
||||
"-a",
|
||||
@@ -486,6 +487,14 @@ class dl:
|
||||
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(
|
||||
"-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(
|
||||
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
|
||||
@@ -514,6 +523,7 @@ class dl:
|
||||
tmdb_id: Optional[int] = None,
|
||||
tmdb_name: bool = False,
|
||||
tmdb_year: bool = False,
|
||||
output_dir: Optional[Path] = None,
|
||||
*_: Any,
|
||||
**__: Any,
|
||||
):
|
||||
@@ -559,6 +569,7 @@ class dl:
|
||||
self.tmdb_id = tmdb_id
|
||||
self.tmdb_name = tmdb_name
|
||||
self.tmdb_year = tmdb_year
|
||||
self.output_dir = output_dir
|
||||
|
||||
# Initialize debug logger with service name if debug logging is enabled
|
||||
if config.debug or logging.root.level == logging.DEBUG:
|
||||
@@ -913,7 +924,7 @@ class dl:
|
||||
self,
|
||||
service: Service,
|
||||
quality: list[int],
|
||||
vcodec: Optional[Video.Codec],
|
||||
vcodec: list[Video.Codec],
|
||||
acodec: list[Audio.Codec],
|
||||
vbitrate: int,
|
||||
abitrate: int,
|
||||
@@ -1384,9 +1395,12 @@ class dl:
|
||||
if isinstance(title, (Movie, Episode)):
|
||||
# filter video tracks
|
||||
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:
|
||||
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)
|
||||
|
||||
if range_:
|
||||
@@ -1438,10 +1452,38 @@ class dl:
|
||||
self.log.error(f"There's no {processed_video_lang} Video Track...")
|
||||
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:
|
||||
missing_resolutions = []
|
||||
if any(r == Video.Range.HYBRID for r in range_):
|
||||
title.tracks.select_video(title.tracks.select_hybrid(title.tracks.videos, quality))
|
||||
if has_hybrid:
|
||||
# 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:
|
||||
title.tracks.by_resolutions(quality)
|
||||
|
||||
@@ -1468,21 +1510,63 @@ class dl:
|
||||
sys.exit(1)
|
||||
|
||||
# choose best track by range and quality
|
||||
if any(r == Video.Range.HYBRID for r in range_):
|
||||
# For hybrid mode, always apply hybrid selection
|
||||
# If no quality specified, use only the best (highest) resolution
|
||||
if has_hybrid:
|
||||
# Apply hybrid selection for HYBRID tracks
|
||||
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:
|
||||
# Get the highest resolution available
|
||||
best_resolution = max((v.height for v in title.tracks.videos), default=None)
|
||||
best_resolution = max(
|
||||
(v.height for v in hybrid_candidate_tracks), default=None
|
||||
)
|
||||
if best_resolution:
|
||||
# Use the hybrid selection logic with only the best resolution
|
||||
title.tracks.select_video(
|
||||
title.tracks.select_hybrid(title.tracks.videos, [best_resolution])
|
||||
hybrid_filter = title.tracks.select_hybrid(
|
||||
hybrid_candidate_tracks, [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:
|
||||
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(
|
||||
(
|
||||
t
|
||||
@@ -1493,6 +1577,7 @@ class dl:
|
||||
or int(t.width * (9 / 16)) == resolution
|
||||
)
|
||||
and (not color_range or t.range == color_range)
|
||||
and (not codec or t.codec == codec)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -1508,29 +1593,38 @@ class dl:
|
||||
]
|
||||
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
|
||||
|
||||
hybrid_failed = False
|
||||
if not base_tracks and not dv_tracks:
|
||||
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
||||
self.log.error(
|
||||
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available"
|
||||
)
|
||||
self.log.error(
|
||||
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available"
|
||||
msg_detail = (
|
||||
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
|
||||
)
|
||||
sys.exit(1)
|
||||
hybrid_failed = True
|
||||
elif not base_tracks:
|
||||
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
||||
self.log.error(
|
||||
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available"
|
||||
)
|
||||
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
|
||||
sys.exit(1)
|
||||
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available"
|
||||
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
|
||||
hybrid_failed = True
|
||||
elif not dv_tracks:
|
||||
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
||||
self.log.error(
|
||||
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available"
|
||||
)
|
||||
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
|
||||
sys.exit(1)
|
||||
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available"
|
||||
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
|
||||
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
|
||||
if require_subs:
|
||||
@@ -1777,11 +1871,6 @@ class dl:
|
||||
)
|
||||
self.cdm = quality_based_cdm
|
||||
|
||||
for track in title.tracks.subtitles:
|
||||
if callable(track.OnSegmentFilter) and track.downloader.__name__ == "n_m3u8dl_re":
|
||||
from unshackle.core.downloaders import requests as requests_downloader
|
||||
track.downloader = requests_downloader
|
||||
|
||||
dl_start_time = time.time()
|
||||
|
||||
try:
|
||||
@@ -2121,6 +2210,8 @@ class dl:
|
||||
task_description += f" {video_track.height}p"
|
||||
if len(range_) > 1:
|
||||
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
|
||||
if video_track:
|
||||
@@ -2172,7 +2263,7 @@ class dl:
|
||||
else:
|
||||
base_filename = str(title)
|
||||
|
||||
sidecar_dir = config.directories.downloads
|
||||
sidecar_dir = self.output_dir or config.directories.downloads
|
||||
if not no_folder and isinstance(title, (Episode, Song)) and media_info:
|
||||
sidecar_dir /= title.get_filename(
|
||||
media_info, show_service=not no_source, folder=True
|
||||
@@ -2226,7 +2317,7 @@ class dl:
|
||||
|
||||
if no_mux:
|
||||
# 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)):
|
||||
# Create folder based on title
|
||||
# Use first available track for filename generation
|
||||
@@ -2279,7 +2370,7 @@ class dl:
|
||||
used_final_paths: set[Path] = set()
|
||||
for muxed_path in muxed_paths:
|
||||
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)
|
||||
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.0.0"
|
||||
__version__ = "3.1.0"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import base64
|
||||
import logging
|
||||
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 pathlib import Path
|
||||
from typing import Optional, Union
|
||||
@@ -24,9 +25,26 @@ from unshackle.core.search_result import SearchResult
|
||||
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.tracks import Chapters, Tracks
|
||||
from unshackle.core.tracks.video import Video
|
||||
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).
|
||||
@@ -89,6 +107,16 @@ class Service(metaclass=ABCMeta):
|
||||
self.credential = None # Will be set in authenticate()
|
||||
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 ctx.parent:
|
||||
proxy = ctx.parent.params["proxy"]
|
||||
@@ -205,6 +233,76 @@ class Service(metaclass=ABCMeta):
|
||||
self.log.debug(f"Failed to get cached IP info: {e}")
|
||||
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
|
||||
# The following functions may be implemented by the Service.
|
||||
# Otherwise, the base service code (if any) of the function will be executed on call.
|
||||
@@ -222,7 +320,7 @@ class Service(metaclass=ABCMeta):
|
||||
session.mount(
|
||||
"https://",
|
||||
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,
|
||||
),
|
||||
)
|
||||
@@ -482,4 +580,4 @@ class Service(metaclass=ABCMeta):
|
||||
"""
|
||||
|
||||
|
||||
__all__ = ("Service",)
|
||||
__all__ = ("Service", "TrackRequest")
|
||||
|
||||
@@ -56,7 +56,7 @@ class MaxRetriesError(exceptions.RequestException):
|
||||
class CurlSession(Session):
|
||||
def __init__(
|
||||
self,
|
||||
max_retries: int = 10,
|
||||
max_retries: int = 5,
|
||||
backoff_factor: float = 0.2,
|
||||
max_backoff: float = 60.0,
|
||||
status_forcelist: list[int] | None = None,
|
||||
@@ -150,7 +150,7 @@ def session(
|
||||
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
|
||||
fingerprint preset name (e.g. "okhttp4").
|
||||
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.
|
||||
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.
|
||||
@@ -172,7 +172,7 @@ def session(
|
||||
- cert: Client certificate (str or tuple)
|
||||
|
||||
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)
|
||||
- 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])
|
||||
|
||||
@@ -24,7 +24,7 @@ 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.drm import DRM_T, PlayReady, Widevine
|
||||
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
|
||||
|
||||
|
||||
@@ -210,23 +210,12 @@ class Track:
|
||||
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
||||
if track_type == "Subtitle":
|
||||
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 (
|
||||
self.descriptor == self.Descriptor.URL
|
||||
or get_extension(self.url)
|
||||
in {
|
||||
".srt",
|
||||
".vtt",
|
||||
".ttml",
|
||||
".ssa",
|
||||
".ass",
|
||||
".stpp",
|
||||
".wvtt",
|
||||
".xml",
|
||||
}
|
||||
):
|
||||
self.downloader = requests
|
||||
|
||||
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
||||
self.descriptor == self.Descriptor.URL
|
||||
or track_type in ("Subtitle", "Attachment")
|
||||
):
|
||||
self.downloader = requests
|
||||
|
||||
if self.descriptor != self.Descriptor.URL:
|
||||
save_dir = save_path.with_name(save_path.name + "_segments")
|
||||
|
||||
@@ -44,6 +44,33 @@ class VideoCodecChoice(click.Choice):
|
||||
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):
|
||||
"""
|
||||
A custom Choice type for subtitle codecs that accepts both enum names, values, and common aliases.
|
||||
|
||||
Reference in New Issue
Block a user