mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-15 13:37:24 +00:00
Compare commits
17 Commits
03c309303c
...
d576174f62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d576174f62 | ||
|
|
425b3764f4 | ||
|
|
29f0e4eee8 | ||
|
|
44ea9a90a7 | ||
|
|
96411e5d7d | ||
|
|
d404f213b1 | ||
|
|
6c83790834 | ||
|
|
a04f1ad4db | ||
|
|
774b9ba96c | ||
|
|
0b9a3a75f8 | ||
|
|
c7d4a68cbf | ||
|
|
4bc2e93d09 | ||
|
|
de41395a45 | ||
|
|
e50dd3f2bc | ||
|
|
6f3aafebc5 | ||
|
|
a66234190c | ||
|
|
62aa85c666 |
1047
CHANGELOG.md
1047
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
48
cliff.toml
48
cliff.toml
@@ -1,4 +1,4 @@
|
||||
# git-cliff ~ default configuration file
|
||||
# git-cliff ~ configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.3.1"
|
||||
__version__ = "2.4.0"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user