mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2025-10-23 15:11:08 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e9a78b2a | ||
|
|
a2bfe47993 | ||
|
|
cf4dc1ce76 | ||
|
|
40028c81d7 | ||
|
|
06df10cb58 | ||
|
|
d61bec4a8c | ||
|
|
058bb60502 | ||
|
|
7583129e8f | ||
|
|
4691694d2e | ||
|
|
a07345a0a2 | ||
|
|
091d7335a3 | ||
|
|
8c798b95c4 | ||
|
|
46c28fe943 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# unshackle
|
# unshackle
|
||||||
unshackle.yaml
|
unshackle.yaml
|
||||||
unshackle.yml
|
unshackle.yml
|
||||||
|
update_check.json
|
||||||
*.mkv
|
*.mkv
|
||||||
*.mp4
|
*.mp4
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.3.0] - 2025-08-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **mp4decrypt Support**: Alternative DRM decryption method using mp4decrypt from Bento4
|
||||||
|
- Added `mp4decrypt` binary detection and support in binaries module
|
||||||
|
- New `decryption` configuration option in unshackle.yaml for service-specific decryption methods
|
||||||
|
- Enhanced PlayReady and Widevine DRM classes with mp4decrypt decryption support
|
||||||
|
- Service-specific decryption mapping allows choosing between `shaka` and `mp4decrypt` per service
|
||||||
|
- Improved error handling and progress reporting for mp4decrypt operations
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **DRM Decryption Architecture**: Enhanced decryption system with dual method support
|
||||||
|
- Updated `dl.py` to handle service-specific decryption method selection
|
||||||
|
- Refactored `Config` class to manage decryption method mapping per service
|
||||||
|
- Enhanced DRM decrypt methods with `use_mp4decrypt` parameter for method selection
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Service Track Filtering**: Cleaned up ATVP service to remove unnecessary track filtering
|
||||||
|
- Simplified track return logic to pass all tracks to dl.py for centralized filtering
|
||||||
|
- Removed unused codec and quality filter parameters from service initialization
|
||||||
|
|
||||||
## [1.2.0] - 2025-07-30
|
## [1.2.0] - 2025-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
31
CONFIG.md
31
CONFIG.md
@@ -213,6 +213,37 @@ downloader:
|
|||||||
|
|
||||||
The `default` entry is optional. If omitted, `requests` will be used for services not listed.
|
The `default` entry is optional. If omitted, `requests` will be used for services not listed.
|
||||||
|
|
||||||
|
## decryption (str | dict)
|
||||||
|
|
||||||
|
Choose what software to use to decrypt DRM-protected content throughout unshackle where needed.
|
||||||
|
You may provide a single decryption method globally or a mapping of service tags to
|
||||||
|
decryption methods.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- `shaka` (default) - Shaka Packager - <https://github.com/shaka-project/shaka-packager>
|
||||||
|
- `mp4decrypt` - mp4decrypt from Bento4 - <https://github.com/axiomatic-systems/Bento4>
|
||||||
|
|
||||||
|
Note that Shaka Packager is the traditional method and works with most services. mp4decrypt
|
||||||
|
is an alternative that may work better with certain services that have specific encryption formats.
|
||||||
|
|
||||||
|
Example mapping:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
decryption:
|
||||||
|
ATVP: mp4decrypt
|
||||||
|
AMZN: shaka
|
||||||
|
default: shaka
|
||||||
|
```
|
||||||
|
|
||||||
|
The `default` entry is optional. If omitted, `shaka` will be used for services not listed.
|
||||||
|
|
||||||
|
Simple configuration (single method for all services):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
decryption: mp4decrypt
|
||||||
|
```
|
||||||
|
|
||||||
## filenames (dict)
|
## filenames (dict)
|
||||||
|
|
||||||
Override the default filenames used across unshackle.
|
Override the default filenames used across unshackle.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "unshackle"
|
name = "unshackle"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
description = "Modular Movie, TV, and Music Archival Software."
|
description = "Modular Movie, TV, and Music Archival Software."
|
||||||
authors = [{ name = "unshackle team" }]
|
authors = [{ name = "unshackle team" }]
|
||||||
requires-python = ">=3.10,<3.13"
|
requires-python = ">=3.10,<3.13"
|
||||||
|
|||||||
@@ -765,7 +765,8 @@ class dl:
|
|||||||
DOWNLOAD_LICENCE_ONLY.set()
|
DOWNLOAD_LICENCE_ONLY.set()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Live(Padding(download_table, (1, 5)), console=console, refresh_per_second=5):
|
# Use transient mode to prevent display remnants
|
||||||
|
with Live(Padding(download_table, (1, 5)), console=console, refresh_per_second=5, transient=True):
|
||||||
with ThreadPoolExecutor(downloads) as pool:
|
with ThreadPoolExecutor(downloads) as pool:
|
||||||
for download in futures.as_completed(
|
for download in futures.as_completed(
|
||||||
(
|
(
|
||||||
@@ -911,6 +912,31 @@ class dl:
|
|||||||
if font_count:
|
if font_count:
|
||||||
self.log.info(f"Attached {font_count} fonts for the Subtitles")
|
self.log.info(f"Attached {font_count} fonts for the Subtitles")
|
||||||
|
|
||||||
|
# Handle DRM decryption BEFORE repacking (must decrypt first!)
|
||||||
|
service_name = service.__class__.__name__.upper()
|
||||||
|
decryption_method = config.decryption_map.get(service_name, config.decryption)
|
||||||
|
use_mp4decrypt = decryption_method.lower() == "mp4decrypt"
|
||||||
|
|
||||||
|
if use_mp4decrypt:
|
||||||
|
decrypt_tool = "mp4decrypt"
|
||||||
|
else:
|
||||||
|
decrypt_tool = "Shaka Packager"
|
||||||
|
|
||||||
|
drm_tracks = [track for track in title.tracks if track.drm]
|
||||||
|
if drm_tracks:
|
||||||
|
with console.status(f"Decrypting tracks with {decrypt_tool}..."):
|
||||||
|
has_decrypted = False
|
||||||
|
for track in drm_tracks:
|
||||||
|
for drm in track.drm:
|
||||||
|
if hasattr(drm, "decrypt"):
|
||||||
|
drm.decrypt(track.path, use_mp4decrypt=use_mp4decrypt)
|
||||||
|
has_decrypted = True
|
||||||
|
events.emit(events.Types.TRACK_REPACKED, track=track)
|
||||||
|
break
|
||||||
|
if has_decrypted:
|
||||||
|
self.log.info(f"Decrypted tracks with {decrypt_tool}")
|
||||||
|
|
||||||
|
# Now repack the decrypted tracks
|
||||||
with console.status("Repackaging tracks with FFMPEG..."):
|
with console.status("Repackaging tracks with FFMPEG..."):
|
||||||
has_repacked = False
|
has_repacked = False
|
||||||
for track in title.tracks:
|
for track in title.tracks:
|
||||||
@@ -1009,7 +1035,7 @@ class dl:
|
|||||||
|
|
||||||
multiplex_tasks.append((task_id, task_tracks))
|
multiplex_tasks.append((task_id, task_tracks))
|
||||||
|
|
||||||
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
with Live(Padding(progress, (0, 5, 1, 5)), console=console, transient=True):
|
||||||
for task_id, task_tracks in multiplex_tasks:
|
for task_id, task_tracks in multiplex_tasks:
|
||||||
progress.start_task(task_id) # TODO: Needed?
|
progress.start_task(task_id) # TODO: Needed?
|
||||||
muxed_path, return_code, errors = task_tracks.mux(
|
muxed_path, return_code, errors = task_tracks.mux(
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ def check() -> None:
|
|||||||
"desc": "DRM decryption",
|
"desc": "DRM decryption",
|
||||||
"cat": "DRM",
|
"cat": "DRM",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mp4decrypt",
|
||||||
|
"binary": binaries.Mp4decrypt,
|
||||||
|
"required": False,
|
||||||
|
"desc": "DRM decryption",
|
||||||
|
"cat": "DRM",
|
||||||
|
},
|
||||||
# HDR Processing
|
# HDR Processing
|
||||||
{"name": "dovi_tool", "binary": binaries.DoviTool, "required": False, "desc": "Dolby Vision", "cat": "HDR"},
|
{"name": "dovi_tool", "binary": binaries.DoviTool, "required": False, "desc": "Dolby Vision", "cat": "HDR"},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.2.0"
|
__version__ = "1.3.0"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ MKVToolNix = find("mkvmerge")
|
|||||||
Mkvpropedit = find("mkvpropedit")
|
Mkvpropedit = find("mkvpropedit")
|
||||||
DoviTool = find("dovi_tool")
|
DoviTool = find("dovi_tool")
|
||||||
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
|
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
|
||||||
|
Mp4decrypt = find("mp4decrypt")
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -71,5 +72,6 @@ __all__ = (
|
|||||||
"Mkvpropedit",
|
"Mkvpropedit",
|
||||||
"DoviTool",
|
"DoviTool",
|
||||||
"HDR10PlusTool",
|
"HDR10PlusTool",
|
||||||
|
"Mp4decrypt",
|
||||||
"find",
|
"find",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,10 +75,20 @@ class Config:
|
|||||||
self.proxy_providers: dict = kwargs.get("proxy_providers") or {}
|
self.proxy_providers: dict = kwargs.get("proxy_providers") or {}
|
||||||
self.serve: dict = kwargs.get("serve") or {}
|
self.serve: dict = kwargs.get("serve") or {}
|
||||||
self.services: dict = kwargs.get("services") or {}
|
self.services: dict = kwargs.get("services") or {}
|
||||||
|
decryption_cfg = kwargs.get("decryption") or {}
|
||||||
|
if isinstance(decryption_cfg, dict):
|
||||||
|
self.decryption_map = {k.upper(): v for k, v in decryption_cfg.items()}
|
||||||
|
self.decryption = self.decryption_map.get("DEFAULT", "shaka")
|
||||||
|
else:
|
||||||
|
self.decryption_map = {}
|
||||||
|
self.decryption = decryption_cfg or "shaka"
|
||||||
|
|
||||||
self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False)
|
self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False)
|
||||||
self.tag: str = kwargs.get("tag") or ""
|
self.tag: str = kwargs.get("tag") or ""
|
||||||
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
||||||
self.update_checks: bool = kwargs.get("update_checks", True)
|
self.update_checks: bool = kwargs.get("update_checks", True)
|
||||||
|
self.update_check_interval: int = kwargs.get("update_check_interval", 24)
|
||||||
|
self.scene_naming: bool = kwargs.get("scene_naming", True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml(cls, path: Path) -> Config:
|
def from_yaml(cls, path: Path) -> Config:
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import IO, Callable, Iterable, List, Literal, Mapping, Optional, Union
|
from typing import IO, Callable, Iterable, List, Literal, Mapping, Optional, Union
|
||||||
@@ -167,6 +170,8 @@ class ComfyConsole(Console):
|
|||||||
time.monotonic.
|
time.monotonic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_cleanup_registered = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -233,6 +238,9 @@ class ComfyConsole(Console):
|
|||||||
if log_renderer:
|
if log_renderer:
|
||||||
self._log_render = log_renderer
|
self._log_render = log_renderer
|
||||||
|
|
||||||
|
# Register terminal cleanup handlers
|
||||||
|
self._register_cleanup()
|
||||||
|
|
||||||
def status(
|
def status(
|
||||||
self,
|
self,
|
||||||
status: RenderableType,
|
status: RenderableType,
|
||||||
@@ -283,6 +291,38 @@ class ComfyConsole(Console):
|
|||||||
|
|
||||||
return status_renderable
|
return status_renderable
|
||||||
|
|
||||||
|
def _register_cleanup(self):
|
||||||
|
"""Register terminal cleanup handlers."""
|
||||||
|
if not ComfyConsole._cleanup_registered:
|
||||||
|
ComfyConsole._cleanup_registered = True
|
||||||
|
|
||||||
|
# Register cleanup on normal exit
|
||||||
|
atexit.register(self._cleanup_terminal)
|
||||||
|
|
||||||
|
# Register cleanup on signals
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
|
||||||
|
def _cleanup_terminal(self):
|
||||||
|
"""Restore terminal to a clean state."""
|
||||||
|
try:
|
||||||
|
# Show cursor using ANSI escape codes
|
||||||
|
sys.stdout.write("\x1b[?25h") # Show cursor
|
||||||
|
sys.stdout.write("\x1b[0m") # Reset attributes
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
# Also use Rich's method
|
||||||
|
self.show_cursor(True)
|
||||||
|
except Exception:
|
||||||
|
# Silently fail if cleanup fails
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _signal_handler(self, signum, frame):
|
||||||
|
"""Handle signals with cleanup."""
|
||||||
|
self._cleanup_terminal()
|
||||||
|
# Exit after cleanup
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
catppuccin_mocha = {
|
catppuccin_mocha = {
|
||||||
# Colors based on "CatppuccinMocha" from Gogh themes
|
# Colors based on "CatppuccinMocha" from Gogh themes
|
||||||
|
|||||||
@@ -187,14 +187,69 @@ class PlayReady:
|
|||||||
if not self.content_keys:
|
if not self.content_keys:
|
||||||
raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License")
|
raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License")
|
||||||
|
|
||||||
def decrypt(self, path: Path) -> None:
|
def decrypt(self, path: Path, use_mp4decrypt: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Decrypt a Track with PlayReady DRM.
|
||||||
|
Args:
|
||||||
|
path: Path to the encrypted file to decrypt
|
||||||
|
use_mp4decrypt: If True, use mp4decrypt instead of Shaka Packager
|
||||||
|
Raises:
|
||||||
|
EnvironmentError if the required decryption executable could not be found.
|
||||||
|
ValueError if the track has not yet been downloaded.
|
||||||
|
SubprocessError if the decryption process returned a non-zero exit code.
|
||||||
|
"""
|
||||||
if not self.content_keys:
|
if not self.content_keys:
|
||||||
raise ValueError("Cannot decrypt a Track without any Content Keys...")
|
raise ValueError("Cannot decrypt a Track without any Content Keys...")
|
||||||
if not binaries.ShakaPackager:
|
|
||||||
raise EnvironmentError("Shaka Packager executable not found but is required.")
|
|
||||||
if not path or not path.exists():
|
if not path or not path.exists():
|
||||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
raise ValueError("Tried to decrypt a file that does not exist.")
|
||||||
|
|
||||||
|
if use_mp4decrypt:
|
||||||
|
return self._decrypt_with_mp4decrypt(path)
|
||||||
|
else:
|
||||||
|
return self._decrypt_with_shaka_packager(path)
|
||||||
|
|
||||||
|
def _decrypt_with_mp4decrypt(self, path: Path) -> None:
|
||||||
|
"""Decrypt using mp4decrypt"""
|
||||||
|
if not binaries.Mp4decrypt:
|
||||||
|
raise EnvironmentError("mp4decrypt executable not found but is required.")
|
||||||
|
|
||||||
|
output_path = path.with_stem(f"{path.stem}_decrypted")
|
||||||
|
|
||||||
|
# Build key arguments
|
||||||
|
key_args = []
|
||||||
|
for kid, key in self.content_keys.items():
|
||||||
|
kid_hex = kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "")
|
||||||
|
key_hex = key if isinstance(key, str) else key.hex()
|
||||||
|
key_args.extend(["--key", f"{kid_hex}:{key_hex}"])
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
str(binaries.Mp4decrypt),
|
||||||
|
"--show-progress",
|
||||||
|
*key_args,
|
||||||
|
str(path),
|
||||||
|
str(output_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
|
||||||
|
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
|
||||||
|
|
||||||
|
if not output_path.exists():
|
||||||
|
raise RuntimeError(f"mp4decrypt failed: output file {output_path} was not created")
|
||||||
|
if output_path.stat().st_size == 0:
|
||||||
|
raise RuntimeError(f"mp4decrypt failed: output file {output_path} is empty")
|
||||||
|
|
||||||
|
path.unlink()
|
||||||
|
shutil.move(output_path, path)
|
||||||
|
|
||||||
|
def _decrypt_with_shaka_packager(self, path: Path) -> None:
|
||||||
|
"""Decrypt using Shaka Packager (original method)"""
|
||||||
|
if not binaries.ShakaPackager:
|
||||||
|
raise EnvironmentError("Shaka Packager executable not found but is required.")
|
||||||
|
|
||||||
output_path = path.with_stem(f"{path.stem}_decrypted")
|
output_path = path.with_stem(f"{path.stem}_decrypted")
|
||||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -227,22 +227,69 @@ class Widevine:
|
|||||||
finally:
|
finally:
|
||||||
cdm.close(session_id)
|
cdm.close(session_id)
|
||||||
|
|
||||||
def decrypt(self, path: Path) -> None:
|
def decrypt(self, path: Path, use_mp4decrypt: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Decrypt a Track with Widevine DRM.
|
Decrypt a Track with Widevine DRM.
|
||||||
|
Args:
|
||||||
|
path: Path to the encrypted file to decrypt
|
||||||
|
use_mp4decrypt: If True, use mp4decrypt instead of Shaka Packager
|
||||||
Raises:
|
Raises:
|
||||||
EnvironmentError if the Shaka Packager executable could not be found.
|
EnvironmentError if the required decryption executable could not be found.
|
||||||
ValueError if the track has not yet been downloaded.
|
ValueError if the track has not yet been downloaded.
|
||||||
SubprocessError if Shaka Packager returned a non-zero exit code.
|
SubprocessError if the decryption process returned a non-zero exit code.
|
||||||
"""
|
"""
|
||||||
if not self.content_keys:
|
if not self.content_keys:
|
||||||
raise ValueError("Cannot decrypt a Track without any Content Keys...")
|
raise ValueError("Cannot decrypt a Track without any Content Keys...")
|
||||||
|
|
||||||
if not binaries.ShakaPackager:
|
|
||||||
raise EnvironmentError("Shaka Packager executable not found but is required.")
|
|
||||||
if not path or not path.exists():
|
if not path or not path.exists():
|
||||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
raise ValueError("Tried to decrypt a file that does not exist.")
|
||||||
|
|
||||||
|
if use_mp4decrypt:
|
||||||
|
return self._decrypt_with_mp4decrypt(path)
|
||||||
|
else:
|
||||||
|
return self._decrypt_with_shaka_packager(path)
|
||||||
|
|
||||||
|
def _decrypt_with_mp4decrypt(self, path: Path) -> None:
|
||||||
|
"""Decrypt using mp4decrypt"""
|
||||||
|
if not binaries.Mp4decrypt:
|
||||||
|
raise EnvironmentError("mp4decrypt executable not found but is required.")
|
||||||
|
|
||||||
|
output_path = path.with_stem(f"{path.stem}_decrypted")
|
||||||
|
|
||||||
|
# Build key arguments
|
||||||
|
key_args = []
|
||||||
|
for kid, key in self.content_keys.items():
|
||||||
|
kid_hex = kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "")
|
||||||
|
key_hex = key if isinstance(key, str) else key.hex()
|
||||||
|
key_args.extend(["--key", f"{kid_hex}:{key_hex}"])
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
str(binaries.Mp4decrypt),
|
||||||
|
"--show-progress",
|
||||||
|
*key_args,
|
||||||
|
str(path),
|
||||||
|
str(output_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
|
||||||
|
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
|
||||||
|
|
||||||
|
if not output_path.exists():
|
||||||
|
raise RuntimeError(f"mp4decrypt failed: output file {output_path} was not created")
|
||||||
|
if output_path.stat().st_size == 0:
|
||||||
|
raise RuntimeError(f"mp4decrypt failed: output file {output_path} is empty")
|
||||||
|
|
||||||
|
path.unlink()
|
||||||
|
shutil.move(output_path, path)
|
||||||
|
|
||||||
|
def _decrypt_with_shaka_packager(self, path: Path) -> None:
|
||||||
|
"""Decrypt using Shaka Packager (original method)"""
|
||||||
|
if not binaries.ShakaPackager:
|
||||||
|
raise EnvironmentError("Shaka Packager executable not found but is required.")
|
||||||
|
|
||||||
output_path = path.with_stem(f"{path.stem}_decrypted")
|
output_path = path.with_stem(f"{path.stem}_decrypted")
|
||||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -107,75 +107,86 @@ class Episode(Title):
|
|||||||
name=self.name or "",
|
name=self.name or "",
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
# Resolution
|
if config.scene_naming:
|
||||||
if primary_video_track:
|
# Resolution
|
||||||
resolution = primary_video_track.height
|
if primary_video_track:
|
||||||
aspect_ratio = [int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")]
|
resolution = primary_video_track.height
|
||||||
if len(aspect_ratio) == 1:
|
aspect_ratio = [
|
||||||
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
|
||||||
aspect_ratio.append(1)
|
]
|
||||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
if len(aspect_ratio) == 1:
|
||||||
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
||||||
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
aspect_ratio.append(1)
|
||||||
# otherwise the track's height value is fine.
|
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
||||||
# We are assuming this title is some weird aspect ratio so most
|
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
||||||
# likely a movie or HD source, so it's most likely widescreen so
|
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
||||||
# 16:9 canvas makes the most sense.
|
# otherwise the track's height value is fine.
|
||||||
resolution = int(primary_video_track.width * (9 / 16))
|
# We are assuming this title is some weird aspect ratio so most
|
||||||
name += f" {resolution}p"
|
# 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))
|
||||||
|
name += f" {resolution}p"
|
||||||
|
|
||||||
# Service
|
# Service
|
||||||
if show_service:
|
if show_service:
|
||||||
name += f" {self.service.__name__}"
|
name += f" {self.service.__name__}"
|
||||||
|
|
||||||
# 'WEB-DL'
|
# 'WEB-DL'
|
||||||
name += " WEB-DL"
|
name += " WEB-DL"
|
||||||
|
|
||||||
# DUAL
|
# DUAL
|
||||||
if unique_audio_languages == 2:
|
if unique_audio_languages == 2:
|
||||||
name += " DUAL"
|
name += " DUAL"
|
||||||
|
|
||||||
# MULTi
|
# MULTi
|
||||||
if unique_audio_languages > 2:
|
if unique_audio_languages > 2:
|
||||||
name += " MULTi"
|
name += " MULTi"
|
||||||
|
|
||||||
# Audio Codec + Channels (+ feature)
|
# Audio Codec + Channels (+ feature)
|
||||||
if primary_audio_track:
|
if primary_audio_track:
|
||||||
codec = primary_audio_track.format
|
codec = primary_audio_track.format
|
||||||
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
|
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
|
||||||
if channel_layout:
|
if channel_layout:
|
||||||
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
|
channels = float(
|
||||||
else:
|
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
|
||||||
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
|
|
||||||
trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original
|
|
||||||
frame_rate = float(primary_video_track.frame_rate)
|
|
||||||
if hdr_format:
|
|
||||||
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
|
|
||||||
if (primary_video_track.hdr_format_commercial) != "Dolby Vision":
|
|
||||||
name += f" DV {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
|
||||||
else:
|
else:
|
||||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
|
||||||
elif trc and "HLG" in trc:
|
channels = float(channel_count)
|
||||||
name += " HLG"
|
|
||||||
if frame_rate > 30:
|
|
||||||
name += " HFR"
|
|
||||||
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
|
||||||
|
|
||||||
if config.tag:
|
features = primary_audio_track.format_additionalfeatures or ""
|
||||||
name += f"-{config.tag}"
|
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
||||||
|
if "JOC" in features or primary_audio_track.joc:
|
||||||
|
name += " Atmos"
|
||||||
|
|
||||||
return sanitize_filename(name)
|
# Video (dynamic range + hfr +) Codec
|
||||||
|
if primary_video_track:
|
||||||
|
codec = primary_video_track.format
|
||||||
|
hdr_format = primary_video_track.hdr_format_commercial
|
||||||
|
trc = (
|
||||||
|
primary_video_track.transfer_characteristics
|
||||||
|
or primary_video_track.transfer_characteristics_original
|
||||||
|
)
|
||||||
|
frame_rate = float(primary_video_track.frame_rate)
|
||||||
|
if hdr_format:
|
||||||
|
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
|
||||||
|
if (primary_video_track.hdr_format_commercial) != "Dolby Vision":
|
||||||
|
name += f" DV {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||||
|
else:
|
||||||
|
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||||
|
elif trc and "HLG" in trc:
|
||||||
|
name += " HLG"
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
# Simple naming style without technical details - use spaces instead of dots
|
||||||
|
return sanitize_filename(name, " ")
|
||||||
|
|
||||||
|
|
||||||
class Series(SortedKeyList, ABC):
|
class Series(SortedKeyList, ABC):
|
||||||
|
|||||||
@@ -58,75 +58,86 @@ class Movie(Title):
|
|||||||
# Name (Year)
|
# Name (Year)
|
||||||
name = str(self).replace("$", "S") # e.g., Arli$$
|
name = str(self).replace("$", "S") # e.g., Arli$$
|
||||||
|
|
||||||
# Resolution
|
if config.scene_naming:
|
||||||
if primary_video_track:
|
# Resolution
|
||||||
resolution = primary_video_track.height
|
if primary_video_track:
|
||||||
aspect_ratio = [int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")]
|
resolution = primary_video_track.height
|
||||||
if len(aspect_ratio) == 1:
|
aspect_ratio = [
|
||||||
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
|
||||||
aspect_ratio.append(1)
|
]
|
||||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
if len(aspect_ratio) == 1:
|
||||||
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
||||||
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
aspect_ratio.append(1)
|
||||||
# otherwise the track's height value is fine.
|
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
||||||
# We are assuming this title is some weird aspect ratio so most
|
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
||||||
# likely a movie or HD source, so it's most likely widescreen so
|
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
||||||
# 16:9 canvas makes the most sense.
|
# otherwise the track's height value is fine.
|
||||||
resolution = int(primary_video_track.width * (9 / 16))
|
# We are assuming this title is some weird aspect ratio so most
|
||||||
name += f" {resolution}p"
|
# 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))
|
||||||
|
name += f" {resolution}p"
|
||||||
|
|
||||||
# Service
|
# Service
|
||||||
if show_service:
|
if show_service:
|
||||||
name += f" {self.service.__name__}"
|
name += f" {self.service.__name__}"
|
||||||
|
|
||||||
# 'WEB-DL'
|
# 'WEB-DL'
|
||||||
name += " WEB-DL"
|
name += " WEB-DL"
|
||||||
|
|
||||||
# DUAL
|
# DUAL
|
||||||
if unique_audio_languages == 2:
|
if unique_audio_languages == 2:
|
||||||
name += " DUAL"
|
name += " DUAL"
|
||||||
|
|
||||||
# MULTi
|
# MULTi
|
||||||
if unique_audio_languages > 2:
|
if unique_audio_languages > 2:
|
||||||
name += " MULTi"
|
name += " MULTi"
|
||||||
|
|
||||||
# Audio Codec + Channels (+ feature)
|
# Audio Codec + Channels (+ feature)
|
||||||
if primary_audio_track:
|
if primary_audio_track:
|
||||||
codec = primary_audio_track.format
|
codec = primary_audio_track.format
|
||||||
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
|
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
|
||||||
if channel_layout:
|
if channel_layout:
|
||||||
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
|
channels = float(
|
||||||
else:
|
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
|
||||||
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
|
|
||||||
trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original
|
|
||||||
frame_rate = float(primary_video_track.frame_rate)
|
|
||||||
if hdr_format:
|
|
||||||
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
|
|
||||||
if (primary_video_track.hdr_format_commercial) != "Dolby Vision":
|
|
||||||
name += f" DV {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
|
||||||
else:
|
else:
|
||||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
|
||||||
elif trc and "HLG" in trc:
|
channels = float(channel_count)
|
||||||
name += " HLG"
|
|
||||||
if frame_rate > 30:
|
|
||||||
name += " HFR"
|
|
||||||
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
|
||||||
|
|
||||||
if config.tag:
|
features = primary_audio_track.format_additionalfeatures or ""
|
||||||
name += f"-{config.tag}"
|
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
||||||
|
if "JOC" in features or primary_audio_track.joc:
|
||||||
|
name += " Atmos"
|
||||||
|
|
||||||
return sanitize_filename(name)
|
# Video (dynamic range + hfr +) Codec
|
||||||
|
if primary_video_track:
|
||||||
|
codec = primary_video_track.format
|
||||||
|
hdr_format = primary_video_track.hdr_format_commercial
|
||||||
|
trc = (
|
||||||
|
primary_video_track.transfer_characteristics
|
||||||
|
or primary_video_track.transfer_characteristics_original
|
||||||
|
)
|
||||||
|
frame_rate = float(primary_video_track.frame_rate)
|
||||||
|
if hdr_format:
|
||||||
|
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
|
||||||
|
if (primary_video_track.hdr_format_commercial) != "Dolby Vision":
|
||||||
|
name += f" DV {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||||
|
else:
|
||||||
|
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||||
|
elif trc and "HLG" in trc:
|
||||||
|
name += " HLG"
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
# Simple naming style without technical details - use spaces instead of dots
|
||||||
|
return sanitize_filename(name, " ")
|
||||||
|
|
||||||
|
|
||||||
class Movies(SortedKeyList, ABC):
|
class Movies(SortedKeyList, ABC):
|
||||||
|
|||||||
@@ -100,22 +100,26 @@ class Song(Title):
|
|||||||
# NN. Song Name
|
# NN. Song Name
|
||||||
name = str(self).split(" / ")[1]
|
name = str(self).split(" / ")[1]
|
||||||
|
|
||||||
# Service
|
if config.scene_naming:
|
||||||
if show_service:
|
# Service
|
||||||
name += f" {self.service.__name__}"
|
if show_service:
|
||||||
|
name += f" {self.service.__name__}"
|
||||||
|
|
||||||
# 'WEB-DL'
|
# 'WEB-DL'
|
||||||
name += " WEB-DL"
|
name += " WEB-DL"
|
||||||
|
|
||||||
# Audio Codec + Channels (+ feature)
|
# Audio Codec + Channels (+ feature)
|
||||||
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
|
||||||
if "JOC" in features or audio_track.joc:
|
if "JOC" in features or audio_track.joc:
|
||||||
name += " Atmos"
|
name += " Atmos"
|
||||||
|
|
||||||
if config.tag:
|
if config.tag:
|
||||||
name += f"-{config.tag}"
|
name += f"-{config.tag}"
|
||||||
|
|
||||||
return sanitize_filename(name, " ")
|
return sanitize_filename(name, " ")
|
||||||
|
else:
|
||||||
|
# Simple naming style without technical details
|
||||||
|
return sanitize_filename(name, " ")
|
||||||
|
|
||||||
|
|
||||||
class Album(SortedKeyList, ABC):
|
class Album(SortedKeyList, ABC):
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class Hybrid:
|
|||||||
|
|
||||||
for video in self.videos:
|
for video in self.videos:
|
||||||
if not video.path or not os.path.exists(video.path):
|
if not video.path or not os.path.exists(video.path):
|
||||||
self.log.exit(f" - Video track {video.id} was not downloaded before injection.")
|
raise ValueError(f"Video track {video.id} was not downloaded before injection.")
|
||||||
|
|
||||||
# Check if we have DV track available
|
# Check if we have DV track available
|
||||||
has_dv = any(video.range == Video.Range.DV for video in self.videos)
|
has_dv = any(video.range == Video.Range.DV for video in self.videos)
|
||||||
@@ -51,14 +51,14 @@ class Hybrid:
|
|||||||
has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos)
|
has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos)
|
||||||
|
|
||||||
if not has_hdr10:
|
if not has_hdr10:
|
||||||
self.log.exit(" - No HDR10 track available for hybrid processing.")
|
raise ValueError("No HDR10 track available for hybrid processing.")
|
||||||
|
|
||||||
# If we have HDR10+ but no DV, we can convert HDR10+ to DV
|
# If we have HDR10+ but no DV, we can convert HDR10+ to DV
|
||||||
if not has_dv and has_hdr10p:
|
if not has_dv and has_hdr10p:
|
||||||
self.log.info("✓ No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
|
self.log.info("✓ No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
|
||||||
self.hdr10plus_to_dv = True
|
self.hdr10plus_to_dv = True
|
||||||
elif not has_dv:
|
elif not has_dv:
|
||||||
self.log.exit(" - No DV track available and no HDR10+ to convert.")
|
raise ValueError("No DV track available and no HDR10+ to convert.")
|
||||||
|
|
||||||
if os.path.isfile(config.directories.temp / self.hevc_file):
|
if os.path.isfile(config.directories.temp / self.hevc_file):
|
||||||
self.log.info("✓ Already Injected")
|
self.log.info("✓ Already Injected")
|
||||||
@@ -68,7 +68,7 @@ class Hybrid:
|
|||||||
# Use the actual path from the video track
|
# Use the actual path from the video track
|
||||||
save_path = video.path
|
save_path = video.path
|
||||||
if not save_path or not os.path.exists(save_path):
|
if not save_path or not os.path.exists(save_path):
|
||||||
self.log.exit(f" - Video track {video.id} was not downloaded or path not found: {save_path}")
|
raise ValueError(f"Video track {video.id} was not downloaded or path not found: {save_path}")
|
||||||
|
|
||||||
if video.range == Video.Range.HDR10:
|
if video.range == Video.Range.HDR10:
|
||||||
self.extract_stream(save_path, "HDR10")
|
self.extract_stream(save_path, "HDR10")
|
||||||
@@ -164,9 +164,9 @@ class Hybrid:
|
|||||||
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
|
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
|
||||||
self.extract_rpu(video, untouched=True)
|
self.extract_rpu(video, untouched=True)
|
||||||
elif b"Invalid PPS index" in rpu_extraction.stderr:
|
elif b"Invalid PPS index" in rpu_extraction.stderr:
|
||||||
self.log.exit("x Dolby Vision VideoTrack seems to be corrupt")
|
raise ValueError("Dolby Vision VideoTrack seems to be corrupt")
|
||||||
else:
|
else:
|
||||||
self.log.exit(f"x Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
|
raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
|
||||||
|
|
||||||
def level_6(self):
|
def level_6(self):
|
||||||
"""Edit RPU Level 6 values"""
|
"""Edit RPU Level 6 values"""
|
||||||
@@ -203,7 +203,7 @@ class Hybrid:
|
|||||||
|
|
||||||
if level6.returncode:
|
if level6.returncode:
|
||||||
Path.unlink(config.directories.temp / "RPU_L6.bin")
|
Path.unlink(config.directories.temp / "RPU_L6.bin")
|
||||||
self.log.exit("x Failed editing RPU Level 6 values")
|
raise ValueError("Failed editing RPU Level 6 values")
|
||||||
|
|
||||||
# Update rpu_file to use the edited version
|
# Update rpu_file to use the edited version
|
||||||
self.rpu_file = "RPU_L6.bin"
|
self.rpu_file = "RPU_L6.bin"
|
||||||
@@ -239,7 +239,7 @@ class Hybrid:
|
|||||||
|
|
||||||
if inject.returncode:
|
if inject.returncode:
|
||||||
Path.unlink(config.directories.temp / self.hevc_file)
|
Path.unlink(config.directories.temp / self.hevc_file)
|
||||||
self.log.exit("x Failed injecting Dolby Vision metadata into HDR10 stream")
|
raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream")
|
||||||
|
|
||||||
def extract_hdr10plus(self, _video):
|
def extract_hdr10plus(self, _video):
|
||||||
"""Extract HDR10+ metadata from the video stream"""
|
"""Extract HDR10+ metadata from the video stream"""
|
||||||
@@ -247,7 +247,7 @@ class Hybrid:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not HDR10PlusTool:
|
if not HDR10PlusTool:
|
||||||
self.log.exit("x HDR10Plus_tool not found. Please install it to use HDR10+ to DV conversion.")
|
raise ValueError("HDR10Plus_tool not found. Please install it to use HDR10+ to DV conversion.")
|
||||||
|
|
||||||
self.log.info("+ Extracting HDR10+ metadata")
|
self.log.info("+ Extracting HDR10+ metadata")
|
||||||
|
|
||||||
@@ -265,11 +265,11 @@ class Hybrid:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if extraction.returncode:
|
if extraction.returncode:
|
||||||
self.log.exit("x Failed extracting HDR10+ metadata")
|
raise ValueError("Failed extracting HDR10+ metadata")
|
||||||
|
|
||||||
# Check if the extracted file has content
|
# Check if the extracted file has content
|
||||||
if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0:
|
if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0:
|
||||||
self.log.exit("x No HDR10+ metadata found in the stream")
|
raise ValueError("No HDR10+ metadata found in the stream")
|
||||||
|
|
||||||
def convert_hdr10plus_to_dv(self):
|
def convert_hdr10plus_to_dv(self):
|
||||||
"""Convert HDR10+ metadata to Dolby Vision RPU"""
|
"""Convert HDR10+ metadata to Dolby Vision RPU"""
|
||||||
@@ -310,7 +310,7 @@ class Hybrid:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if conversion.returncode:
|
if conversion.returncode:
|
||||||
self.log.exit("x Failed converting HDR10+ to Dolby Vision")
|
raise ValueError("Failed converting HDR10+ to Dolby Vision")
|
||||||
|
|
||||||
self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8")
|
self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8")
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ class Video(Track):
|
|||||||
class Transfer(Enum):
|
class Transfer(Enum):
|
||||||
Unspecified = 0
|
Unspecified = 0
|
||||||
BT_709 = 1
|
BT_709 = 1
|
||||||
|
Unspecified_Image = 2
|
||||||
BT_601 = 6
|
BT_601 = 6
|
||||||
BT_2020 = 14
|
BT_2020 = 14
|
||||||
BT_2100 = 15
|
BT_2100 = 15
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -11,6 +14,66 @@ class UpdateChecker:
|
|||||||
|
|
||||||
REPO_URL = "https://api.github.com/repos/unshackle-dl/unshackle/releases/latest"
|
REPO_URL = "https://api.github.com/repos/unshackle-dl/unshackle/releases/latest"
|
||||||
TIMEOUT = 5
|
TIMEOUT = 5
|
||||||
|
DEFAULT_CHECK_INTERVAL = 24 * 60 * 60 # 24 hours in seconds
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_cache_file(cls) -> Path:
|
||||||
|
"""Get the path to the update check cache file."""
|
||||||
|
from unshackle.core.config import config
|
||||||
|
|
||||||
|
return config.directories.cache / "update_check.json"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _should_check_for_updates(cls, check_interval: int = DEFAULT_CHECK_INTERVAL) -> bool:
|
||||||
|
"""
|
||||||
|
Check if enough time has passed since the last update check.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
check_interval: Time in seconds between checks (default: 24 hours)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if we should check for updates, False otherwise
|
||||||
|
"""
|
||||||
|
cache_file = cls._get_cache_file()
|
||||||
|
|
||||||
|
if not cache_file.exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cache_file, "r") as f:
|
||||||
|
cache_data = json.load(f)
|
||||||
|
|
||||||
|
last_check = cache_data.get("last_check", 0)
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
return (current_time - last_check) >= check_interval
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, KeyError, OSError):
|
||||||
|
# If cache is corrupted or unreadable, allow check
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _update_cache(cls, latest_version: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Update the cache file with the current timestamp and latest version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
latest_version: The latest version found, if any
|
||||||
|
"""
|
||||||
|
cache_file = cls._get_cache_file()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure cache directory exists
|
||||||
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cache_data = {"last_check": time.time(), "latest_version": latest_version}
|
||||||
|
|
||||||
|
with open(cache_file, "w") as f:
|
||||||
|
json.dump(cache_data, f)
|
||||||
|
|
||||||
|
except (OSError, json.JSONEncodeError):
|
||||||
|
# Silently fail if we can't write cache
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _compare_versions(current: str, latest: str) -> bool:
|
def _compare_versions(current: str, latest: str) -> bool:
|
||||||
@@ -75,32 +138,51 @@ class UpdateChecker:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_for_updates_sync(cls, current_version: str) -> Optional[str]:
|
def check_for_updates_sync(cls, current_version: str, check_interval: Optional[int] = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Synchronous version of update check.
|
Synchronous version of update check with rate limiting.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
current_version: The current version string (e.g., "1.1.0")
|
current_version: The current version string (e.g., "1.1.0")
|
||||||
|
check_interval: Time in seconds between checks (default: from config)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The latest version string if an update is available, None otherwise
|
The latest version string if an update is available, None otherwise
|
||||||
"""
|
"""
|
||||||
|
# Use config value if not specified
|
||||||
|
if check_interval is None:
|
||||||
|
from unshackle.core.config import config
|
||||||
|
|
||||||
|
check_interval = config.update_check_interval * 60 * 60 # Convert hours to seconds
|
||||||
|
|
||||||
|
# Check if we should skip this check due to rate limiting
|
||||||
|
if not cls._should_check_for_updates(check_interval):
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(cls.REPO_URL, timeout=cls.TIMEOUT)
|
response = requests.get(cls.REPO_URL, timeout=cls.TIMEOUT)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
# Update cache even on failure to prevent rapid retries
|
||||||
|
cls._update_cache()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
latest_version = data.get("tag_name", "").lstrip("v")
|
latest_version = data.get("tag_name", "").lstrip("v")
|
||||||
|
|
||||||
if not latest_version:
|
if not latest_version:
|
||||||
|
cls._update_cache()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Update cache with the latest version info
|
||||||
|
cls._update_cache(latest_version)
|
||||||
|
|
||||||
if cls._compare_versions(current_version, latest_version):
|
if cls._compare_versions(current_version, latest_version):
|
||||||
return latest_version
|
return latest_version
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Update cache even on exception to prevent rapid retries
|
||||||
|
cls._update_cache()
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -4,17 +4,40 @@ tag: user_tag
|
|||||||
# Set terminal background color (custom option not in CONFIG.md)
|
# Set terminal background color (custom option not in CONFIG.md)
|
||||||
set_terminal_bg: false
|
set_terminal_bg: false
|
||||||
|
|
||||||
|
# Set file naming convention
|
||||||
|
# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264
|
||||||
|
# false for style - Prime Suspect S07E01 The Final Act - Part One
|
||||||
|
scene_naming: true
|
||||||
|
|
||||||
# Check for updates from GitHub repository on startup (default: true)
|
# Check for updates from GitHub repository on startup (default: true)
|
||||||
update_checks: true
|
update_checks: true
|
||||||
|
|
||||||
|
# How often to check for updates, in hours (default: 24)
|
||||||
|
update_check_interval: 24
|
||||||
|
|
||||||
# Muxing configuration
|
# Muxing configuration
|
||||||
muxing:
|
muxing:
|
||||||
set_title: false
|
set_title: false
|
||||||
|
|
||||||
# Login credentials for each Service
|
# Login credentials for each Service
|
||||||
credentials:
|
credentials:
|
||||||
|
# Direct credentials (no profile support)
|
||||||
EXAMPLE: email@example.com:password
|
EXAMPLE: email@example.com:password
|
||||||
EXAMPLE2: username:password
|
|
||||||
|
# Per-profile credentials with default fallback
|
||||||
|
SERVICE_NAME:
|
||||||
|
default: default@email.com:password # Used when no -p/--profile is specified
|
||||||
|
profile1: user1@email.com:password1
|
||||||
|
profile2: user2@email.com:password2
|
||||||
|
|
||||||
|
# Per-profile credentials without default (requires -p/--profile)
|
||||||
|
SERVICE_NAME2:
|
||||||
|
john: john@example.com:johnspassword
|
||||||
|
jane: jane@example.com:janespassword
|
||||||
|
|
||||||
|
# You can also use list format for passwords with special characters
|
||||||
|
SERVICE_NAME3:
|
||||||
|
default: ["user@email.com", ":PasswordWith:Colons"]
|
||||||
|
|
||||||
# Override default directories used across unshackle
|
# Override default directories used across unshackle
|
||||||
directories:
|
directories:
|
||||||
@@ -36,8 +59,17 @@ directories:
|
|||||||
|
|
||||||
# Pre-define which Widevine or PlayReady device to use for each Service
|
# Pre-define which Widevine or PlayReady device to use for each Service
|
||||||
cdm:
|
cdm:
|
||||||
|
# Global default CDM device (fallback for all services/profiles)
|
||||||
default: WVD_1
|
default: WVD_1
|
||||||
EXAMPLE: PRD_1
|
|
||||||
|
# Direct service-specific CDM
|
||||||
|
DIFFERENT_EXAMPLE: PRD_1
|
||||||
|
|
||||||
|
# Per-profile CDM configuration
|
||||||
|
EXAMPLE:
|
||||||
|
john_sd: chromecdm_903_l3 # Profile 'john_sd' uses Chrome CDM L3
|
||||||
|
jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1
|
||||||
|
default: generic_android_l3 # Default CDM for this service
|
||||||
|
|
||||||
# Use pywidevine Serve-compliant Remote CDMs
|
# Use pywidevine Serve-compliant Remote CDMs
|
||||||
remote_cdm:
|
remote_cdm:
|
||||||
@@ -154,20 +186,45 @@ serve:
|
|||||||
# Configuration data for each Service
|
# Configuration data for each Service
|
||||||
services:
|
services:
|
||||||
# Service-specific configuration goes here
|
# Service-specific configuration goes here
|
||||||
# EXAMPLE:
|
# Profile-specific configurations can be nested under service names
|
||||||
# api_key: "service_specific_key"
|
|
||||||
|
# Example: with profile-specific device configs
|
||||||
|
EXAMPLE:
|
||||||
|
# Global service config
|
||||||
|
api_key: "service_api_key"
|
||||||
|
|
||||||
|
# Profile-specific device configurations
|
||||||
|
profiles:
|
||||||
|
john_sd:
|
||||||
|
device:
|
||||||
|
app_name: "AIV"
|
||||||
|
device_model: "SHIELD Android TV"
|
||||||
|
jane_uhd:
|
||||||
|
device:
|
||||||
|
app_name: "AIV"
|
||||||
|
device_model: "Fire TV Stick 4K"
|
||||||
|
|
||||||
|
# Example: Service with different regions per profile
|
||||||
|
SERVICE_NAME:
|
||||||
|
profiles:
|
||||||
|
us_account:
|
||||||
|
region: "US"
|
||||||
|
api_endpoint: "https://api.us.service.com"
|
||||||
|
uk_account:
|
||||||
|
region: "GB"
|
||||||
|
api_endpoint: "https://api.uk.service.com"
|
||||||
|
|
||||||
# External proxy provider services
|
# External proxy provider services
|
||||||
proxy_providers:
|
proxy_providers:
|
||||||
nordvpn:
|
nordvpn:
|
||||||
username: username_from_service_credentials
|
username: username_from_service_credentials
|
||||||
password: password_from_service_credentials
|
password: password_from_service_credentials
|
||||||
servers:
|
server_map:
|
||||||
- us: 12 # force US server #12 for US proxies
|
- us: 12 # force US server #12 for US proxies
|
||||||
surfsharkvpn:
|
surfsharkvpn:
|
||||||
username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
||||||
password: your_surfshark_service_password # Service credentials (not your login password)
|
password: your_surfshark_service_password # Service credentials (not your login password)
|
||||||
servers:
|
server_map:
|
||||||
- us: 3844 # force US server #3844 for US proxies
|
- us: 3844 # force US server #3844 for US proxies
|
||||||
- gb: 2697 # force GB server #2697 for GB proxies
|
- gb: 2697 # force GB server #2697 for GB proxies
|
||||||
- au: 4621 # force AU server #4621 for AU proxies
|
- au: 4621 # force AU server #4621 for AU proxies
|
||||||
@@ -30,7 +30,7 @@ class HTTP(Vault):
|
|||||||
api_mode: "query" for query parameters or "json" for JSON API
|
api_mode: "query" for query parameters or "json" for JSON API
|
||||||
"""
|
"""
|
||||||
super().__init__(name)
|
super().__init__(name)
|
||||||
self.url = host.rstrip("/")
|
self.url = host
|
||||||
self.password = password
|
self.password = password
|
||||||
self.username = username
|
self.username = username
|
||||||
self.api_mode = api_mode.lower()
|
self.api_mode = api_mode.lower()
|
||||||
@@ -88,21 +88,23 @@ class HTTP(Vault):
|
|||||||
|
|
||||||
if self.api_mode == "json":
|
if self.api_mode == "json":
|
||||||
try:
|
try:
|
||||||
title = getattr(self, "current_title", None)
|
params = {
|
||||||
response = self.request(
|
"kid": kid,
|
||||||
"GetKey",
|
"service": service.lower(),
|
||||||
{
|
}
|
||||||
"kid": kid,
|
|
||||||
"service": service.lower(),
|
response = self.request("GetKey", params)
|
||||||
"title": title,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if response.get("status") == "not_found":
|
if response.get("status") == "not_found":
|
||||||
return None
|
return None
|
||||||
keys = response.get("keys", [])
|
keys = response.get("keys", [])
|
||||||
for key_entry in keys:
|
for key_entry in keys:
|
||||||
if key_entry["kid"] == kid:
|
if isinstance(key_entry, str) and ":" in key_entry:
|
||||||
return key_entry["key"]
|
entry_kid, entry_key = key_entry.split(":", 1)
|
||||||
|
if entry_kid == kid:
|
||||||
|
return entry_key
|
||||||
|
elif isinstance(key_entry, dict):
|
||||||
|
if key_entry.get("kid") == kid:
|
||||||
|
return key_entry.get("key")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to get key ({e.__class__.__name__}: {e})")
|
print(f"Failed to get key ({e.__class__.__name__}: {e})")
|
||||||
return None
|
return None
|
||||||
|
|||||||
Reference in New Issue
Block a user