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]
name = "envied"
version = "5.1.0"
version = "5.3.0"
description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "rlaphoenix and others" }]
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.
## 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/),
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,
default=None,
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(
"--cdm-only/--vaults-only",
@@ -571,7 +571,7 @@ class dl:
is_flag=True,
default=False,
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(
"--server",
@@ -614,7 +614,7 @@ class dl:
raise click.ClickException(
"No 'output_template' configured in your envied.yaml.\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)
@@ -1697,24 +1697,12 @@ class dl:
has_hybrid = any(r == Video.Range.HYBRID for r in range_)
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:
missing_resolutions = []
if has_hybrid:
hybrid_candidate_tracks = [
v
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_candidate_tracks, non_hybrid_tracks = Tracks.partition_hybrid_videos(
title.tracks.videos, non_hybrid_ranges
)
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality, worst=worst)
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 []
if has_hybrid:
# Apply hybrid selection for HYBRID tracks
hybrid_candidate_tracks = [
v
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_candidate_tracks, non_hybrid_tracks = Tracks.partition_hybrid_videos(
title.tracks.videos, non_hybrid_ranges
)
if not quality:
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)
# Flag the lowest DV track as ingredient-only so mux skips it standalone,
# unless it is itself the chosen DV deliverable (single DV rendition).
selected_dv = [v for v in title.tracks.videos if v.range == Video.Range.DV]
if selected_dv:
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
# Flag tracks selected only as hybrid ingredients (the HDR base and/or
# the lowest DV) so the standalone mux loop skips them. Tracks also
# picked as explicit deliverables stay unflagged.
Tracks.flag_hybrid_ingredients(hybrid_selected, non_hybrid_selected)
else:
selected_videos: list[Video] = []
if video_multi_lang:
@@ -2792,7 +2768,7 @@ class dl:
return meta
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
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
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():
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.")
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
+1 -1
View File
@@ -65,7 +65,7 @@ def serve(
\b
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
next to the unshackle config.
next to the envied.config.
\b
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("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs and JSON debug logging.")
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
logging.basicConfig(
@@ -57,7 +57,7 @@ def main(version: bool, debug: bool) -> None:
r"░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀░" + "\n" ,
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",
),
(1, 11, 1, 10),
@@ -68,6 +68,7 @@ def main(version: bool, debug: bool) -> None:
@atexit.register
def cleanup():
"""Clean up resources on exit."""
@@ -219,7 +219,7 @@ class CustomRemoteCDM:
# HTTP session setup
self._http_session = Session()
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
@@ -151,7 +151,7 @@ class DecryptLabsRemoteCDM:
{
"decrypt-labs-api-key": self.secret,
"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
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:
return Path(binaries.ML_Worker)
return None
+7 -7
View File
@@ -12,7 +12,7 @@ from appdirs import AppDirs
class Config:
class _Directories:
# 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
namespace_dir = core_dir.parent
commands = namespace_dir / "commands"
@@ -33,8 +33,8 @@ class Config:
class _Filenames:
# default filenames, do not modify here, set via config
log = "unshackle_{name}_{time}.log" # Directories.logs
debug_log = "unshackle_debug_{service}_{time}.jsonl" # Directories.logs
log = "envied.{name}_{time}.log" # Directories.logs
debug_log = "envied.debug_{service}_{time}.jsonl" # Directories.logs
config = "config.yaml" # Directories.services / tag
root_config = "envied.yaml" # Directories.user_configs
chapters = "Chapters_{title}_{random}.txt" # Directories.temp
@@ -112,7 +112,7 @@ class Config:
raise SystemExit(
"ERROR: The 'scene_naming' option has been removed.\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:
@@ -231,11 +231,11 @@ class Config:
# noinspection PyProtectedMember
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,
# 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,
# 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,
)
@@ -348,7 +348,7 @@ class Widevine:
try:
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",
"--keys",
",".join(
@@ -12,7 +12,7 @@ from requests.adapters import HTTPAdapter, Retry
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)
YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$")
@@ -81,7 +81,7 @@ class Gluetun(Proxy):
# Global settings (optional)
base_port: 8888
auto_cleanup: true
container_prefix: "unshackle-gluetun"
container_prefix: "envied.gluetun"
Usage:
--proxy gluetun:windscribe:us
@@ -166,7 +166,7 @@ class Gluetun(Proxy):
providers: Optional[dict] = None,
base_port: int = 8888,
auto_cleanup: bool = True,
container_prefix: str = "unshackle-gluetun",
container_prefix: str = "envied.gluetun",
auth_user: Optional[str] = None,
auth_password: Optional[str] = None,
verify_ip: bool = True,
@@ -187,7 +187,7 @@ class Gluetun(Proxy):
}
base_port: Starting port for HTTP proxies (default: 8888)
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_password: Optional HTTP proxy authentication password
verify_ip: Automatically verify IP and region after connection (default: True)
@@ -272,7 +272,7 @@ class Gluetun(Proxy):
debug_logger = get_debug_logger()
# 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:
container = self.active_containers[query_key]
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".
env_file_path: str | None = None
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:
# Best-effort restrictive permissions.
if os.name != "nt":
@@ -921,7 +921,7 @@ class Gluetun(Proxy):
"""
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.
Args:
@@ -955,7 +955,7 @@ class Gluetun(Proxy):
port = int(port_match.group(1))
# Extract provider and region from container name
# Format: unshackle-gluetun-provider-region
# Format: envied.gluetun-provider-region
name_pattern = f"{self.container_prefix}-(.+)-([^-]+)$"
name_match = re.match(name_pattern, container_name)
if not name_match:
@@ -1,7 +1,7 @@
"""Remote service adapter for envied.
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.
"""
@@ -36,7 +36,7 @@ log = logging.getLogger("remote_service")
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:
self.server_url = server_url.rstrip("/")
@@ -49,7 +49,7 @@ class RemoteClient:
from envied.core import __version__
self._session = requests.Session()
self._session.headers["User-Agent"] = f"unshackle/{__version__}"
self._session.headers["User-Agent"] = f"envied.{__version__}"
if self.api_key:
self._session.headers["X-Secret-Key"] = self.api_key
return self._session
@@ -59,7 +59,7 @@ class RemoteClient:
try:
resp = getattr(self.session, method)(url, json=data, timeout=120 if method == "post" else 30)
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)
except requests.Timeout:
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:
"""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
subclassing Service (avoids proxy/geofence setup in __init__).
@@ -861,20 +861,13 @@ class Subtitle(Track):
Subtitle.Codec.SAMI: "sami",
Subtitle.Codec.MicroDVD: "microdvd",
}.get(codec, codec.name.lower())
'''sub_edit_args = [
sub_edit_args = [
str(binaries.SubtitleEdit),
"/convert",
str(self.path),
sub_edit_format,
f"/outputfilename:{output_path.name}",
"/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:
sub_edit_args.append("/ConvertColorsToDialog")
@@ -339,6 +339,32 @@ class Tracks:
merged.append(video)
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):
# Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata)
base_ranges = (Video.Range.HDR10P, Video.Range.HDR10)
@@ -42,7 +42,7 @@ class CRAV(Service):
\b
Notes:
- 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.
- Account pins are currently not supported.
"""
@@ -6,7 +6,7 @@ endpoints:
headers:
Accept: application/json
User-Agent: okhttp/5.1.0
User-Agent: okhttp/5..0
client:
apikey: 592c205299de839aa3948643addfe0bc
@@ -9,11 +9,11 @@ from urllib.parse import urljoin, urlparse
import click
from requests import Request
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Series, Title_T, Titles_T
from unshackle.core.tracks import Chapters, Tracks
from envied.core.manifests import DASH
from envied.core.search_result import SearchResult
from envied.core.service import Service
from envied.core.titles import Episode, Series, Title_T, Titles_T
from envied.core.tracks import Chapters, Tracks
class VM(Service):
+1 -1
View File
@@ -14,7 +14,7 @@ class API(Vault):
super().__init__(name, no_push)
self.uri = uri.rstrip("/")
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}"})
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.current_title = None
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
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
+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\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
import click
from unshackle.core import service
from envied.core import service
import yaml
from beaupy import select_multiple
from rich.console import Console
Generated
+1 -1
View File
@@ -690,7 +690,7 @@ wheels = [
[[package]]
name = "envied"
version = "5.1.0"
version = "5.3.0"
source = { editable = "packages/envied" }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -14,7 +14,7 @@ class API(Vault):
super().__init__(name, no_push)
self.uri = uri.rstrip("/")
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}"})
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.current_title = None
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
if self.api_mode == "decrypt_labs":