mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
upstream updates
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
__version__ = "5.1.0"
|
__version__ = "5.3.0"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user