4 Commits

9 changed files with 157 additions and 32 deletions

View File

@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.3] - 2025-08-20
### Added
- Cached IP info helper for region detection
- New `get_cached_ip_info()` with 24h cache and provider rotation (ipinfo/ipapi) with 429 handling.
- Reduces external calls and stabilizes non-proxy region lookups for caching/logging.
### Changed
- DRM decryption selection is fully configuration-driven
- Widevine and PlayReady now select the decrypter based solely on `decryption` in YAML (including per-service mapping).
- Shaka Packager remains the default decrypter when not specified.
- `dl.py` logs the chosen tool based on the resolved configuration.
- Geofencing and proxy verification improvements
- Safer geofence checks with error handling and clearer logs.
- Always verify proxy exit region via live IP lookup; fallback to proxy parsing on failure.
- Example config updated to default to Shaka
- `unshackle.yaml`/example now sets `decryption.default: shaka` (service overrides still supported).
### Removed
- Deprecated parameter `use_mp4decrypt`
- Removed from `Widevine.decrypt()` and `PlayReady.decrypt()` and all callsites.
- Internal naming switched from mp4decrypt-specific flags to generic `decrypter` selection.
## [1.4.2] - 2025-08-14 ## [1.4.2] - 2025-08-14
### Added ### Added

110
CONFIG.md
View File

@@ -141,6 +141,11 @@ The following directories are available and may be overridden,
- `logs` - Logs. - `logs` - Logs.
- `wvds` - Widevine Devices. - `wvds` - Widevine Devices.
- `prds` - PlayReady Devices. - `prds` - PlayReady Devices.
- `dcsl` - Device Certificate Status List.
Notes:
- `services` accepts either a single directory or a list of directories to search for service modules.
For example, For example,
@@ -165,6 +170,14 @@ For example to set the default primary language to download to German,
lang: de lang: de
``` ```
You can also set multiple preferred languages using a list, e.g.,
```yaml
lang:
- en
- fr
```
to set how many tracks to download concurrently to 4 and download threads to 16, to set how many tracks to download concurrently to 4 and download threads to 16,
```yaml ```yaml
@@ -302,6 +315,11 @@ Note: SQLite and MySQL vaults have to connect directly to the Host/IP. It cannot
Beware that some Hosting Providers do not let you access the MySQL server outside their intranet and may not be Beware that some Hosting Providers do not let you access the MySQL server outside their intranet and may not be
accessible outside their hosting platform. accessible outside their hosting platform.
Additional behavior:
- `no_push` (bool): Optional per-vault flag. When `true`, the vault will not receive pushed keys (writes) but
will still be queried and can provide keys for lookups. Useful for read-only/backup vaults.
### Using an API Vault ### Using an API Vault
API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may
@@ -314,6 +332,7 @@ not work in unshackle. The API format can be seen in the [API Vault Code](unshac
# uri: "127.0.0.1:80/key-vault" # uri: "127.0.0.1:80/key-vault"
# uri: "https://api.example.com/key-vault" # uri: "https://api.example.com/key-vault"
token: "random secret key" # authorization token token: "random secret key" # authorization token
# no_push: true # optional; make this API vault read-only (lookups only)
``` ```
### Using a MySQL Vault ### Using a MySQL Vault
@@ -329,6 +348,7 @@ A MySQL Vault can be on a local or remote network, but I recommend SQLite for lo
database: vault # database used for unshackle database: vault # database used for unshackle
username: jane11 username: jane11
password: Doe123 password: Doe123
# no_push: false # optional; defaults to false
``` ```
I recommend giving only a trustable user (or yourself) CREATE permission and then use unshackle to cache at least one CEK I recommend giving only a trustable user (or yourself) CREATE permission and then use unshackle to cache at least one CEK
@@ -352,6 +372,7 @@ case something happens to your MySQL Vault.
- type: SQLite - type: SQLite
name: "My Local Vault" # arbitrary vault name name: "My Local Vault" # arbitrary vault name
path: "C:/Users/Jane11/Documents/unshackle/data/key_vault.db" path: "C:/Users/Jane11/Documents/unshackle/data/key_vault.db"
# no_push: true # optional; commonly true for local backup vaults
``` ```
**Note**: You do not need to create the file at the specified path. **Note**: You do not need to create the file at the specified path.
@@ -394,7 +415,7 @@ n_m3u8dl_re:
Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy
system where required. system where required.
You can also specify specific servers to use per-region with the `servers` key. You can also specify specific servers to use per-region with the `server_map` key.
Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps. Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps.
For example, For example,
@@ -403,8 +424,8 @@ For example,
nordvpn: nordvpn:
username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format
password: wXVHmht22hhRKUEQ32PQVjCZ password: wXVHmht22hhRKUEQ32PQVjCZ
servers: server_map:
- us: 12 # force US server #12 for US proxies us: 12 # force US server #12 for US proxies
``` ```
The username and password should NOT be your normal NordVPN Account Credentials. The username and password should NOT be your normal NordVPN Account Credentials.
@@ -443,7 +464,7 @@ second proxy of the US list.
Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy
system where required. system where required.
You can also specify specific servers to use per-region with the `servers` key. You can also specify specific servers to use per-region with the `server_map` key.
Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps. Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps.
For example, For example,
@@ -451,8 +472,8 @@ For example,
```yaml ```yaml
username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format
password: wXVHmht22hhRKUEQ32PQVjCZ password: wXVHmht22hhRKUEQ32PQVjCZ
servers: server_map:
- us: 12 # force US server #12 for US proxies us: 12 # force US server #12 for US proxies
``` ```
The username and password should NOT be your normal NordVPN Account Credentials. The username and password should NOT be your normal NordVPN Account Credentials.
@@ -463,6 +484,20 @@ You can even set a specific server number this way, e.g., `--proxy=gb2366`.
Note that `gb` is used instead of `uk` to be more consistent across regional systems. Note that `gb` is used instead of `uk` to be more consistent across regional systems.
### surfsharkvpn (dict)
Enable Surfshark VPN proxy service using Surfshark Service credentials (not your login password).
You may pin specific server IDs per region using `server_map`.
```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
server_map:
us: 3844 # force US server #3844
gb: 2697 # force GB server #2697
au: 4621 # force AU server #4621
```
### hola (dict) ### hola (dict)
Enable Hola VPN proxy service. This is a simple provider that doesn't require configuration. Enable Hola VPN proxy service. This is a simple provider that doesn't require configuration.
@@ -497,6 +532,15 @@ For example,
[pywidevine]: https://github.com/rlaphoenix/pywidevine [pywidevine]: https://github.com/rlaphoenix/pywidevine
## scene_naming (bool)
Set scene-style naming for titles. When `true` uses scene naming patterns (e.g., `Prime.Suspect.S07E01...`), when
`false` uses a more human-readable style (e.g., `Prime Suspect S07E01 ...`). Default: `true`.
## series_year (bool)
Whether to include the series year in series names for episodes and folders. Default: `true`.
## serve (dict) ## serve (dict)
Configuration data for pywidevine's serve functionality run through unshackle. Configuration data for pywidevine's serve functionality run through unshackle.
@@ -561,6 +605,27 @@ set_terminal_bg: true
Group or Username to postfix to the end of all download filenames following a dash. Group or Username to postfix to the end of all download filenames following a dash.
For example, `tag: "J0HN"` will have `-J0HN` at the end of all download filenames. For example, `tag: "J0HN"` will have `-J0HN` at the end of all download filenames.
## tag_group_name (bool)
Enable/disable tagging downloads with your group name when `tag` is set. Default: `true`.
## tag_imdb_tmdb (bool)
Enable/disable tagging downloaded files with IMDB/TMDB/TVDB identifiers (when available). Default: `true`.
## title_cache_enabled (bool)
Enable/disable caching of title metadata to reduce redundant API calls. Default: `true`.
## title_cache_time (int)
Cache duration in seconds for title metadata. Default: `1800` (30 minutes).
## title_cache_max_retention (int)
Maximum retention time in seconds for serving slightly stale cached title metadata when API calls fail.
Default: `86400` (24 hours). Effective retention is `min(title_cache_time + grace, title_cache_max_retention)`.
## tmdb_api_key (str) ## tmdb_api_key (str)
API key for The Movie Database (TMDB). This is used for tagging downloaded files with TMDB, API key for The Movie Database (TMDB). This is used for tagging downloaded files with TMDB,
@@ -580,3 +645,36 @@ tmdb_api_key: cf66bf18956kca5311ada3bebb84eb9a # Not a real key
``` ```
**Note**: Keep your API key secure and do not share it publicly. This key is used by the core/utils/tags.py module to fetch metadata from TMDB for proper file tagging. **Note**: Keep your API key secure and do not share it publicly. This key is used by the core/utils/tags.py module to fetch metadata from TMDB for proper file tagging.
## subtitle (dict)
Control subtitle conversion and SDH (hearing-impaired) stripping behavior.
- `conversion_method`: How to convert subtitles between formats. Default: `auto`.
- `auto`: Use subby for WebVTT/SAMI, standard 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).
- `sdh_method`: How to strip SDH cues. Default: `auto`.
- `auto`: Try subby for SRT first, then SubtitleEdit, then filter-subs.
- `subby`: Use subbys SDHStripper (SRT only).
- `subtitleedit`: Use SubtitleEdits RemoveTextForHI when available.
- `filter-subs`: Use the subtitle-filter library.
Example:
```yaml
subtitle:
conversion_method: auto
sdh_method: auto
```
## update_checks (bool)
Check for updates from the GitHub repository on startup. Default: `true`.
## update_check_interval (int)
How often to check for updates, in hours. Default: `24`.

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "unshackle" name = "unshackle"
version = "1.4.2" version = "1.4.3"
description = "Modular Movie, TV, and Music Archival Software." description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "unshackle team" }] authors = [{ name = "unshackle team" }]
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"

View File

@@ -248,7 +248,9 @@ class dl:
) )
@click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.") @click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.")
@click.option("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.") @click.option("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.")
@click.option("--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching.") @click.option(
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
)
@click.pass_context @click.pass_context
def cli(ctx: click.Context, **kwargs: Any) -> dl: def cli(ctx: click.Context, **kwargs: Any) -> dl:
return dl(ctx, **kwargs) return dl(ctx, **kwargs)
@@ -294,6 +296,9 @@ class dl:
if getattr(config, "downloader_map", None): if getattr(config, "downloader_map", None):
config.downloader = config.downloader_map.get(self.service, config.downloader) config.downloader = config.downloader_map.get(self.service, config.downloader)
if getattr(config, "decryption_map", None):
config.decryption = config.decryption_map.get(self.service, config.decryption)
with console.status("Loading DRM CDM...", spinner="dots"): with console.status("Loading DRM CDM...", spinner="dots"):
try: try:
self.cdm = self.get_cdm(self.service, self.profile) self.cdm = self.get_cdm(self.service, self.profile)
@@ -531,7 +536,7 @@ class dl:
else: else:
console.print(Padding("Search -> [bright_black]No match found[/]", (0, 5))) console.print(Padding("Search -> [bright_black]No match found[/]", (0, 5)))
if self.tmdb_id and getattr(self, 'search_source', None) != 'simkl': if self.tmdb_id and getattr(self, "search_source", None) != "simkl":
kind = "tv" if isinstance(title, Episode) else "movie" kind = "tv" if isinstance(title, Episode) else "movie"
tags.external_ids(self.tmdb_id, kind) tags.external_ids(self.tmdb_id, kind)
if self.tmdb_year: if self.tmdb_year:
@@ -1001,12 +1006,7 @@ class dl:
# Handle DRM decryption BEFORE repacking (must decrypt first!) # Handle DRM decryption BEFORE repacking (must decrypt first!)
service_name = service.__class__.__name__.upper() service_name = service.__class__.__name__.upper()
decryption_method = config.decryption_map.get(service_name, config.decryption) decryption_method = config.decryption_map.get(service_name, config.decryption)
use_mp4decrypt = decryption_method.lower() == "mp4decrypt" decrypt_tool = "mp4decrypt" if decryption_method.lower() == "mp4decrypt" else "Shaka Packager"
if use_mp4decrypt:
decrypt_tool = "mp4decrypt"
else:
decrypt_tool = "Shaka Packager"
drm_tracks = [track for track in title.tracks if track.drm] drm_tracks = [track for track in title.tracks if track.drm]
if drm_tracks: if drm_tracks:
@@ -1015,7 +1015,7 @@ class dl:
for track in drm_tracks: for track in drm_tracks:
drm = track.get_drm_for_cdm(self.cdm) drm = track.get_drm_for_cdm(self.cdm)
if drm and hasattr(drm, "decrypt"): if drm and hasattr(drm, "decrypt"):
drm.decrypt(track.path, use_mp4decrypt=use_mp4decrypt) drm.decrypt(track.path)
has_decrypted = True has_decrypted = True
events.emit(events.Types.TRACK_REPACKED, track=track) events.emit(events.Types.TRACK_REPACKED, track=track)
else: else:

View File

@@ -1 +1 @@
__version__ = "1.4.2" __version__ = "1.4.3"

View File

@@ -253,12 +253,11 @@ class PlayReady:
if not self.content_keys: if not self.content_keys:
raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License") raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License")
def decrypt(self, path: Path, use_mp4decrypt: bool = False) -> None: def decrypt(self, path: Path) -> None:
""" """
Decrypt a Track with PlayReady DRM. Decrypt a Track with PlayReady DRM.
Args: Args:
path: Path to the encrypted file to decrypt path: Path to the encrypted file to decrypt
use_mp4decrypt: If True, use mp4decrypt instead of Shaka Packager
Raises: Raises:
EnvironmentError if the required decryption executable could not be found. EnvironmentError if the required decryption executable could not be found.
ValueError if the track has not yet been downloaded. ValueError if the track has not yet been downloaded.
@@ -270,7 +269,9 @@ class PlayReady:
if not path or not path.exists(): if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.") raise ValueError("Tried to decrypt a file that does not exist.")
if use_mp4decrypt: decrypter = str(getattr(config, "decryption", "")).lower()
if decrypter == "mp4decrypt":
return self._decrypt_with_mp4decrypt(path) return self._decrypt_with_mp4decrypt(path)
else: else:
return self._decrypt_with_shaka_packager(path) return self._decrypt_with_shaka_packager(path)

View File

@@ -227,12 +227,11 @@ class Widevine:
finally: finally:
cdm.close(session_id) cdm.close(session_id)
def decrypt(self, path: Path, use_mp4decrypt: bool = False) -> None: def decrypt(self, path: Path) -> None:
""" """
Decrypt a Track with Widevine DRM. Decrypt a Track with Widevine DRM.
Args: Args:
path: Path to the encrypted file to decrypt path: Path to the encrypted file to decrypt
use_mp4decrypt: If True, use mp4decrypt instead of Shaka Packager
Raises: Raises:
EnvironmentError if the required decryption executable could not be found. EnvironmentError if the required decryption executable could not be found.
ValueError if the track has not yet been downloaded. ValueError if the track has not yet been downloaded.
@@ -244,7 +243,9 @@ class Widevine:
if not path or not path.exists(): if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.") raise ValueError("Tried to decrypt a file that does not exist.")
if use_mp4decrypt: decrypter = str(getattr(config, "decryption", "")).lower()
if decrypter == "mp4decrypt":
return self._decrypt_with_mp4decrypt(path) return self._decrypt_with_mp4decrypt(path)
else: else:
return self._decrypt_with_shaka_packager(path) return self._decrypt_with_shaka_packager(path)

View File

@@ -156,7 +156,6 @@ curl_impersonate:
# Pre-define default options and switches of the dl command # Pre-define default options and switches of the dl command
dl: dl:
best: true
sub_format: srt sub_format: srt
downloads: 4 downloads: 4
workers: 16 workers: 16
@@ -241,14 +240,14 @@ proxy_providers:
username: username_from_service_credentials username: username_from_service_credentials
password: password_from_service_credentials password: password_from_service_credentials
server_map: server_map:
- us: 12 # force US server #12 for US proxies us: 12 # force US server #12 for US proxies
surfsharkvpn: surfsharkvpn:
username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn
password: your_surfshark_service_password # Service credentials (not your login password) password: your_surfshark_service_password # Service credentials (not your login password)
server_map: server_map:
- us: 3844 # force US server #3844 for US proxies us: 3844 # force US server #3844 for US proxies
- gb: 2697 # force GB server #2697 for GB proxies gb: 2697 # force GB server #2697 for GB proxies
- au: 4621 # force AU server #4621 for AU proxies au: 4621 # force AU server #4621 for AU proxies
basic: basic:
GB: GB:
- "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham) - "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham)

2
uv.lock generated
View File

@@ -1499,7 +1499,7 @@ wheels = [
[[package]] [[package]]
name = "unshackle" name = "unshackle"
version = "1.4.2" version = "1.4.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "appdirs" }, { name = "appdirs" },