Merge branch 'dev' into service.py

This commit is contained in:
CodeName393
2026-02-25 19:21:22 +09:00
committed by GitHub
19 changed files with 625 additions and 1606 deletions

1
.gitignore vendored
View File

@@ -27,6 +27,7 @@ unshackle/PRDs/
temp/
logs/
services/
/.[^/]*/
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@@ -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

1483
CONFIG.md

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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"
```
---

View File

@@ -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.

View File

@@ -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.
---

View File

@@ -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
```
---

View File

@@ -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"

View File

@@ -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)

View File

@@ -1 +1 @@
__version__ = "3.0.0"
__version__ = "3.1.0"

View File

@@ -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")

View File

@@ -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])

View File

@@ -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")

View File

@@ -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.

2
uv.lock generated
View File

@@ -1627,7 +1627,7 @@ wheels = [
[[package]]
name = "unshackle"
version = "3.0.0"
version = "3.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },