upstream updates

This commit is contained in:
VineFeeder
2026-07-01 09:12:10 +01:00
parent 82b654ddaa
commit 9567540e59
28 changed files with 122 additions and 92 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "uv_build"
[project] [project]
name = "envied" name = "envied"
version = "5.1.0" version = "5.3.0"
description = "Modular Movie, TV, and Music Archival Software." description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "rlaphoenix and others" }] authors = [{ name = "rlaphoenix and others" }]
requires-python = ">=3.10,<=3.14" requires-python = ">=3.10,<=3.14"
+34
View File
@@ -2,6 +2,40 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 5.3.0 01-07-2026
Repository: unshackle-dl/unshackle · Tag: 5.3.0 · Commit: d4a5826 · Released by: imSp4rky
Features
--merge-video: merge video language variants into one MKV per (resolution, range, codec) group
Native ExpressVPN HTTPS proxy provider (full OAuth PKCE flow, no external tools)
Proton VPN proxy provider with country/city/server-number selection, plus ProtonVPN TV-code login
Load service plugins from git repos (clone + TTL-pull cache, refresh-services command, dirty-clone guard)
Python 3.14 support (supported range widened to 3.11-3.14)
Automatic Firefox cookie and localStorage extraction
Nested directories in folder templates (/ treated as path separator)
air_date for date-based episode naming (daily/sports content named by date instead of SxxExx)
Per-vault network timeout for remote API/HTTP vaults
Richer API job progress: active-track segment counts, transfer speed, title description/date/cover_url
Bug Fixes
drm: don't switch to a mismatched CDM type during licensing; raise a clear error instead
config: match per-service cdm keys case-insensitively
dl: apply per-service decryption tool override before decrypt
hls: stream segment merges to avoid OOM on large (multi-GB) tracks
console: pause active live/spinner contexts during terminal input so prompts stay visible
output: treat both / and \ as folder separators on any OS
env: only resolve Path items in directory lists (git repo specs pass through)
Performance & Changes
perf(hls): rename single decrypted range instead of re-copying a full track
perf(tracks): skip redundant DV/VUI bitstream passes when VUI already matches range
api: collapse route registration, dedup handlers, remove dead serve/remote code paths
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).
+14 -38
View File
@@ -514,7 +514,7 @@ class dl:
type=str, type=str,
default=None, default=None,
hidden=True, hidden=True,
help="Internal: path to an export JSON to reconstruct a download from (used by 'unshackle import').", help="Internal: path to an export JSON to reconstruct a download from (used by 'envied.import').",
) )
@click.option( @click.option(
"--cdm-only/--vaults-only", "--cdm-only/--vaults-only",
@@ -571,7 +571,7 @@ class dl:
is_flag=True, is_flag=True,
default=False, default=False,
is_eager=True, is_eager=True,
help="Use a remote unshackle server instead of local service code.", help="Use a remote envied.server instead of local service code.",
) )
@click.option( @click.option(
"--server", "--server",
@@ -614,7 +614,7 @@ class dl:
raise click.ClickException( raise click.ClickException(
"No 'output_template' configured in your envied.yaml.\n" "No 'output_template' configured in your envied.yaml.\n"
"Please add an 'output_template' section with movies, series, and songs templates.\n" "Please add an 'output_template' section with movies, series, and songs templates.\n"
"See unshackle-example.yaml for examples." "See envied.example.yaml for examples."
) )
self.service = Services.get_tag(ctx.invoked_subcommand) self.service = Services.get_tag(ctx.invoked_subcommand)
@@ -1697,24 +1697,12 @@ class dl:
has_hybrid = any(r == Video.Range.HYBRID for r in range_) has_hybrid = any(r == Video.Range.HYBRID for r in range_)
non_hybrid_ranges = [r for r in range_ if r != Video.Range.HYBRID] non_hybrid_ranges = [r for r in range_ if r != Video.Range.HYBRID]
# DV is both a hybrid ingredient (lowest track) and, when explicitly
# requested, a standalone deliverable (best track per resolution).
dv_is_deliverable = Video.Range.DV in non_hybrid_ranges
if quality: if quality:
missing_resolutions = [] missing_resolutions = []
if has_hybrid: if has_hybrid:
hybrid_candidate_tracks = [ hybrid_candidate_tracks, non_hybrid_tracks = Tracks.partition_hybrid_videos(
v title.tracks.videos, non_hybrid_ranges
for v in title.tracks.videos )
if v.range in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
]
non_hybrid_tracks = [
v
for v in title.tracks.videos
if v.range not in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
or (dv_is_deliverable and v.range == Video.Range.DV)
]
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality, worst=worst) hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality, worst=worst)
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks)) hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
@@ -1757,17 +1745,9 @@ class dl:
pre_hybrid_videos: list[Video] = list(title.tracks.videos) if has_hybrid else [] pre_hybrid_videos: list[Video] = list(title.tracks.videos) if has_hybrid else []
if has_hybrid: if has_hybrid:
# Apply hybrid selection for HYBRID tracks # Apply hybrid selection for HYBRID tracks
hybrid_candidate_tracks = [ hybrid_candidate_tracks, non_hybrid_tracks = Tracks.partition_hybrid_videos(
v title.tracks.videos, non_hybrid_ranges
for v in title.tracks.videos )
if v.range in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
]
non_hybrid_tracks = [
v
for v in title.tracks.videos
if v.range not in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
or (dv_is_deliverable and v.range == Video.Range.DV)
]
if not quality: if not quality:
best_resolution = max((v.height for v in hybrid_candidate_tracks), default=None) best_resolution = max((v.height for v in hybrid_candidate_tracks), default=None)
@@ -1811,14 +1791,10 @@ class dl:
title.tracks.videos = Tracks.merge_video_selections(hybrid_selected, non_hybrid_selected) title.tracks.videos = Tracks.merge_video_selections(hybrid_selected, non_hybrid_selected)
# Flag the lowest DV track as ingredient-only so mux skips it standalone, # Flag tracks selected only as hybrid ingredients (the HDR base and/or
# unless it is itself the chosen DV deliverable (single DV rendition). # the lowest DV) so the standalone mux loop skips them. Tracks also
selected_dv = [v for v in title.tracks.videos if v.range == Video.Range.DV] # picked as explicit deliverables stay unflagged.
if selected_dv: Tracks.flag_hybrid_ingredients(hybrid_selected, non_hybrid_selected)
ingredient_dv = min(selected_dv, key=lambda v: v.height)
deliverable_dv = [v for v in non_hybrid_selected if v.range == Video.Range.DV]
if not (dv_is_deliverable and ingredient_dv in deliverable_dv):
ingredient_dv.hybrid_base_only = True
else: else:
selected_videos: list[Video] = [] selected_videos: list[Video] = []
if video_multi_lang: if video_multi_lang:
@@ -2792,7 +2768,7 @@ class dl:
return meta return meta
def write_export(self, export: Path, title: Title_T, track: AnyTrack, drm: Any) -> None: def write_export(self, export: Path, title: Title_T, track: AnyTrack, drm: Any) -> None:
"""Write a shareable v2 export usable by ``unshackle import``. """Write a shareable v2 export usable by ``envied.import``.
Carries no session/cookies/dl-flags. Region (country code) is stored only when the Carries no session/cookies/dl-flags. Region (country code) is stored only when the
export used ``--proxy``, as an import geofence. Each track records only the licensed export used ``--proxy``, as an import geofence. Each track records only the licensed
@@ -27,7 +27,7 @@ class ImportCommand:
Re-fetches the manifest, injects the stored keys, then downloads/decrypts/muxes as a Re-fetches the manifest, injects the stored keys, then downloads/decrypts/muxes as a
normal `dl` run. Any `dl` options after the file are forwarded verbatim: normal `dl` run. Any `dl` options after the file are forwarded verbatim:
unshackle import export.json -r HDR10 --proxy US envied.import export.json -r HDR10 --proxy US
""" """
if not export_file.is_file(): if not export_file.is_file():
raise click.ClickException(f"Export file not found: {export_file}") raise click.ClickException(f"Export file not found: {export_file}")
@@ -48,7 +48,7 @@ class ImportCommand:
raise click.ClickException("Export file is missing the 'service' tag.") raise click.ClickException("Export file is missing the 'service' tag.")
args = [*dl_args, "--import", str(export_file), service_tag] args = [*dl_args, "--import", str(export_file), service_tag]
dl.cli.main(args=args, prog_name="unshackle dl", standalone_mode=False) dl.cli.main(args=args, prog_name="envied.dl", standalone_mode=False)
globals()["import"] = ImportCommand globals()["import"] = ImportCommand
+1 -1
View File
@@ -65,7 +65,7 @@ def serve(
\b \b
You may serve with Caddy at the same time with --caddy. You can use Caddy You may serve with Caddy at the same time with --caddy. You can use Caddy
as a reverse-proxy to serve with HTTPS. The config used will be the Caddyfile as a reverse-proxy to serve with HTTPS. The config used will be the Caddyfile
next to the unshackle config. next to the envied.config.
\b \b
DEVICE CONFIGURATION: DEVICE CONFIGURATION:
+1 -1
View File
@@ -1 +1 @@
__version__ = "5.1.0" __version__ = "5.3.0"
+3 -2
View File
@@ -23,7 +23,7 @@ from envied.core.utilities import close_debug_logger, init_debug_logger
@click.option("-v", "--version", is_flag=True, default=False, help="Print version information.") @click.option("-v", "--version", is_flag=True, default=False, help="Print version information.")
@click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs and JSON debug logging.") @click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs and JSON debug logging.")
def main(version: bool, debug: bool) -> None: def main(version: bool, debug: bool) -> None:
"""unshackle—Modular Movie, TV, and Music Archival Software.""" """envied.Modular Movie, TV, and Music Archival Software."""
debug_logging_enabled = debug or config.debug debug_logging_enabled = debug or config.debug
logging.basicConfig( logging.basicConfig(
@@ -57,7 +57,7 @@ def main(version: bool, debug: bool) -> None:
r"░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀░" + "\n" , r"░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀░" + "\n" ,
style="ascii.art", style="ascii.art",
), ),
Text(" and more than unshackled...", style = "ascii.art"), Text(" and more than envied....", style = "ascii.art"),
f"\nv [repr.number]{__version__}[/] - https://github.com/vinefeeder/envied", f"\nv [repr.number]{__version__}[/] - https://github.com/vinefeeder/envied",
), ),
(1, 11, 1, 10), (1, 11, 1, 10),
@@ -68,6 +68,7 @@ def main(version: bool, debug: bool) -> None:
@atexit.register @atexit.register
def cleanup(): def cleanup():
"""Clean up resources on exit.""" """Clean up resources on exit."""
@@ -219,7 +219,7 @@ class CustomRemoteCDM:
# HTTP session setup # HTTP session setup
self._http_session = Session() self._http_session = Session()
self._http_session.headers.update( self._http_session.headers.update(
{"Content-Type": "application/json", "User-Agent": f"unshackle-custom-cdm/{__version__}"} {"Content-Type": "application/json", "User-Agent": f"envied.custom-cdm/{__version__}"}
) )
# Apply custom headers from auth config # Apply custom headers from auth config
@@ -151,7 +151,7 @@ class DecryptLabsRemoteCDM:
{ {
"decrypt-labs-api-key": self.secret, "decrypt-labs-api-key": self.secret,
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": f"unshackle-decrypt-labs-cdm/{__version__}", "User-Agent": f"envied.decrypt-labs-cdm/{__version__}",
} }
) )
@@ -95,7 +95,7 @@ class MonaLisaCDM:
@staticmethod @staticmethod
def get_worker_path() -> Optional[Path]: def get_worker_path() -> Optional[Path]:
"""Get ML-Worker binary path from the unshackle binaries system.""" """Get ML-Worker binary path from the envied.binaries system."""
if binaries.ML_Worker: if binaries.ML_Worker:
return Path(binaries.ML_Worker) return Path(binaries.ML_Worker)
return None return None
+7 -7
View File
@@ -12,7 +12,7 @@ from appdirs import AppDirs
class Config: class Config:
class _Directories: class _Directories:
# default directories, do not modify here, set via config # default directories, do not modify here, set via config
app_dirs = AppDirs("unshackle", False) app_dirs = AppDirs("envied", False)
core_dir = Path(__file__).resolve().parent core_dir = Path(__file__).resolve().parent
namespace_dir = core_dir.parent namespace_dir = core_dir.parent
commands = namespace_dir / "commands" commands = namespace_dir / "commands"
@@ -33,8 +33,8 @@ class Config:
class _Filenames: class _Filenames:
# default filenames, do not modify here, set via config # default filenames, do not modify here, set via config
log = "unshackle_{name}_{time}.log" # Directories.logs log = "envied.{name}_{time}.log" # Directories.logs
debug_log = "unshackle_debug_{service}_{time}.jsonl" # Directories.logs debug_log = "envied.debug_{service}_{time}.jsonl" # Directories.logs
config = "config.yaml" # Directories.services / tag config = "config.yaml" # Directories.services / tag
root_config = "envied.yaml" # Directories.user_configs root_config = "envied.yaml" # Directories.user_configs
chapters = "Chapters_{title}_{random}.txt" # Directories.temp chapters = "Chapters_{title}_{random}.txt" # Directories.temp
@@ -112,7 +112,7 @@ class Config:
raise SystemExit( raise SystemExit(
"ERROR: The 'scene_naming' option has been removed.\n" "ERROR: The 'scene_naming' option has been removed.\n"
"Please configure 'output_template' in your envied.yaml instead.\n" "Please configure 'output_template' in your envied.yaml instead.\n"
"See unshackle-example.yaml for examples." "See envied.example.yaml for examples."
) )
if self.output_template: if self.output_template:
@@ -231,11 +231,11 @@ class Config:
# noinspection PyProtectedMember # noinspection PyProtectedMember
POSSIBLE_CONFIG_PATHS = ( POSSIBLE_CONFIG_PATHS = (
# The unshackle Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/unshackle) # The envied.Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/envied.
Config._Directories.namespace_dir / Config._Filenames.root_config, Config._Directories.namespace_dir / Config._Filenames.root_config,
# The Parent Folder to the unshackle Namespace Folder (e.g., %appdata%/Python/Python311/site-packages) # The Parent Folder to the envied.Namespace Folder (e.g., %appdata%/Python/Python311/site-packages)
Config._Directories.namespace_dir.parent / Config._Filenames.root_config, Config._Directories.namespace_dir.parent / Config._Filenames.root_config,
# The AppDirs User Config Folder (e.g., ~/.config/unshackle on Linux, %LOCALAPPDATA%\unshackle on Windows) # The AppDirs User Config Folder (e.g., ~/.config/envied.on Linux, %LOCALAPPDATA%\envied.on Windows)
Path(Config._Directories.app_dirs.user_config_dir) / Config._Filenames.root_config, Path(Config._Directories.app_dirs.user_config_dir) / Config._Filenames.root_config,
) )
@@ -348,7 +348,7 @@ class Widevine:
try: try:
arguments = [ arguments = [
f"input={path},stream=0,output={output_path},output_format=mp4", f"input={path},stream=0,output={output_path},output_format=MP4",
"--enable_raw_key_decryption", "--enable_raw_key_decryption",
"--keys", "--keys",
",".join( ",".join(
@@ -12,7 +12,7 @@ from requests.adapters import HTTPAdapter, Retry
log = logging.getLogger("METADATA") log = logging.getLogger("METADATA")
HEADERS = {"User-Agent": "unshackle-tags/1.0"} HEADERS = {"User-Agent": "envied.tags/1.0"}
STRIP_RE = re.compile(r"[^a-z0-9]+", re.I) STRIP_RE = re.compile(r"[^a-z0-9]+", re.I)
YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$") YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$")
@@ -81,7 +81,7 @@ class Gluetun(Proxy):
# Global settings (optional) # Global settings (optional)
base_port: 8888 base_port: 8888
auto_cleanup: true auto_cleanup: true
container_prefix: "unshackle-gluetun" container_prefix: "envied.gluetun"
Usage: Usage:
--proxy gluetun:windscribe:us --proxy gluetun:windscribe:us
@@ -166,7 +166,7 @@ class Gluetun(Proxy):
providers: Optional[dict] = None, providers: Optional[dict] = None,
base_port: int = 8888, base_port: int = 8888,
auto_cleanup: bool = True, auto_cleanup: bool = True,
container_prefix: str = "unshackle-gluetun", container_prefix: str = "envied.gluetun",
auth_user: Optional[str] = None, auth_user: Optional[str] = None,
auth_password: Optional[str] = None, auth_password: Optional[str] = None,
verify_ip: bool = True, verify_ip: bool = True,
@@ -187,7 +187,7 @@ class Gluetun(Proxy):
} }
base_port: Starting port for HTTP proxies (default: 8888) base_port: Starting port for HTTP proxies (default: 8888)
auto_cleanup: Automatically remove stopped containers (default: True) auto_cleanup: Automatically remove stopped containers (default: True)
container_prefix: Docker container name prefix (default: "unshackle-gluetun") container_prefix: Docker container name prefix (default: "envied.gluetun")
auth_user: Optional HTTP proxy authentication username auth_user: Optional HTTP proxy authentication username
auth_password: Optional HTTP proxy authentication password auth_password: Optional HTTP proxy authentication password
verify_ip: Automatically verify IP and region after connection (default: True) verify_ip: Automatically verify IP and region after connection (default: True)
@@ -272,7 +272,7 @@ class Gluetun(Proxy):
debug_logger = get_debug_logger() debug_logger = get_debug_logger()
# Check if container already exists (in memory OR in Docker) # Check if container already exists (in memory OR in Docker)
# This handles multiple concurrent Unshackle sessions # This handles multiple concurrent envied.sessions
if query_key in self.active_containers: if query_key in self.active_containers:
container = self.active_containers[query_key] container = self.active_containers[query_key]
if self._is_container_running(container["container_name"]): if self._is_container_running(container["container_name"]):
@@ -778,7 +778,7 @@ class Gluetun(Proxy):
# Avoid exposing credentials in process listings by using --env-file instead of many "-e KEY=VALUE". # Avoid exposing credentials in process listings by using --env-file instead of many "-e KEY=VALUE".
env_file_path: str | None = None env_file_path: str | None = None
try: try:
fd, env_file_path = tempfile.mkstemp(prefix=f"unshackle-{container_name}-", suffix=".env") fd, env_file_path = tempfile.mkstemp(prefix=f"envied.{container_name}-", suffix=".env")
try: try:
# Best-effort restrictive permissions. # Best-effort restrictive permissions.
if os.name != "nt": if os.name != "nt":
@@ -921,7 +921,7 @@ class Gluetun(Proxy):
""" """
Check if a container exists in Docker and get its info. Check if a container exists in Docker and get its info.
This handles multiple Unshackle sessions - if another session already This handles multiple envied.sessions - if another session already
created the container, we'll reuse it instead of trying to create a duplicate. created the container, we'll reuse it instead of trying to create a duplicate.
Args: Args:
@@ -955,7 +955,7 @@ class Gluetun(Proxy):
port = int(port_match.group(1)) port = int(port_match.group(1))
# Extract provider and region from container name # Extract provider and region from container name
# Format: unshackle-gluetun-provider-region # Format: envied.gluetun-provider-region
name_pattern = f"{self.container_prefix}-(.+)-([^-]+)$" name_pattern = f"{self.container_prefix}-(.+)-([^-]+)$"
name_match = re.match(name_pattern, container_name) name_match = re.match(name_pattern, container_name)
if not name_match: if not name_match:
@@ -1,7 +1,7 @@
"""Remote service adapter for envied. """Remote service adapter for envied.
Implements the Service interface by proxying authenticate, get_titles, Implements the Service interface by proxying authenticate, get_titles,
get_tracks, get_chapters, and license methods to a remote unshackle server. get_tracks, get_chapters, and license methods to a remote envied.server.
Everything else (track selection, download, decrypt, mux) runs locally. Everything else (track selection, download, decrypt, mux) runs locally.
""" """
@@ -36,7 +36,7 @@ log = logging.getLogger("remote_service")
class RemoteClient: class RemoteClient:
"""HTTP client for the unshackle serve API.""" """HTTP client for the envied.serve API."""
def __init__(self, server_url: str, api_key: str) -> None: def __init__(self, server_url: str, api_key: str) -> None:
self.server_url = server_url.rstrip("/") self.server_url = server_url.rstrip("/")
@@ -49,7 +49,7 @@ class RemoteClient:
from envied.core import __version__ from envied.core import __version__
self._session = requests.Session() self._session = requests.Session()
self._session.headers["User-Agent"] = f"unshackle/{__version__}" self._session.headers["User-Agent"] = f"envied.{__version__}"
if self.api_key: if self.api_key:
self._session.headers["X-Secret-Key"] = self.api_key self._session.headers["X-Secret-Key"] = self.api_key
return self._session return self._session
@@ -59,7 +59,7 @@ class RemoteClient:
try: try:
resp = getattr(self.session, method)(url, json=data, timeout=120 if method == "post" else 30) resp = getattr(self.session, method)(url, json=data, timeout=120 if method == "post" else 30)
except requests.ConnectionError: except requests.ConnectionError:
log.error(f"Could not connect to remote server at {self.server_url}. Is it running? (unshackle serve)") log.error(f"Could not connect to remote server at {self.server_url}. Is it running? (envied.serve)")
raise SystemExit(1) raise SystemExit(1)
except requests.Timeout: except requests.Timeout:
log.error(f"Request to remote server timed out: {endpoint}") log.error(f"Request to remote server timed out: {endpoint}")
@@ -366,7 +366,7 @@ def _resolve_proxy(proxy_arg: Optional[str]) -> Optional[str]:
class RemoteService: class RemoteService:
"""Service adapter that proxies to a remote unshackle server. """Service adapter that proxies to a remote envied.server.
Implements the same interface dl.py's result() expects without Implements the same interface dl.py's result() expects without
subclassing Service (avoids proxy/geofence setup in __init__). subclassing Service (avoids proxy/geofence setup in __init__).
@@ -861,20 +861,13 @@ class Subtitle(Track):
Subtitle.Codec.SAMI: "sami", Subtitle.Codec.SAMI: "sami",
Subtitle.Codec.MicroDVD: "microdvd", Subtitle.Codec.MicroDVD: "microdvd",
}.get(codec, codec.name.lower()) }.get(codec, codec.name.lower())
'''sub_edit_args = [ sub_edit_args = [
str(binaries.SubtitleEdit), str(binaries.SubtitleEdit),
"/convert", "/convert",
str(self.path), str(self.path),
sub_edit_format, sub_edit_format,
f"/outputfilename:{output_path.name}", f"/outputfilename:{output_path.name}",
"/encoding:utf8", "/encoding:utf8",
]'''
## Angela edited this to remove the /convert argument because it was causing issues with SubtitleEdit
sub_edit_args = [
binaries.SubtitleEdit,
self.path, sub_edit_format,
f"/outputfilename:{output_path.name}",
"/encoding:utf8"
] ]
if codec == Subtitle.Codec.SubRip: if codec == Subtitle.Codec.SubRip:
sub_edit_args.append("/ConvertColorsToDialog") sub_edit_args.append("/ConvertColorsToDialog")
@@ -339,6 +339,32 @@ class Tracks:
merged.append(video) merged.append(video)
return merged return merged
@staticmethod
def partition_hybrid_videos(
videos: list[Video], non_hybrid_ranges: list[Video.Range]
) -> tuple[list[Video], list[Video]]:
"""Split videos into hybrid-ingredient candidates and the standalone-deliverable pool.
HDR10/HDR10+/DV tracks are hybrid ingredients; they only enter the standalone
pool when their range was explicitly requested alongside HYBRID, so e.g.
`-r HYBRID` muxes only the hybrid while `-r HYBRID,HDR10P` also delivers HDR10+.
"""
ingredient_ranges = (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
hybrid_candidates = [v for v in videos if v.range in ingredient_ranges]
non_hybrid = [v for v in videos if v.range not in ingredient_ranges or v.range in non_hybrid_ranges]
return hybrid_candidates, non_hybrid
@staticmethod
def flag_hybrid_ingredients(hybrid_selected: list[Video], non_hybrid_selected: list[Video]) -> None:
"""Mark tracks selected only as hybrid ingredients so the standalone mux loop skips them.
A track that was also picked as an explicit deliverable (same track in both
selections) stays unflagged and is muxed standalone alongside the hybrid.
"""
for video in hybrid_selected:
if video not in non_hybrid_selected:
video.hybrid_base_only = True
def select_hybrid(self, tracks, quality, worst: bool = False): def select_hybrid(self, tracks, quality, worst: bool = False):
# Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata) # Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata)
base_ranges = (Video.Range.HDR10P, Video.Range.HDR10) base_ranges = (Video.Range.HDR10P, Video.Range.HDR10)
@@ -42,7 +42,7 @@ class CRAV(Service):
\b \b
Notes: Notes:
- Subtitles are not always consistent, so both external (VTT) and internal (WVTT) subtitles are added. - Subtitles are not always consistent, so both external (VTT) and internal (WVTT) subtitles are added.
If you experience incomplete subtitles, try using a newer version of Unshackle (v5.0.0+). If you experience incomplete subtitles, try using a newer version of envied.(v5.0.0+).
- Authentication will look for master profile first, then first profile with adult maturity scope. - Authentication will look for master profile first, then first profile with adult maturity scope.
- Account pins are currently not supported. - Account pins are currently not supported.
""" """
@@ -6,7 +6,7 @@ endpoints:
headers: headers:
Accept: application/json Accept: application/json
User-Agent: okhttp/5.1.0 User-Agent: okhttp/5..0
client: client:
apikey: 592c205299de839aa3948643addfe0bc apikey: 592c205299de839aa3948643addfe0bc
@@ -9,11 +9,11 @@ from urllib.parse import urljoin, urlparse
import click import click
from requests import Request from requests import Request
from unshackle.core.manifests import DASH from envied.core.manifests import DASH
from unshackle.core.search_result import SearchResult from envied.core.search_result import SearchResult
from unshackle.core.service import Service from envied.core.service import Service
from unshackle.core.titles import Episode, Series, Title_T, Titles_T from envied.core.titles import Episode, Series, Title_T, Titles_T
from unshackle.core.tracks import Chapters, Tracks from envied.core.tracks import Chapters, Tracks
class VM(Service): class VM(Service):
+1 -1
View File
@@ -14,7 +14,7 @@ class API(Vault):
super().__init__(name, no_push) super().__init__(name, no_push)
self.uri = uri.rstrip("/") self.uri = uri.rstrip("/")
self.session = Session() self.session = Session()
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"}) self.session.headers.update({"User-Agent": f"envied.v{__version__}"})
self.session.headers.update({"Authorization": f"Bearer {token}"}) self.session.headers.update({"Authorization": f"Bearer {token}"})
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]: def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
+1 -1
View File
@@ -57,7 +57,7 @@ class HTTP(Vault):
self.api_mode = api_mode.lower() self.api_mode = api_mode.lower()
self.current_title = None self.current_title = None
self.session = Session() self.session = Session()
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"}) self.session.headers.update({"User-Agent": f"envied.v{__version__}"})
self.api_session_id = None self.api_session_id = None
if self.api_mode == "decrypt_labs": if self.api_mode == "decrypt_labs":
+1 -1
View File
@@ -1,5 +1,5 @@
""" """
Compatibility shim for precompiled services expecting `unshackle`. Compatibility shim for precompiled services expecting `envied..
""" """
from envied import * # noqa: F401,F403 from envied import * # noqa: F401,F403
+1 -1
View File
@@ -39,7 +39,7 @@ def pretty_print():
f"[{catppuccin_mocha['pink']}]░█▀▀░█▀█░█░█░▀█▀░█▀▀░█▀▄" + "\n" f"[{catppuccin_mocha['pink']}]░█▀▀░█▀█░█░█░▀█▀░█▀▀░█▀▄" + "\n"
f"[{catppuccin_mocha['pink']}]░█▀▀░█░█░▀▄▀░░█░░█▀▀░█░█" + "\n" f"[{catppuccin_mocha['pink']}]░█▀▀░█░█░▀▄▀░░█░░█▀▀░█░█" + "\n"
f"[{catppuccin_mocha['pink']}]░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀░" + "\n\n" f"[{catppuccin_mocha['pink']}]░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀░" + "\n\n"
f"[{catppuccin_mocha['pink']}]It's more than unshackled...[/]" + "\n" f"[{catppuccin_mocha['pink']}]It's more than envied....[/]" + "\n"
@@ -7,7 +7,7 @@ from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import click import click
from unshackle.core import service from envied.core import service
import yaml import yaml
from beaupy import select_multiple from beaupy import select_multiple
from rich.console import Console from rich.console import Console
Generated
+1 -1
View File
@@ -690,7 +690,7 @@ wheels = [
[[package]] [[package]]
name = "envied" name = "envied"
version = "5.1.0" version = "5.3.0"
source = { editable = "packages/envied" } source = { editable = "packages/envied" }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
+1 -1
View File
@@ -14,7 +14,7 @@ class API(Vault):
super().__init__(name, no_push) super().__init__(name, no_push)
self.uri = uri.rstrip("/") self.uri = uri.rstrip("/")
self.session = Session() self.session = Session()
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"}) self.session.headers.update({"User-Agent": f"envied.v{__version__}"})
self.session.headers.update({"Authorization": f"Bearer {token}"}) self.session.headers.update({"Authorization": f"Bearer {token}"})
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]: def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
+1 -1
View File
@@ -57,7 +57,7 @@ class HTTP(Vault):
self.api_mode = api_mode.lower() self.api_mode = api_mode.lower()
self.current_title = None self.current_title = None
self.session = Session() self.session = Session()
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"}) self.session.headers.update({"User-Agent": f"envied.v{__version__}"})
self.api_session_id = None self.api_session_id = None
if self.api_mode == "decrypt_labs": if self.api_mode == "decrypt_labs":