17 Commits

Author SHA1 Message Date
Andy
d576174f62 fix(naming): keep technical tokens with scene_naming off
Title filenames now include resolution/service/WEB-DL/codecs/HDR tokens in both modes; scene_naming only changes the spacer ('.' vs ' ').

Also avoid overwriting muxed outputs by disambiguating on collision (append codec suffix when needed, then a numeric suffix).
2026-02-07 20:24:32 -07:00
Andy
425b3764f4 fix(titles): avoid None/double spaces in HDR tokens
Ensure dynamic-range tokens use safe fallback when not in DYNAMIC_RANGE_MAP and append exactly one space-separated token without trailing/double spaces.
2026-02-07 19:55:45 -07:00
Andy
29f0e4eee8 fix(config): normalize playready_remote remote_cdm keys 2026-02-07 19:47:21 -07:00
Andy
44ea9a90a7 fix(titles): remove trailing space from HDR dynamic range label 2026-02-07 19:46:35 -07:00
Andy
96411e5d7d fix(hls): keep range offset numeric and align MonaLisa licensing
- Parse init section byterange offset as int to avoid string arithmetic bugs

- Wrap MonaLisa licensing in the same progress + error handling flow as Widevine/PlayReady
2026-02-07 19:44:23 -07:00
Andy
d404f213b1 fix(dl): avoid selecting all variants when multiple audio codecs requested
When --a-lang is specific (not best/all) and multiple codecs are requested via -a/--acodec, select only the best-bitrate track per codec per language (plus descriptive if --audio-description).

Blame: regression introduced by 939ca25 (fix(dl): keep descriptive and standard audio for requested langs).
2026-02-07 19:37:48 -07:00
Andy
6c83790834 fix(monalisa): avoid leaking secrets and add worker safety
- Pass ML-Worker key via env/stdin instead of argv to reduce exposure in process listings/logs.

- Add a hard timeout to the ML-Worker subprocess call and convert timeouts into DecryptionFailed errors.

- Make ticket bytes decoding defensive: try UTF-8, fall back to ASCII (base64), otherwise raise a descriptive ValueError.
2026-02-07 19:24:15 -07:00
Andy
a04f1ad4db fix(gluetun): stop leaking proxy/vpn secrets to process list
- Switch docker run to use a temporary --env-file instead of per-var -e flags\n- Ensure temp env file is always removed (best-effort overwrite + unlink)\n- Tighten _is_container_running to exact-name matching via anchored docker filter\n- Close requests.Session used for IP verification to release connections\n- Redact more secret-like env keys in debug logs\n
2026-02-07 19:22:13 -07:00
Andy
774b9ba96c fix(dl): preserve proxy_query selector (not resolved URI) 2026-02-07 19:09:29 -07:00
Andy
0b9a3a75f8 fix(serve)!: make PlayReady users config consistently a mapping
Ensure playready_config['users'] and API-only config always use a dict, even under --no-key, to avoid type mismatches.

Also stop implicitly granting PlayReady access by defaulting per-user 'playready_devices' to all devices; missing 'playready_devices' now defaults to an empty list and logs a warning including the user key.

BREAKING CHANGE: users without an explicit 'playready_devices' list no longer get access to all PlayReady devices by default.
2026-02-07 19:06:22 -07:00
Andy
c7d4a68cbf fix(aria2c): warn on config mismatch and wait for RPC ready
When ensure_started() is called while aria2c is already running, it now compares the requested proxy/max_workers against the values the process was started with and logs a warning if they differ (since the running process cannot be reconfigured in-place). Startup no longer uses a fixed sleep; instead it probes the JSON-RPC endpoint with a bounded retry loop (aria2.getVersion) and only proceeds once RPC is responsive, terminating the subprocess and raising on timeout.
2026-02-07 19:04:39 -07:00
Andy
4bc2e93d09 fix(dl): support snake_case keys for RemoteCdm
Use safe get() fallbacks for RemoteCdm config keys and default security_level to 3000 to avoid KeyError when snake_case is used.
2026-02-07 19:00:12 -07:00
Andy
de41395a45 fix(dl): invert audio codec suffixing when splitting 2026-02-07 18:56:30 -07:00
Andy
e50dd3f2bc Merge branch 'dev' of https://github.com/unshackle-dl/unshackle into dev
# Conflicts:
#	CHANGELOG.md
2026-02-07 18:47:23 -07:00
Andy
6f3aafebc5 docs(changelog): update cliff config and regenerate changelog
merge commit filtering, deduplication, granular chore parsing, and regenerate CHANGELOG.md using git-cliff.
2026-02-07 18:46:43 -07:00
Andy
a66234190c docs(changelog): complete 2.4.0 notes 2026-02-07 18:05:03 -07:00
Andy
62aa85c666 chore(release): bump version to 2.4.0 2026-02-07 14:52:54 -07:00
15 changed files with 986 additions and 1066 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
# git-cliff ~ default configuration file
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
@@ -8,8 +8,7 @@ All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Versions [3.0.0] and older use a format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
but versions thereafter use a custom changelog format using [git-cliff](https://git-cliff.org).\n
This changelog is automatically generated using [git-cliff](https://git-cliff.org).\n
"""
body = """
{% if version -%}
@@ -17,9 +16,11 @@ body = """
{% else -%}
## [Unreleased]
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
{% for group, commits in commits
| filter(attribute="merge_commit", value=false)
| group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
{% for commit in commits | unique(attribute="message") %}
- {% if commit.scope %}*{{ commit.scope }}*: {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
@@ -38,34 +39,39 @@ footer = """
[unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
/compare/{{ release.previous.version }}..HEAD
{% endif -%}
{% endfor %}
{% endfor -%}
"""
trim = true
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
postprocessors = []
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = []
commit_preprocessors = [
# Strip emoji (both UTF-8 and :shortcode: styles) from commit messages
{ pattern = ' *(:\w+:|[\p{Emoji_Presentation}\p{Extended_Pictographic}]\x{FE0F}?\x{200D}?) *', replace = "" },
# Remove trailing PR/issue numbers like (#123) from commit messages
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" },
]
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix|revert", group = "<!-- 1 -->Bug Fixes" },
{ message = "^docs", group = "<!-- 2 -->Documentation" },
{ message = "^style", skip = true },
{ message = "^refactor", group = "<!-- 3 -->Changes" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^revert", group = "<!-- 2 -->Reverts" },
{ message = "^docs", group = "<!-- 3 -->Documentation" },
{ message = "^perf", group = "<!-- 4 -->Performance Improvements" },
{ message = "^refactor", group = "<!-- 5 -->Changes" },
{ message = "^style", skip = true },
{ message = "^test", skip = true },
{ message = "^build", group = "<!-- 5 -->Builds" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore", group = "<!-- 6 -->Maintenance" },
{ message = "^ci", skip = true },
{ message = "^chore", skip = true },
{ message = "^build", group = "<!-- 7 -->Builds" },
{ body = ".*security", group = "<!-- 8 -->Security" },
]
protect_breaking_commits = false
filter_commits = false
# tag_pattern = "v[0-9].*"
# skip_tags = ""
# ignore_tags = ""
protect_breaking_commits = true
filter_commits = true
topo_order = false
sort_commits = "oldest"

View File

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

View File

@@ -777,6 +777,9 @@ class dl:
r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE
):
proxy = proxy.lower()
# Preserve the original user query (region code) for service-specific proxy_map overrides.
# NOTE: `proxy` may be overwritten with the resolved proxy URI later.
proxy_query = proxy
status_msg = (
f"Connecting to VPN ({proxy})..."
if requested_provider == "gluetun"
@@ -791,7 +794,6 @@ class dl:
if not proxy_provider:
self.log.error(f"The proxy provider '{requested_provider}' was not recognised.")
sys.exit(1)
proxy_query = proxy # Save query before overwriting with URI
proxy_uri = proxy_provider.get_proxy(proxy)
if not proxy_uri:
self.log.error(f"The proxy provider {requested_provider} had no proxy for {proxy}")
@@ -810,7 +812,6 @@ class dl:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
else:
for proxy_provider in self.proxy_providers:
proxy_query = proxy # Save query before overwriting with URI
proxy_uri = proxy_provider.get_proxy(proxy)
if proxy_uri:
proxy = ctx.params["proxy"] = proxy_uri
@@ -827,7 +828,7 @@ class dl:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
break
# Store proxy query info for service-specific overrides
ctx.params["proxy_query"] = proxy
ctx.params["proxy_query"] = proxy_query
ctx.params["proxy_provider"] = requested_provider
else:
self.log.info(f"Using explicit Proxy: {proxy}")
@@ -1483,22 +1484,54 @@ class dl:
selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0))
title.tracks.audio = selected_audio
elif "all" not in processed_lang:
per_language = 0 if acodec and len(acodec) > 1 else 1
if audio_description:
standard_audio = [a for a in title.tracks.audio if not a.descriptive]
selected_standards = title.tracks.by_language(
standard_audio, processed_lang, per_language=per_language, exact_match=exact_lang
)
desc_audio = [a for a in title.tracks.audio if a.descriptive]
# Include all descriptive tracks for the requested languages.
selected_descs = title.tracks.by_language(
desc_audio, processed_lang, per_language=0, exact_match=exact_lang
)
title.tracks.audio = selected_standards + selected_descs
# If multiple codecs were explicitly requested, pick the best track per codec per
# requested language instead of selecting *all* bitrate variants of a codec.
if acodec and len(acodec) > 1:
selected_audio: list[Audio] = []
for language in processed_lang:
for codec in acodec:
codec_tracks = [a for a in title.tracks.audio if a.codec == codec]
if not codec_tracks:
continue
candidates = title.tracks.by_language(
codec_tracks, [language], per_language=0, exact_match=exact_lang
)
if not candidates:
continue
if audio_description:
standards = [t for t in candidates if not t.descriptive]
if standards:
selected_audio.append(max(standards, key=lambda x: x.bitrate or 0))
descs = [t for t in candidates if t.descriptive]
if descs:
selected_audio.append(max(descs, key=lambda x: x.bitrate or 0))
else:
selected_audio.append(max(candidates, key=lambda x: x.bitrate or 0))
title.tracks.audio = selected_audio
else:
title.tracks.audio = title.tracks.by_language(
title.tracks.audio, processed_lang, per_language=per_language, exact_match=exact_lang
)
per_language = 1
if audio_description:
standard_audio = [a for a in title.tracks.audio if not a.descriptive]
selected_standards = title.tracks.by_language(
standard_audio, processed_lang, per_language=per_language, exact_match=exact_lang
)
desc_audio = [a for a in title.tracks.audio if a.descriptive]
# Include all descriptive tracks for the requested languages.
selected_descs = title.tracks.by_language(
desc_audio, processed_lang, per_language=0, exact_match=exact_lang
)
title.tracks.audio = selected_standards + selected_descs
else:
title.tracks.audio = title.tracks.by_language(
title.tracks.audio,
processed_lang,
per_language=per_language,
exact_match=exact_lang,
)
if not title.tracks.audio:
self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...")
sys.exit(1)
@@ -1830,11 +1863,11 @@ class dl:
console=console,
)
if split_audio is not None:
merge_audio = not split_audio
else:
merge_audio = config.muxing.get("merge_audio", True)
append_audio_codec_suffix = merge_audio
merge_audio = (
(not split_audio) if split_audio is not None else config.muxing.get("merge_audio", True)
)
# When we split audio (merge_audio=False), multiple outputs may exist per title, so suffix codec.
append_audio_codec_suffix = not merge_audio
multiplex_tasks: list[tuple[TaskID, Tracks, Optional[Audio.Codec]]] = []
# Track hybrid-processing outputs explicitly so we can always clean them up,
@@ -2093,14 +2126,23 @@ class dl:
final_dir = config.directories.downloads
final_filename = title.get_filename(media_info, show_service=not no_source)
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
if audio_codec_suffix and append_audio_codec_suffix:
final_filename = f"{final_filename}.{audio_codec_suffix.name}"
if not no_folder and isinstance(title, (Episode, Song)):
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
final_dir.mkdir(parents=True, exist_ok=True)
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix:
sep = "." if config.scene_naming else " "
final_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}"
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
if final_path.exists():
sep = "." if config.scene_naming else " "
i = 2
while final_path.exists():
final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}"
i += 1
shutil.move(muxed_path, final_path)
tags.tag_file(final_path, title, self.tmdb_id)
@@ -2711,12 +2753,12 @@ class dl:
)
else:
return RemoteCdm(
device_type=cdm_api["Device Type"],
system_id=cdm_api["System ID"],
security_level=cdm_api["Security Level"],
host=cdm_api["Host"],
secret=cdm_api["Secret"],
device_name=cdm_api["Device Name"],
device_type=cdm_api.get("Device Type", cdm_api.get("device_type", "")),
system_id=cdm_api.get("System ID", cdm_api.get("system_id", "")),
security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)),
host=cdm_api.get("Host", cdm_api.get("host")),
secret=cdm_api.get("Secret", cdm_api.get("secret")),
device_name=cdm_api.get("Device Name", cdm_api.get("device_name")),
)
prd_path = config.directories.prds / f"{cdm_name}.prd"

View File

@@ -123,7 +123,7 @@ def serve(
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
if no_key:
app = web.Application(middlewares=[cors_middleware])
app["config"] = {"users": []}
app["config"] = {"users": {}}
else:
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
app["config"] = {"users": {api_secret: {"devices": [], "username": "api_user"}}}
@@ -164,7 +164,12 @@ def serve(
for user_key, user_config in serve_config["users"].items():
if "playready_devices" not in user_config:
user_config["playready_devices"] = prd_device_names
# Require explicit PlayReady device access per user (default: no access).
user_config["playready_devices"] = []
log.warning(
f'User "{user_key}" has no "playready_devices" configured; PlayReady access disabled for this user. '
f"Available PlayReady devices: {prd_device_names}"
)
def create_serve_authentication(serve_playready_flag: bool):
@web.middleware
@@ -212,7 +217,7 @@ def serve(
for user_key, user_cfg in serve_config["users"].items()
}
if not no_key
else [],
else {},
}
playready_app["config"] = playready_config
playready_app.on_startup.append(pyplayready_serve._startup)

View File

@@ -1 +1 @@
__version__ = "2.3.1"
__version__ = "2.4.0"

View File

@@ -1,3 +1,4 @@
import logging
import os
import subprocess
import textwrap
@@ -54,11 +55,13 @@ class _Aria2Manager:
"""Singleton manager to run one aria2c process and enqueue downloads via RPC."""
def __init__(self) -> None:
self._logger = logging.getLogger(__name__)
self._proc: Optional[subprocess.Popen] = None
self._rpc_port: Optional[int] = None
self._rpc_secret: Optional[str] = None
self._rpc_uri: Optional[str] = None
self._session: Session = Session()
self._max_workers: Optional[int] = None
self._max_concurrent_downloads: int = 0
self._max_connection_per_server: int = 1
self._split_default: int = 5
@@ -66,6 +69,47 @@ class _Aria2Manager:
self._proxy: Optional[str] = None
self._lock: threading.Lock = threading.Lock()
def _wait_for_rpc_ready(self, timeout_s: float = 8.0, interval_s: float = 0.1) -> None:
assert self._proc is not None
assert self._rpc_uri is not None
assert self._rpc_secret is not None
deadline = time.monotonic() + timeout_s
payload = {
"jsonrpc": "2.0",
"id": get_random_bytes(16).hex(),
"method": "aria2.getVersion",
"params": [f"token:{self._rpc_secret}"],
}
while time.monotonic() < deadline:
if self._proc.poll() is not None:
raise RuntimeError(
f"aria2c exited before RPC became ready (exit code {self._proc.returncode})"
)
try:
res = self._session.post(self._rpc_uri, json=payload, timeout=0.25)
data = res.json()
if isinstance(data, dict) and data.get("result") is not None:
return
except (requests.exceptions.RequestException, ValueError):
# Not ready yet (connection refused / bad response / etc.)
pass
time.sleep(interval_s)
# Timed out: ensure we don't leave a zombie/stray aria2c process behind.
try:
self._proc.terminate()
self._proc.wait(timeout=2)
except Exception:
try:
self._proc.kill()
self._proc.wait(timeout=2)
except Exception:
pass
raise TimeoutError(f"aria2c RPC did not become ready within {timeout_s:.1f}s")
def _build_args(self) -> list[str]:
args = [
"--continue=true",
@@ -95,9 +139,6 @@ class _Aria2Manager:
max_workers: Optional[int],
) -> None:
with self._lock:
if self._proc and self._proc.poll() is None:
return
if not binaries.Aria2:
debug_logger = get_debug_logger()
if debug_logger:
@@ -109,27 +150,45 @@ class _Aria2Manager:
)
raise EnvironmentError("Aria2c executable not found...")
effective_proxy = proxy or None
if not max_workers:
max_workers = min(32, (os.cpu_count() or 1) + 4)
effective_max_workers = min(32, (os.cpu_count() or 1) + 4)
elif not isinstance(max_workers, int):
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
else:
effective_max_workers = max_workers
if self._proc and self._proc.poll() is None:
if effective_proxy != self._proxy or effective_max_workers != self._max_workers:
self._logger.warning(
"aria2c process is already running; requested proxy=%r, max_workers=%r, "
"but running process will continue with proxy=%r, max_workers=%r",
effective_proxy,
effective_max_workers,
self._proxy,
self._max_workers,
)
return
self._rpc_port = get_free_port()
self._rpc_secret = get_random_bytes(16).hex()
self._rpc_uri = f"http://127.0.0.1:{self._rpc_port}/jsonrpc"
self._max_concurrent_downloads = int(config.aria2c.get("max_concurrent_downloads", max_workers))
self._max_workers = effective_max_workers
self._max_concurrent_downloads = int(
config.aria2c.get("max_concurrent_downloads", effective_max_workers)
)
self._max_connection_per_server = int(config.aria2c.get("max_connection_per_server", 1))
self._split_default = int(config.aria2c.get("split", 5))
self._file_allocation = config.aria2c.get("file_allocation", "prealloc")
self._proxy = proxy or None
self._proxy = effective_proxy
args = self._build_args()
self._proc = subprocess.Popen(
[binaries.Aria2, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
# Give aria2c a moment to start up and bind to the RPC port
time.sleep(0.5)
self._wait_for_rpc_ready()
@property
def rpc_uri(self) -> str:

View File

@@ -7,6 +7,7 @@ segment decryption (ML-Worker binary + AES-ECB).
from __future__ import annotations
import logging
import os
import subprocess
import sys
@@ -17,6 +18,8 @@ from uuid import UUID
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad
log = logging.getLogger(__name__)
class MonaLisa:
"""
@@ -142,7 +145,16 @@ class MonaLisa:
The raw PSSH value as a base64 string.
"""
if isinstance(self._ticket, bytes):
return self._ticket.decode("utf-8")
try:
return self._ticket.decode("utf-8")
except UnicodeDecodeError:
# Tickets are typically base64, so ASCII is a reasonable fallback.
try:
return self._ticket.decode("ascii")
except UnicodeDecodeError as e:
raise ValueError(
f"Ticket bytes must be UTF-8 text or ASCII base64; got undecodable bytes (len={len(self._ticket)})"
) from e
return self._ticket
@property
@@ -222,19 +234,27 @@ class MonaLisa:
raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}")
# Stage 1: ML-Worker decryption
cmd = [str(worker_path), self._key, str(bbts_path), str(ents_path)]
# Do not pass secrets via argv (visible in process listings/logs).
# ML-Worker supports receiving the key out-of-band; we provide it via env + stdin.
cmd = [str(worker_path), "-", str(bbts_path), str(ents_path)]
worker_env = os.environ.copy()
worker_env["WORKER_KEY"] = self._key
startupinfo = None
if sys.platform == "win32":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
worker_timeout_s = 60
process = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
input=self._key,
env=worker_env,
startupinfo=startupinfo,
timeout=worker_timeout_s,
)
if process.returncode != 0:
@@ -260,6 +280,11 @@ class MonaLisa:
except MonaLisa.Exceptions.DecryptionFailed:
raise
except subprocess.TimeoutExpired as e:
log.error("ML-Worker timed out after %ss for %s", worker_timeout_s, segment_path.name)
raise MonaLisa.Exceptions.DecryptionFailed(
f"ML-Worker timed out after {worker_timeout_s}s for {segment_path.name}"
) from e
except Exception as e:
raise MonaLisa.Exceptions.DecryptionFailed(f"Failed to decrypt segment {segment_path.name}: {e}")
finally:

View File

@@ -350,8 +350,16 @@ class HLS:
raise
if not initial_drm_licensed and session_drm and isinstance(session_drm, MonaLisa):
if license_widevine:
try:
if not license_widevine:
raise ValueError("license_widevine func must be supplied to use DRM")
progress(downloaded="LICENSING")
license_widevine(session_drm)
progress(downloaded="[yellow]LICENSED")
except Exception: # noqa
DOWNLOAD_CANCELLED.set() # skip pending track downloads
progress(downloaded="[red]FAILED")
raise
if DOWNLOAD_LICENCE_ONLY.is_set():
progress(downloaded="[yellow]SKIPPED")
@@ -608,7 +616,7 @@ class HLS:
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
if segment.init_section.byterange:
init_byte_range = HLS.calculate_byte_range(segment.init_section.byterange, range_offset)
range_offset = init_byte_range.split("-")[0]
range_offset = int(init_byte_range.split("-")[0])
init_range_header = {"Range": f"bytes={init_byte_range}"}
else:
init_range_header = {}

View File

@@ -2,7 +2,9 @@ import atexit
import logging
import os
import re
import stat
import subprocess
import tempfile
import threading
import time
from typing import Optional
@@ -750,7 +752,8 @@ class Gluetun(Proxy):
# Debug log environment variables (redact sensitive values)
if debug_logger:
safe_env = {k: ("***" if "KEY" in k or "PASSWORD" in k else v) for k, v in env_vars.items()}
redact_markers = ("KEY", "PASSWORD", "PASS", "TOKEN", "SECRET", "USER")
safe_env = {k: ("***" if any(m in k for m in redact_markers) else v) for k, v in env_vars.items()}
debug_logger.log(
level="DEBUG",
operation="gluetun_env_vars",
@@ -771,23 +774,62 @@ class Gluetun(Proxy):
f"127.0.0.1:{port}:8888/tcp",
]
# Add environment variables
for key, value in env_vars.items():
cmd.extend(["-e", f"{key}={value}"])
# Add Gluetun image
cmd.append("qmcgaw/gluetun:latest")
# Execute docker run
# Avoid exposing credentials in process listings by using --env-file instead of many "-e KEY=VALUE".
env_file_path: str | None = None
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
encoding="utf-8",
errors="replace",
)
fd, env_file_path = tempfile.mkstemp(prefix=f"unshackle-{container_name}-", suffix=".env")
try:
# Best-effort restrictive permissions.
if os.name != "nt":
if hasattr(os, "fchmod"):
os.fchmod(fd, 0o600)
else:
os.chmod(env_file_path, 0o600)
else:
os.chmod(env_file_path, stat.S_IREAD | stat.S_IWRITE)
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
for key, value in env_vars.items():
if "=" in key:
raise ValueError(f"Invalid env var name for docker env-file: {key!r}")
v = "" if value is None else str(value)
if "\n" in v or "\r" in v:
raise ValueError(f"Invalid env var value (contains newline) for {key!r}")
f.write(f"{key}={v}\n")
except Exception:
# If we fail before fdopen closes the descriptor, make sure it's not leaked.
try:
os.close(fd)
except Exception:
pass
raise
cmd.extend(["--env-file", env_file_path])
# Add Gluetun image
cmd.append(gluetun_image)
# Execute docker run
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
encoding="utf-8",
errors="replace",
)
except subprocess.TimeoutExpired:
if debug_logger:
debug_logger.log(
level="ERROR",
operation="gluetun_container_create_timeout",
message=f"Docker run timed out for {container_name}",
context={"container_name": container_name},
success=False,
duration_ms=(time.time() - start_time) * 1000,
)
raise RuntimeError("Docker run command timed out")
if result.returncode != 0:
error_msg = result.stderr or "unknown error"
@@ -826,29 +868,51 @@ class Gluetun(Proxy):
success=True,
duration_ms=duration_ms,
)
except subprocess.TimeoutExpired:
if debug_logger:
debug_logger.log(
level="ERROR",
operation="gluetun_container_create_timeout",
message=f"Docker run timed out for {container_name}",
context={"container_name": container_name},
success=False,
duration_ms=(time.time() - start_time) * 1000,
)
raise RuntimeError("Docker run command timed out")
finally:
if env_file_path:
# Best-effort "secure delete": overwrite then unlink (not guaranteed on all filesystems).
try:
with open(env_file_path, "r+b") as f:
try:
f.seek(0, os.SEEK_END)
length = f.tell()
f.seek(0)
if length > 0:
f.write(b"\x00" * length)
f.flush()
os.fsync(f.fileno())
except Exception:
pass
except Exception:
pass
try:
os.remove(env_file_path)
except FileNotFoundError:
pass
except Exception:
pass
def _is_container_running(self, container_name: str) -> bool:
"""Check if a Docker container is running."""
try:
result = subprocess.run(
["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Names}}"],
[
"docker",
"ps",
"--filter",
f"name=^{re.escape(container_name)}$",
"--format",
"{{.Names}}",
],
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0 and container_name in result.stdout
if result.returncode != 0:
return False
names = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
return any(name == container_name for name in names)
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
@@ -1132,98 +1196,104 @@ class Gluetun(Proxy):
# Create a session with the proxy configured
session = requests.Session()
session.proxies = {"http": proxy_url, "https": proxy_url}
try:
session.proxies = {"http": proxy_url, "https": proxy_url}
# Retry with exponential backoff
for attempt in range(max_retries):
try:
# Get external IP through the proxy using shared utility
ip_info = get_ip_info(session)
# Retry with exponential backoff
for attempt in range(max_retries):
try:
# Get external IP through the proxy using shared utility
ip_info = get_ip_info(session)
if ip_info:
actual_country = ip_info.get("country", "").upper()
if ip_info:
actual_country = ip_info.get("country", "").upper()
# Check if country matches (if we have an expected country)
# ipinfo.io returns country codes (CA), but we may have full names (Canada)
# Normalize both to country codes for comparison using shared utility
if expected_country:
# Convert expected country name to code if it's a full name
expected_code = get_country_code(expected_country) or expected_country
expected_code = expected_code.upper()
# Check if country matches (if we have an expected country)
# ipinfo.io returns country codes (CA), but we may have full names (Canada)
# Normalize both to country codes for comparison using shared utility
if expected_country:
# Convert expected country name to code if it's a full name
expected_code = get_country_code(expected_country) or expected_country
expected_code = expected_code.upper()
if actual_country != expected_code:
duration_ms = (time.time() - start_time) * 1000
if debug_logger:
debug_logger.log(
level="ERROR",
operation="gluetun_verify_mismatch",
message=f"Region mismatch for {query_key}",
context={
"query_key": query_key,
"expected_country": expected_code,
"actual_country": actual_country,
"ip": ip_info.get("ip"),
"city": ip_info.get("city"),
"org": ip_info.get("org"),
},
success=False,
duration_ms=duration_ms,
)
raise RuntimeError(
if actual_country != expected_code:
duration_ms = (time.time() - start_time) * 1000
if debug_logger:
debug_logger.log(
level="ERROR",
operation="gluetun_verify_mismatch",
message=f"Region mismatch for {query_key}",
context={
"query_key": query_key,
"expected_country": expected_code,
"actual_country": actual_country,
"ip": ip_info.get("ip"),
"city": ip_info.get("city"),
"org": ip_info.get("org"),
},
success=False,
duration_ms=duration_ms,
)
raise RuntimeError(
f"Region mismatch for {container['provider']}:{container['region']}: "
f"Expected '{expected_code}' but got '{actual_country}' "
f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})"
)
# Verification successful - store IP info in container record
if query_key in self.active_containers:
self.active_containers[query_key]["public_ip"] = ip_info.get("ip")
self.active_containers[query_key]["ip_country"] = actual_country
self.active_containers[query_key]["ip_city"] = ip_info.get("city")
self.active_containers[query_key]["ip_org"] = ip_info.get("org")
# Verification successful - store IP info in container record
if query_key in self.active_containers:
self.active_containers[query_key]["public_ip"] = ip_info.get("ip")
self.active_containers[query_key]["ip_country"] = actual_country
self.active_containers[query_key]["ip_city"] = ip_info.get("city")
self.active_containers[query_key]["ip_org"] = ip_info.get("org")
duration_ms = (time.time() - start_time) * 1000
duration_ms = (time.time() - start_time) * 1000
if debug_logger:
debug_logger.log(
level="INFO",
operation="gluetun_verify_success",
message=f"VPN IP verified for: {query_key}",
context={
"query_key": query_key,
"ip": ip_info.get("ip"),
"country": actual_country,
"city": ip_info.get("city"),
"org": ip_info.get("org"),
"attempts": attempt + 1,
},
success=True,
duration_ms=duration_ms,
)
return
# ip_info was None, retry
last_error = "Failed to get IP info from ipinfo.io"
except RuntimeError:
raise # Re-raise region mismatch errors immediately
except Exception as e:
last_error = str(e)
if debug_logger:
debug_logger.log(
level="INFO",
operation="gluetun_verify_success",
message=f"VPN IP verified for: {query_key}",
level="DEBUG",
operation="gluetun_verify_retry",
message=f"Verification attempt {attempt + 1} failed, retrying",
context={
"query_key": query_key,
"ip": ip_info.get("ip"),
"country": actual_country,
"city": ip_info.get("city"),
"org": ip_info.get("org"),
"attempts": attempt + 1,
"attempt": attempt + 1,
"error": last_error,
},
success=True,
duration_ms=duration_ms,
)
return
# ip_info was None, retry
last_error = "Failed to get IP info from ipinfo.io"
except RuntimeError:
raise # Re-raise region mismatch errors immediately
except Exception as e:
last_error = str(e)
if debug_logger:
debug_logger.log(
level="DEBUG",
operation="gluetun_verify_retry",
message=f"Verification attempt {attempt + 1} failed, retrying",
context={
"query_key": query_key,
"attempt": attempt + 1,
"error": last_error,
},
)
# Wait before retry (exponential backoff)
if attempt < max_retries - 1:
wait_time = 2**attempt # 1, 2, 4 seconds
time.sleep(wait_time)
# Wait before retry (exponential backoff)
if attempt < max_retries - 1:
wait_time = 2**attempt # 1, 2, 4 seconds
time.sleep(wait_time)
finally:
try:
session.close()
except Exception:
pass
# All retries exhausted
duration_ms = (time.time() - start_time) * 1000

View File

@@ -102,6 +102,27 @@ class Episode(Title):
primary_audio_track = sorted_audio[0]
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
def _get_resolution_token(track: Any) -> str:
if not track or not getattr(track, "height", None):
return ""
resolution = track.height
try:
dar = getattr(track, "other_display_aspect_ratio", None) or []
if dar and dar[0]:
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
resolution = int(track.width * (9 / 16))
except Exception:
pass
scan_suffix = "p"
scan_type = getattr(track, "scan_type", None)
if scan_type and str(scan_type).lower() == "interlaced":
scan_suffix = "i"
return f"{resolution}{scan_suffix}"
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
if folder:
name = f"{self.title}"
@@ -135,108 +156,95 @@ class Episode(Title):
name=self.name or "",
).strip()
if config.scene_naming:
# Resolution
if primary_video_track:
resolution = primary_video_track.height
aspect_ratio = [
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
]
if len(aspect_ratio) == 1:
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
# We want the resolution represented in a 4:3 or 16:9 canvas.
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
# otherwise the track's height value is fine.
# We are assuming this title is some weird aspect ratio so most
# likely a movie or HD source, so it's most likely widescreen so
# 16:9 canvas makes the most sense.
resolution = int(primary_video_track.width * (9 / 16))
# Determine scan type suffix - default to "p", use "i" only if explicitly interlaced
scan_suffix = "p"
scan_type = getattr(primary_video_track, 'scan_type', None)
if scan_type and str(scan_type).lower() == "interlaced":
scan_suffix = "i"
name += f" {resolution}{scan_suffix}"
if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track)
if resolution_token:
name += f" {resolution_token}"
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# 'WEB-DL'
name += " WEB-DL"
# 'WEB-DL'
name += " WEB-DL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
)
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or primary_audio_track.joc:
name += " Atmos"
# Video (dynamic range + hfr +) Codec
if primary_video_track:
codec = primary_video_track.format
hdr_format = primary_video_track.hdr_format_commercial
hdr_format_full = primary_video_track.hdr_format or ""
trc = (
primary_video_track.transfer_characteristics
or primary_video_track.transfer_characteristics_original
or ""
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
)
frame_rate = float(primary_video_track.frame_rate)
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
# Primary HDR format detection
if hdr_format:
if hdr_format_full.startswith("Dolby Vision"):
name += " DV"
if any(
indicator in (hdr_format_full + " " + hdr_format)
for indicator in ["HDR10", "SMPTE ST 2086"]
):
name += " HDR"
elif "HDR Vivid" in hdr_format:
name += " HDR"
else:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
name += " HLG"
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
name += " HDR"
if frame_rate > 30:
name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or primary_audio_track.joc:
name += " Atmos"
if config.tag:
name += f"-{config.tag}"
# Video (dynamic range + hfr +) Codec
if primary_video_track:
codec = primary_video_track.format
hdr_format = primary_video_track.hdr_format_commercial
hdr_format_full = primary_video_track.hdr_format or ""
trc = (
primary_video_track.transfer_characteristics
or primary_video_track.transfer_characteristics_original
or ""
)
frame_rate = float(primary_video_track.frame_rate)
return sanitize_filename(name)
else:
# Simple naming style without technical details - use spaces instead of dots
return sanitize_filename(name, " ")
def _append_token(current: str, token: Optional[str]) -> str:
token = (token or "").strip()
current = current.rstrip()
if not token:
return current
if current.endswith(f" {token}"):
return current
return f"{current} {token}"
# Primary HDR format detection
if hdr_format:
if hdr_format_full.startswith("Dolby Vision"):
name = _append_token(name, "DV")
if any(
indicator in (hdr_format_full + " " + hdr_format)
for indicator in ["HDR10", "SMPTE ST 2086"]
):
name = _append_token(name, "HDR")
elif "HDR Vivid" in hdr_format:
name = _append_token(name, "HDR")
else:
dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or ""
name = _append_token(name, dynamic_range)
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
name += " HLG"
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
name += " HDR"
if frame_rate > 30:
name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name, "." if config.scene_naming else " ")
class Series(SortedKeyList, ABC):

View File

@@ -67,111 +67,119 @@ class Movie(Title):
primary_audio_track = sorted_audio[0]
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
def _get_resolution_token(track: Any) -> str:
if not track or not getattr(track, "height", None):
return ""
resolution = track.height
try:
dar = getattr(track, "other_display_aspect_ratio", None) or []
if dar and dar[0]:
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
resolution = int(track.width * (9 / 16))
except Exception:
pass
scan_suffix = "p"
scan_type = getattr(track, "scan_type", None)
if scan_type and str(scan_type).lower() == "interlaced":
scan_suffix = "i"
return f"{resolution}{scan_suffix}"
# Name (Year)
name = str(self).replace("$", "S") # e.g., Arli$$
if config.scene_naming:
# Resolution
if primary_video_track:
resolution = primary_video_track.height
aspect_ratio = [
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
]
if len(aspect_ratio) == 1:
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
# We want the resolution represented in a 4:3 or 16:9 canvas.
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
# otherwise the track's height value is fine.
# We are assuming this title is some weird aspect ratio so most
# likely a movie or HD source, so it's most likely widescreen so
# 16:9 canvas makes the most sense.
resolution = int(primary_video_track.width * (9 / 16))
# Determine scan type suffix - default to "p", use "i" only if explicitly interlaced
scan_suffix = "p"
scan_type = getattr(primary_video_track, 'scan_type', None)
if scan_type and str(scan_type).lower() == "interlaced":
scan_suffix = "i"
name += f" {resolution}{scan_suffix}"
if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track)
if resolution_token:
name += f" {resolution_token}"
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# 'WEB-DL'
name += " WEB-DL"
# 'WEB-DL'
name += " WEB-DL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
)
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or primary_audio_track.joc:
name += " Atmos"
# Video (dynamic range + hfr +) Codec
if primary_video_track:
codec = primary_video_track.format
hdr_format = primary_video_track.hdr_format_commercial
hdr_format_full = primary_video_track.hdr_format or ""
trc = (
primary_video_track.transfer_characteristics
or primary_video_track.transfer_characteristics_original
or ""
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
)
frame_rate = float(primary_video_track.frame_rate)
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
# Primary HDR format detection
if hdr_format:
if hdr_format_full.startswith("Dolby Vision"):
name += " DV"
if any(
indicator in (hdr_format_full + " " + hdr_format)
for indicator in ["HDR10", "SMPTE ST 2086"]
):
name += " HDR"
elif "HDR Vivid" in hdr_format:
name += " HDR"
else:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
name += " HLG"
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
name += " HDR"
if frame_rate > 30:
name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or primary_audio_track.joc:
name += " Atmos"
if config.tag:
name += f"-{config.tag}"
# Video (dynamic range + hfr +) Codec
if primary_video_track:
codec = primary_video_track.format
hdr_format = primary_video_track.hdr_format_commercial
hdr_format_full = primary_video_track.hdr_format or ""
trc = (
primary_video_track.transfer_characteristics
or primary_video_track.transfer_characteristics_original
or ""
)
frame_rate = float(primary_video_track.frame_rate)
return sanitize_filename(name)
else:
# Simple naming style without technical details - use spaces instead of dots
return sanitize_filename(name, " ")
def _append_token(current: str, token: Optional[str]) -> str:
token = (token or "").strip()
current = current.rstrip()
if not token:
return current
if current.endswith(f" {token}"):
return current
return f"{current} {token}"
# Primary HDR format detection
if hdr_format:
if hdr_format_full.startswith("Dolby Vision"):
name = _append_token(name, "DV")
if any(
indicator in (hdr_format_full + " " + hdr_format)
for indicator in ["HDR10", "SMPTE ST 2086"]
):
name = _append_token(name, "HDR")
elif "HDR Vivid" in hdr_format:
name = _append_token(name, "HDR")
else:
dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or ""
name = _append_token(name, dynamic_range)
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
name += " HLG"
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
name += " HDR"
if frame_rate > 30:
name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name, "." if config.scene_naming else " ")
class Movies(SortedKeyList, ABC):

View File

@@ -100,31 +100,27 @@ class Song(Title):
# NN. Song Name
name = str(self).split(" / ")[1]
if config.scene_naming:
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# 'WEB-DL'
name += " WEB-DL"
# 'WEB-DL'
name += " WEB-DL"
# Audio Codec + Channels (+ feature)
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or audio_track.joc:
name += " Atmos"
# Audio Codec + Channels (+ feature)
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or audio_track.joc:
name += " Atmos"
if config.tag:
name += f"-{config.tag}"
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name, " ")
else:
# Simple naming style without technical details
return sanitize_filename(name, " ")
return sanitize_filename(name, " ")
class Album(SortedKeyList, ABC):

View File

@@ -271,12 +271,12 @@ remote_cdm:
# PyPlayReady RemoteCdm - connects to an unshackle serve instance
- name: "playready_remote"
Device Type: PLAYREADY
System ID: 0
Security Level: 3000 # 2000 for SL2000, 3000 for SL3000
Host: "http://127.0.0.1:8786/playready" # Include /playready path
Secret: "your-api-secret-key"
Device Name: "my_prd_device" # Device name on the serve instance
device_name: "my_prd_device" # Device name on the serve instance
device_type: PLAYREADY
system_id: 0
security_level: 3000 # 2000 for SL2000, 3000 for SL3000
host: "http://127.0.0.1:8786/playready" # Include /playready path
secret: "your-api-secret-key"
# Key Vaults store your obtained Content Encryption Keys (CEKs)
# Use 'no_push: true' to prevent a vault from receiving pushed keys

2
uv.lock generated
View File

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