upstream updates

This commit is contained in:
VineFeeder
2026-03-18 12:33:44 +00:00
parent bf36d4a993
commit a91ffa4c12
59 changed files with 9134 additions and 2161 deletions
+49 -46
View File
@@ -4,10 +4,10 @@ build-backend = "uv_build"
[project]
name = "envied"
version = "3.0.0"
version = "4.0.0"
description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "rlaphoenix and others" }]
requires-python = ">=3.10,<3.13"
requires-python = ">=3.10,<=3.14"
readme = "README.md"
license = "GPL-3.0-only"
keywords = [
@@ -26,57 +26,60 @@ classifiers = [
"Topic :: Security :: Cryptography",
]
dependencies = [
"appdirs>=1.4.4,<2",
"Brotli>=1.1.0,<2",
"click>=8.1.8,<9",
"construct>=2.8.8,<3",
"crccheck>=1.3.0,<2",
"fonttools>=4.61.1,<5",
"jsonpickle>=4.1.1,<5",
"langcodes>=3.5.0,<4",
"lxml>=6.0.2,<7",
"pproxy>=2.7.9,<3",
"protobuf>=6.33.3,<7",
"pycaption>=2.2.6,<3",
"pycryptodomex>=3.23.0,<4",
"PyJWT>=2.10.1,<3",
"pymediainfo>=6.1.0,<8",
"pymp4>=1.4.0,<2",
"pymysql>=1.1.0,<2",
"pywidevine[serve]>=1.9.0,<2",
"PyYAML>=6.0.1,<7",
"requests[socks]>=2.32.5,<3",
"rich>=14.2.0,<15",
"rlaphoenix.m3u8>=3.4.0,<4",
"ruamel.yaml>=0.19.1,<0.20",
"sortedcontainers>=2.4.0,<3",
"subtitle-filter>=1.4.9,<2",
"Unidecode>=1.3.8,<2",
"urllib3>=2.6.2,<3",
"chardet>=5.2.0,<6",
# curl-cffi is 0.x; keep a tighter cap to avoid accidental breaking changes.
"curl-cffi>=0.14.0,<0.15",
"pyplayready>=0.8.2,<0.9",
"httpx>=0.28.1,<0.29",
"cryptography>=45.0.6,<46.0.0",
# These were unpinned in your list; consider adding bounds once you confirm versions youve tested with.
"subby",
"appdirs>=1.4.4,<2",
"Brotli>=1.1.0,<2",
"click>=8.1.8,<9",
"construct>=2.8.8,<3",
"crccheck>=1.3.0,<2",
"filelock>=3.20.3,<4",
"fonttools>=4.60.2,<5",
"jsonpickle>=3.0.4,<5",
"langcodes>=3.4.0,<4",
"lxml>=5.2.1,<7",
"pproxy>=2.7.9,<3",
"protobuf>=4.25.3,<7",
"pycaption>=2.2.6,<3",
"pycryptodomex>=3.20.0,<4",
"pyjwt>=2.8.0,<3",
"pymediainfo>=6.1.0,<8",
"pymp4>=1.4.0,<2",
"pymysql>=1.1.0,<2",
"pywidevine[serve]>=1.8.0,<2",
"PyYAML>=6.0.1,<7",
"requests[socks]>=2.32.5,<3",
"rich>=13.7.1,<15",
"rlaphoenix.m3u8>=3.4.0,<4",
"ruamel.yaml>=0.18.6,<0.19",
"sortedcontainers>=2.4.0,<3",
"subtitle-filter>=1.4.9,<2",
"Unidecode>=1.3.8,<2",
"urllib3>=2.6.3,<3",
"chardet>=5.2.0,<6",
"curl-cffi>=0.7.0b4,<0.14",
"pyplayready>=0.8.3,<0.9",
"httpx>=0.28.1,<0.29",
"cryptography>=45.0.0,<47",
"subby",
"aiohttp>=3.13.3,<4",
"aiohttp-swagger3>=0.9.0,<1",
"pysubs2>=1.7.0,<2",
"PyExecJS>=1.5.1,<2",
"pycountry>=24.6.1",
"language-data>=1.4.0",
"wasmtime>=41.0.0",
"animeapi-py>=0.6.0",
# envied specifix
"webvtt-py>=0.5.1,<0.6",
"isodate>=0.7.2,<0.8",
#"beaupy>=3.10.2,<4",
"scrapy>=2.14.0,<3",
"mypy>=1.19.1,<2",
"pysubs2>=1.7.0,<2",
"PyExecJS>=1.5.1,<2",
"pycountry>=24.6.1",
"language-data>=1.4.0",
"wasmtime>=41.0.0",
# Keeping your existing cap style here; latest visible is 0.10.0.
"aiohttp-swagger3>=0.10.0,<1",
#"aiohttp-swagger3>=0.10.0,<1",
]
+152
View File
@@ -5,6 +5,158 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.0.0] - 2026-03-17
### Features
- *hybrid*: Add L5 active area and dynamic L6 luminance metadata
- *debug*: Add JSONL debug logging to decryption, muxing, and all downloaders
- *debug*: Log binary tool versions at session start
- *dl*: Add --repack flag to insert REPACK tag in output filenames
- *core*: Add TrackRequest system for multi-codec/multi-range support
- *dl*: Add --output option to override download directory
- *tracks*: Add edition tags to output filenames
- *templates*: [**breaking**] Add customizable output filename templates
- *templates*: Add configurable language tagging rule engine
- Update unshackle version to 4.0.0
- *dl*: Add --animeapi and --enrich options for anime metadata and tagging
- *dl*: Add skip messages for --no-audio and --no-chapters flags
- *dl*: Extract closed captions from HLS manifests and improve CC extraction
- *dl*: Add --worst flag and SHIELD OkHttp fingerprint preset
### Bug Fixes
- *n_m3u8dl_re*: Pass all content keys for DualKey DRM decryption
- *hybrid*: Skip bitrate filter for DV tracks in HYBRID mode
- *attachment*: Sanitize filenames with illegal Windows characters
- *hybrid*: Accept HDR10+ tracks as valid base layer for HYBRID mode
- *dl*: Allow selection of audio tracks for 'all' languages in addition to 'best'
- *dl*: Overwrite existing files on re-download and use atomic replace
- *dl*: Handle cross-device moves when temp and downloads differ
- *track*: Fallback to requests downloader from n_m3u8dl_re for unsupported track types
- *n_m3u8dl_re*: Disable segment count validation for duration-based DASH
- Correct formatting and add missing newlines in selector and EXAMPLE service
- *dependencies*: Update pyplayready version to 0.8.3 and adjust dependencies
- *drm*: Update PlayReady KID extraction for pyplayready 0.8.3 compatibility
- *api*: Resolve Sentinel serialization, missing params, and add search endpoint
- *dash*: Pass period_filter to n_m3u8dl_re via filtered MPD file
- *title*: Add HDR Vivid Format HDR Tag
- *ism*: Prevent duplicate track IDs for audio tracks with same lang/codec/bitrate
- *aria2c*: Correct progress bar tracking for HLS downloads
- *dl*: Filter CC subtitle languages with --s-lang and extract all manifest CCs
### Documentation
- Update and correct configuration documentation
### Changes
- *hybrid*: Replace log.info with console status and add JSONL debug logging
- *dl*: Remove legacy multi-fetch loop for unmigrated services
- *example*: Migrate EXAMPLE service to track_request pattern
- *providers*: Extract metadata providers into modular system
## [3.0.0] - 2026-02-15
### Features
- *titles*: Use track source attribute for service name in filenames
- *debug*: Add download output verification logging
- Gluetun VPN integration and remote service enhancements
- *gluetun*: Improve VPN connection display and Windscribe support
- *serve*: Add PlayReady CDM support alongside Widevine
- *cdm*: Add remote PlayReady CDM support via pyplayready RemoteCdm
- *env*: Add ML-Worker binary for DRM licensing
- *video*: Detect interlaced scan type from MPD manifests
- *drm*: Add MonaLisa DRM support to core infrastructure
- *audio*: Codec lists and split muxing
- *proxy*: Add specific server selection for WindscribeVPN
- *cdm*: Normalize CDM detection for local and remote implementations
- *HLS*: Improve audio codec handling with error handling for codec extraction
- *tracks*: Prioritize Atmos audio tracks over higher bitrate non-Atmos
### Bug Fixes
- *subs*: Update SubtitleEdit CLI syntax and respect conversion_method
- *n_m3u8dl_re*: Include language in DASH audio track selection
- *hls*: Prefer media playlist keys over session keys for accurate KID matching
- *deps*: Upgrade vulnerable dependencies for security alerts
- *serve*: Use correct pywidevine users config format
- *cdm*: Correct error key casing in Decrypt Labs API response parsing
- *api*: Validate Bearer prefix before extracting API key
- *serve*: Correct PlayReady RemoteCDM server validation
- *n_m3u8dl_re*: Remove duplicate --write-meta-json argument causing download failures
- *manifests*: Correct DRM type selection for remote PlayReady CDMs
- *proxies*: Fixes WindscribeVPN server authentication
- *subs*: Route pycaption-unsupported formats to pysubs2 in auto mode
- *proxy*: Remove regional restrictions from WindscribeVPN
- *proxy*: Collect servers from all locations in WindscribeVPN
- *downloader*: Correct progress bar tracking for segmented downloads
- *binaries*: Search subdirectories for binary files
- *dash*: Handle high startNumber in SegmentTimeline for DVR manifests
- *drm*: Hide Shaka Packager message for MonaLisa decryption
- *dash*: Add CENC namespace support for PSSH extraction
- *dash*: Preserve MPD DRM instead of overwriting from init segment
- *subtitles*: Preserve sidecar originals
- *mux*: Avoid audio codec suffix on split-audio outputs
- *dl*: Prevent attachment downloads during --skip-dl
- *progress*: Force track bar completion on terminal states
- *progress*: Bind per-track bars and force terminal completion
- *dl*: Keep descriptive and standard audio for requested langs
- *dl*: Always clean up hybrid temp hevc outputs
- *hls*: Finalize n_m3u8dl_re outputs
- *downloader*: Restore requests progress for single-url downloads
- *dl*: Invert audio codec suffixing when splitting
- *dl*: Support snake_case keys for RemoteCdm
- *aria2c*: Warn on config mismatch and wait for RPC ready
- *serve*: [**breaking**] Make PlayReady users config consistently a mapping
- *dl*: Preserve proxy_query selector (not resolved URI)
- *gluetun*: Stop leaking proxy/vpn secrets to process list
- *monalisa*: Avoid leaking secrets and add worker safety
- *dl*: Avoid selecting all variants when multiple audio codecs requested
- *hls*: Keep range offset numeric and align MonaLisa licensing
- *titles*: Remove trailing space from HDR dynamic range label
- *config*: Normalize playready_remote remote_cdm keys
- *titles*: Avoid None/double spaces in HDR tokens
- *naming*: Keep technical tokens with scene_naming off
- *api*: Log PSSH extraction failures
- *proxies*: Harden surfshark and windscribe selection
- *service*: Redact proxy credentials in logs
- *monalisa*: Harden wasm calls and license handling
- *hls*: Remove no-op encryption_data reassignment
- *serve*: Default PlayReady access to none
- *tracks*: Close temp session and improve path type error
- *main*: Update copyright year dynamically in version display
### Reverts
- *monalisa*: Pass key via argv again
### Documentation
- Add configuration documentation WIP
- *changelog*: Add 2.4.0 release notes
- *changelog*: Update cliff config and regenerate changelog
- *changelog*: Complete 2.4.0 notes
- *config*: Clarify sdh_method uses subtitle-filter
### Performance Improvements
- *aria2c*: Improve download performance with singleton manager
### Changes
- *remote_auth*: Remove unused requests.Session
- Remove remote-service code until feature is more complete
### Maintenance
- *api*: Remove remote services
### Chore
- *release*: [**breaking**] Bump version to 3.0.0
## [2.3.0] - 2026-01-18
### Added
Binary file not shown.
File diff suppressed because it is too large Load Diff
+454 -185
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import html
import logging
import math
import os
import random
import re
import shutil
@@ -24,6 +25,7 @@ import click
import jsonpickle
import yaml
from construct import ConstError
from langcodes import Language
from pymediainfo import MediaInfo
from pyplayready.cdm import Cdm as PlayReadyCdm
from pyplayready.device import Device as PlayReadyDevice
@@ -41,7 +43,7 @@ from rich.table import Table
from rich.text import Text
from rich.tree import Tree
from envied.core import binaries
from envied.core import binaries, providers
from envied.core.cdm import CustomRemoteCDM, DecryptLabsRemoteCDM
from envied.core.cdm.detect import is_playready_cdm, is_widevine_cdm
from envied.core.config import config
@@ -60,10 +62,11 @@ from envied.core.tracks import Audio, Subtitle, Tracks, Video
from envied.core.tracks.attachment import Attachment
from envied.core.tracks.hybrid import Hybrid
from envied.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
is_close_match, suggest_font_packages, time_elapsed_since)
is_close_match, is_exact_match, suggest_font_packages, time_elapsed_since)
from envied.core.utils import tags
from envied.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice)
ContextData, MultipleChoice, MultipleVideoCodecChoice,
SubtitleCodecChoice)
from envied.core.utils.collections import merge_dict
from envied.core.utils.selector import select_multiple
from envied.core.utils.subprocess import ffprobe
@@ -195,12 +198,7 @@ class dl:
sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else ""
extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension
if (
not target_codec
and not subtitle.codec
and source_path
and source_path.suffix
):
if not target_codec and not subtitle.codec and source_path and source_path.suffix:
extension = source_path.suffix.lstrip(".")
filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}"
@@ -292,9 +290,9 @@ class dl:
@click.option(
"-v",
"--vcodec",
type=VideoCodecChoice(Video.Codec),
default=None,
help="Video Codec to download, defaults to any codec.",
type=MultipleVideoCodecChoice(Video.Codec),
default=[],
help="Video Codec(s) to download, defaults to any codec.",
)
@click.option(
"-a",
@@ -410,6 +408,7 @@ class dl:
@click.option(
"--tag", type=str, default=None, help="Set the Group Tag to be used, overriding the one in config if any."
)
@click.option("--repack", is_flag=True, default=False, help="Add REPACK tag to the output filename.")
@click.option(
"--tmdb",
"tmdb_id",
@@ -418,18 +417,24 @@ class dl:
help="Use this TMDB ID for tagging instead of automatic lookup.",
)
@click.option(
"--tmdb-name",
"tmdb_name",
is_flag=True,
default=False,
help="Rename titles using the name returned from TMDB lookup.",
"--animeapi",
"animeapi_id",
type=str,
default=None,
help="Anime database ID via AnimeAPI (e.g. mal:12345, anilist:98765). Defaults to MAL if no prefix.",
)
@click.option(
"--tmdb-year",
"tmdb_year",
"--enrich",
is_flag=True,
default=False,
help="Use the release year from TMDB for naming and tagging.",
help="Override show title and year from external source. Requires --tmdb, --imdb, or --animeapi.",
)
@click.option(
"--imdb",
"imdb_id",
type=str,
default=None,
help="Use this IMDB ID (e.g. tt1375666) for tagging instead of automatic lookup.",
)
@click.option(
"--sub-format",
@@ -489,10 +494,24 @@ class dl:
help="Max workers/threads to download with per-track. Default depends on the downloader.",
)
@click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.")
@click.option(
"-o",
"--output",
"output_dir",
type=Path,
default=None,
help="Override the output directory for this download, instead of the one in config.",
)
@click.option("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.")
@click.option(
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
)
@click.option(
"--worst",
is_flag=True,
default=False,
help="Select the lowest bitrate track within the specified quality. Requires -q/--quality.",
)
@click.option(
"--best-available",
"best_available",
@@ -512,10 +531,13 @@ class dl:
no_proxy: bool,
profile: Optional[str] = None,
proxy: Optional[str] = None,
repack: bool = False,
tag: Optional[str] = None,
tmdb_id: Optional[int] = None,
tmdb_name: bool = False,
tmdb_year: bool = False,
imdb_id: Optional[str] = None,
animeapi_id: Optional[str] = None,
enrich: bool = False,
output_dir: Optional[Path] = None,
*_: Any,
**__: Any,
):
@@ -559,8 +581,23 @@ class dl:
self.profile = profile
self.tmdb_id = tmdb_id
self.tmdb_name = tmdb_name
self.tmdb_year = tmdb_year
self.imdb_id = imdb_id
self.enrich = enrich
self.animeapi_title: Optional[str] = None
self.output_dir = output_dir
if animeapi_id:
from envied.core.utils.animeapi import resolve_animeapi
anime_title, anime_ids = resolve_animeapi(animeapi_id)
self.animeapi_title = anime_title
if not self.tmdb_id and anime_ids.tmdb_id:
self.tmdb_id = anime_ids.tmdb_id
if not self.imdb_id and anime_ids.imdb_id:
self.imdb_id = anime_ids.imdb_id
if self.enrich and not (self.tmdb_id or self.imdb_id or self.animeapi_title):
raise click.UsageError("--enrich requires --tmdb, --imdb, or --animeapi to provide a metadata source.")
# Initialize debug logger with service name if debug logging is enabled
if config.debug or logging.root.level == logging.DEBUG:
@@ -583,16 +620,75 @@ class dl:
"profile": profile,
"proxy": proxy,
"tag": tag,
"tmdb_id": tmdb_id,
"tmdb_name": tmdb_name,
"tmdb_year": tmdb_year,
"tmdb_id": self.tmdb_id,
"imdb_id": self.imdb_id,
"animeapi_id": animeapi_id,
"enrich": enrich,
"cli_params": {
k: v
for k, v in ctx.params.items()
if k not in ["profile", "proxy", "tag", "tmdb_id", "tmdb_name", "tmdb_year"]
if k
not in [
"profile",
"proxy",
"tag",
"tmdb_id",
"imdb_id",
"animeapi_id",
"enrich",
]
},
},
)
# Log binary versions for diagnostics
binary_versions = {}
for name, binary in [
("shaka_packager", binaries.ShakaPackager),
("mp4decrypt", binaries.Mp4decrypt),
("n_m3u8dl_re", binaries.N_m3u8DL_RE),
("mkvmerge", binaries.MKVToolNix),
("ffmpeg", binaries.FFMPEG),
("ffprobe", binaries.FFProbe),
]:
if binary:
version = None
try:
if name == "shaka_packager":
r = subprocess.run(
[str(binary), "--version"], capture_output=True, text=True, timeout=5
)
version = (r.stdout or r.stderr or "").strip()
elif name in ("ffmpeg", "ffprobe"):
r = subprocess.run([str(binary), "-version"], capture_output=True, text=True, timeout=5)
version = (r.stdout or "").split("\n")[0].strip()
elif name == "mkvmerge":
r = subprocess.run(
[str(binary), "--version"], capture_output=True, text=True, timeout=5
)
version = (r.stdout or "").strip()
elif name == "mp4decrypt":
r = subprocess.run([str(binary)], capture_output=True, text=True, timeout=5)
output = (r.stdout or "") + (r.stderr or "")
lines = [line.strip() for line in output.split("\n") if line.strip()]
version = " | ".join(lines[:2]) if lines else None
elif name == "n_m3u8dl_re":
r = subprocess.run(
[str(binary), "--version"], capture_output=True, text=True, timeout=5
)
version = (r.stdout or r.stderr or "").strip().split("\n")[0]
except Exception:
version = "<error getting version>"
binary_versions[name] = {"path": str(binary), "version": version}
else:
binary_versions[name] = None
self.debug_logger.log(
level="DEBUG",
operation="binary_versions",
message="Binary tool versions",
context=binary_versions,
)
else:
self.debug_logger = None
@@ -848,6 +944,9 @@ class dl:
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
)
if repack:
config.repack = True
if tag:
config.tag = tag
@@ -859,7 +958,7 @@ class dl:
self,
service: Service,
quality: list[int],
vcodec: Optional[Video.Codec],
vcodec: list[Video.Codec],
acodec: list[Audio.Codec],
vbitrate: int,
abitrate: int,
@@ -898,6 +997,7 @@ class dl:
no_mux: bool,
workers: Optional[int],
downloads: int,
worst: bool,
best_available: bool,
split_audio: Optional[bool] = None,
*_: Any,
@@ -923,6 +1023,14 @@ class dl:
self.log.error("--require-subs and --s-lang cannot be used together")
sys.exit(1)
if worst and not quality:
self.log.error("--worst requires -q/--quality to be specified")
sys.exit(1)
if select_titles and wanted:
self.log.error("--select-titles and -w/--wanted cannot be used together")
sys.exit(1)
# Check if dovi_tool is available when hybrid mode is requested
if any(r == Video.Range.HYBRID for r in range_):
from envied.core.binaries import DoviTool
@@ -1014,40 +1122,51 @@ class dl:
cache_region = service.current_region if hasattr(service, "current_region") else None
cache_account_hash = get_account_hash(service.credential) if hasattr(service, "credential") else None
if (self.tmdb_year or self.tmdb_name) and self.tmdb_id:
if self.enrich:
sample_title = titles[0] if hasattr(titles, "__getitem__") else titles
kind = "tv" if isinstance(sample_title, Episode) else "movie"
tmdb_year_val = None
tmdb_name_val = None
enrich_title: Optional[str] = None
enrich_year: Optional[int] = None
if self.tmdb_year:
tmdb_year_val = tags.get_year(
if self.animeapi_title:
enrich_title = self.animeapi_title
if self.tmdb_id:
if not enrich_title:
enrich_title = providers.get_title_by_id(
self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
)
enrich_year = providers.get_year_by_id(
self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
)
elif self.imdb_id:
imdbapi = providers.get_provider("imdbapi")
if imdbapi:
imdb_result = imdbapi.get_by_id(self.imdb_id, kind)
if imdb_result:
if not enrich_title:
enrich_title = imdb_result.title
enrich_year = imdb_result.year
if self.tmdb_name:
tmdb_name_val = tags.get_title(
self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
)
if isinstance(titles, (Series, Movies)):
for t in titles:
if tmdb_year_val:
t.year = tmdb_year_val
if tmdb_name_val:
if isinstance(t, Episode):
t.title = tmdb_name_val
if enrich_title or enrich_year:
if isinstance(titles, (Series, Movies)):
for t in titles:
if enrich_title:
if isinstance(t, Episode):
t.title = enrich_title
else:
t.name = enrich_title
if enrich_year and not t.year:
t.year = enrich_year
else:
if enrich_title:
if isinstance(titles, Episode):
titles.title = enrich_title
else:
t.name = tmdb_name_val
else:
if tmdb_year_val:
titles.year = tmdb_year_val
if tmdb_name_val:
if isinstance(titles, Episode):
titles.title = tmdb_name_val
else:
titles.name = tmdb_name_val
titles.name = enrich_title
if enrich_year and not titles.year:
titles.year = enrich_year
console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2)))
@@ -1056,13 +1175,13 @@ class dl:
return
# Enables manual selection for Series when --select-titles is set
if select_titles and type(titles) == Series:
console.print(Padding(Rule(f"[rule.text]Select Titles"), (1, 2)))
if select_titles and isinstance(titles, Series):
console.print(Padding(Rule("[rule.text]Select Titles"), (1, 2)))
selection_titles = []
dependencies = {}
original_indices = {}
current_season = None
current_season_header_idx = -1
@@ -1081,37 +1200,40 @@ class dl:
# Note: Headers are not mapped to actual title indices
# Format display name
display_name = ((t.name[:35].rstrip() + "") if len(t.name) > 35 else t.name) if t.name else None
display_name = ((t.name[:30].rstrip() + "") if len(t.name) > 30 else t.name) if t.name else None
# Apply indentation only for multiple seasons
prefix = " " if multiple_seasons else ""
option_text = (
f"{prefix}{t.number}" + (f". {display_name}" if t.name else "")
)
option_text = f"{prefix}{t.number}" + (f". {display_name}" if t.name else "")
selection_titles.append(option_text)
current_ui_idx = len(selection_titles) - 1
# Map UI index to actual title index
original_indices[current_ui_idx] = i
# Link episode to season header for group selection
if current_season_header_idx != -1:
dependencies[current_season_header_idx].append(current_ui_idx)
selection_start = time.time()
# Execute selector with dependencies (headers select all children)
selected_ui_idx = select_multiple(
selection_titles,
minimal_count=1,
page_size=8,
return_indices=True,
dependencies=dependencies
dependencies=dependencies,
collapse_on_start=multiple_seasons,
)
if not selected_ui_idx:
console.print(Padding(":x: Selection Cancelled...", (0, 5, 1, 5)))
return
selection_end = time.time()
start_time += (selection_end - selection_start)
start_time += selection_end - selection_start
# Map UI indices back to title indices (excluding headers)
selected_idx = []
@@ -1155,15 +1277,20 @@ class dl:
if isinstance(title, Episode) and not self.tmdb_searched:
kind = "tv"
tmdb_title: Optional[str] = None
if self.tmdb_id:
tmdb_title = tags.get_title(
tmdb_title = providers.get_title_by_id(
self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
)
else:
self.tmdb_id, tmdb_title, self.search_source = tags.search_show_info(
result = providers.search_metadata(
title.title, title.year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
)
if not (self.tmdb_id and tmdb_title and tags.fuzzy_match(tmdb_title, title.title)):
if result and result.title and providers.fuzzy_match(result.title, title.title):
self.tmdb_id = result.external_ids.tmdb_id
tmdb_title = result.title
self.search_source = result.source
else:
self.tmdb_id = None
if list_ or list_titles:
if self.tmdb_id:
@@ -1178,22 +1305,25 @@ class dl:
self.tmdb_searched = True
if isinstance(title, Movie) and (list_ or list_titles) and not self.tmdb_id:
movie_id, movie_title, _ = tags.search_show_info(
movie_result = providers.search_metadata(
title.name, title.year, "movie", title_cacher, cache_title_id, cache_region, cache_account_hash
)
if movie_id:
if movie_result and movie_result.external_ids.tmdb_id:
console.print(
Padding(
f"Search -> {movie_title or '?'} [bright_black](ID {movie_id})",
f"Search -> {movie_result.title or '?'} "
f"[bright_black](ID {movie_result.external_ids.tmdb_id})",
(0, 5),
)
)
else:
console.print(Padding("Search -> [bright_black]No match found[/]", (0, 5)))
if self.tmdb_id and getattr(self, "search_source", None) != "simkl":
if self.tmdb_id and getattr(self, "search_source", None) not in ("simkl", "imdbapi"):
kind = "tv" if isinstance(title, Episode) else "movie"
tags.external_ids(self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash)
providers.fetch_external_ids(
self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
)
if slow and i != 0:
delay = random.randint(60, 120)
@@ -1223,6 +1353,15 @@ class dl:
v_lang = None
title.tracks.videos = []
if no_audio:
console.log("Skipped audio as --no-audio was used...")
a_lang = None
title.tracks.audio = []
if no_chapters:
console.log("Skipped chapters as --no-chapters was used...")
title.tracks.chapters = []
with console.status("Getting tracks...", spinner="dots"):
try:
title.tracks.add(service.get_tracks(title), warn_only=True)
@@ -1336,9 +1475,12 @@ class dl:
if isinstance(title, (Movie, Episode)):
# filter video tracks
if vcodec:
title.tracks.select_video(lambda x: x.codec == vcodec)
title.tracks.select_video(lambda x: x.codec in vcodec)
missing_codecs = [c for c in vcodec if not any(x.codec == c for x in title.tracks.videos)]
for codec in missing_codecs:
self.log.warning(f"Skipping {codec.name} video tracks as none are available.")
if not title.tracks.videos:
self.log.error(f"There's no {vcodec.name} Video Track...")
self.log.error(f"There's no {', '.join(c.name for c in vcodec)} Video Track...")
sys.exit(1)
if range_:
@@ -1350,10 +1492,20 @@ class dl:
self.log.warning(f"Skipping {color_range.name} video tracks as none are available.")
if vbitrate:
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
if not title.tracks.videos:
self.log.error(f"There's no {vbitrate}kbps Video Track...")
sys.exit(1)
if any(r == Video.Range.HYBRID for r in range_):
# In HYBRID mode, only apply bitrate filter to non-DV tracks
# DV tracks are kept regardless since they're only used for RPU metadata
title.tracks.select_video(
lambda x: x.range == Video.Range.DV or (x.bitrate and x.bitrate // 1000 == vbitrate)
)
if not any(x.range != Video.Range.DV for x in title.tracks.videos):
self.log.error(f"There's no {vbitrate}kbps Video Track...")
sys.exit(1)
else:
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
if not title.tracks.videos:
self.log.error(f"There's no {vbitrate}kbps Video Track...")
sys.exit(1)
video_languages = [lang for lang in (v_lang or lang) if lang != "best"]
if video_languages and "all" not in video_languages:
@@ -1380,10 +1532,38 @@ class dl:
self.log.error(f"There's no {processed_video_lang} Video Track...")
sys.exit(1)
has_hybrid = any(r == Video.Range.HYBRID for r in range_)
non_hybrid_ranges = [r for r in range_ if r != Video.Range.HYBRID]
if quality:
missing_resolutions = []
if any(r == Video.Range.HYBRID for r in range_):
title.tracks.select_video(title.tracks.select_hybrid(title.tracks.videos, quality))
if has_hybrid:
# Split tracks: hybrid candidates vs non-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)
]
# Apply hybrid selection to HDR10+DV tracks
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality)
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
if non_hybrid_ranges and non_hybrid_tracks:
# Also filter non-hybrid tracks by resolution
non_hybrid_selected = [
v
for v in non_hybrid_tracks
if any(v.height == res or int(v.width * (9 / 16)) == res for res in quality)
]
title.tracks.videos = hybrid_selected + non_hybrid_selected
else:
title.tracks.videos = hybrid_selected
else:
title.tracks.by_resolutions(quality)
@@ -1410,60 +1590,111 @@ class dl:
sys.exit(1)
# choose best track by range and quality
if any(r == Video.Range.HYBRID for r in range_):
# For hybrid mode, always apply hybrid selection
# If no quality specified, use only the best (highest) resolution
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)
]
if not quality:
# Get the highest resolution available
best_resolution = max((v.height for v in title.tracks.videos), default=None)
best_resolution = max((v.height for v in hybrid_candidate_tracks), default=None)
if best_resolution:
# Use the hybrid selection logic with only the best resolution
title.tracks.select_video(
title.tracks.select_hybrid(title.tracks.videos, [best_resolution])
)
# If quality was specified, hybrid selection was already applied above
else:
selected_videos: list[Video] = []
for resolution, color_range in product(quality or [None], range_ or [None]):
match = next(
(
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, [best_resolution])
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
else:
hybrid_selected = []
else:
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality)
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
# For non-hybrid ranges, apply Cartesian product selection
non_hybrid_selected: list[Video] = []
if non_hybrid_ranges and non_hybrid_tracks:
for resolution, color_range, codec in product(
quality or [None], non_hybrid_ranges, vcodec or [None]
):
candidates = [
t
for t in title.tracks.videos
for t in non_hybrid_tracks
if (
not resolution
or t.height == resolution
or int(t.width * (9 / 16)) == resolution
)
and (not color_range or t.range == color_range)
),
None,
)
and (not codec or t.codec == codec)
]
match = candidates[-1] if worst and candidates else next(iter(candidates), None)
if match and match not in non_hybrid_selected:
non_hybrid_selected.append(match)
title.tracks.videos = hybrid_selected + non_hybrid_selected
else:
selected_videos: list[Video] = []
for resolution, color_range, codec in product(
quality or [None], range_ or [None], vcodec or [None]
):
candidates = [
t
for t in title.tracks.videos
if (
not resolution
or t.height == resolution
or int(t.width * (9 / 16)) == resolution
)
and (not color_range or t.range == color_range)
and (not codec or t.codec == codec)
]
match = candidates[-1] if worst and candidates else next(iter(candidates), None)
if match and match not in selected_videos:
selected_videos.append(match)
title.tracks.videos = selected_videos
# validate hybrid mode requirements
if any(r == Video.Range.HYBRID for r in range_):
hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10]
base_tracks = [
v for v in title.tracks.videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)
]
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
if not hdr10_tracks and not dv_tracks:
hybrid_failed = False
if not base_tracks and not dv_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but neither is available")
self.log.error(
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available"
msg_detail = (
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
)
sys.exit(1)
elif not hdr10_tracks:
hybrid_failed = True
elif not base_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only DV is available")
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
sys.exit(1)
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available"
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
hybrid_failed = True
elif not dv_tracks:
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only HDR10 is available")
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
sys.exit(1)
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available"
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
hybrid_failed = True
if hybrid_failed:
other_ranges = [r for r in range_ if r != Video.Range.HYBRID]
if best_available and other_ranges:
self.log.warning(msg)
self.log.warning(
f"Continuing with remaining range(s): {', '.join(r.name for r in other_ranges)}"
)
range_ = other_ranges
else:
self.log.error(msg)
self.log.error(msg_detail)
sys.exit(1)
# filter subtitle tracks
if require_subs:
@@ -1481,8 +1712,6 @@ class dl:
f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles"
)
elif s_lang and "all" not in s_lang:
from envied.core.utilities import is_exact_match
match_func = is_exact_match if exact_lang else is_close_match
missing_langs = [
@@ -1547,7 +1776,7 @@ class dl:
if language not in processed_lang:
processed_lang.append(language)
if "best" in processed_lang:
if "best" in processed_lang or "all" in processed_lang:
unique_languages = {track.language for track in title.tracks.audio}
selected_audio = []
for language in unique_languages:
@@ -1570,7 +1799,7 @@ class dl:
else:
selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0))
title.tracks.audio = selected_audio
elif "all" not in processed_lang:
else:
# If multiple codecs were explicitly requested, pick the best track per codec per
# requested language instead of selecting *all* bitrate variants of a codec.
if acodec and len(acodec) > 1:
@@ -1604,7 +1833,10 @@ class dl:
if audio_description:
standard_audio = [a for a in title.tracks.audio if not a.descriptive]
selected_standards = title.tracks.by_language(
standard_audio, processed_lang, per_language=per_language, exact_match=exact_lang
standard_audio,
processed_lang,
per_language=per_language,
exact_match=exact_lang,
)
desc_audio = [a for a in title.tracks.audio if a.descriptive]
# Include all descriptive tracks for the requested languages.
@@ -1728,10 +1960,7 @@ class dl:
),
licence=partial(
service.get_playready_license
if (
is_playready_cdm(self.cdm)
)
and hasattr(service, "get_playready_license")
if is_playready_cdm(self.cdm)
else service.get_widevine_license,
title=title,
track=track,
@@ -1802,55 +2031,10 @@ class dl:
dl_time = time_elapsed_since(dl_start_time)
console.print(Padding(f"Track downloads finished in [progress.elapsed]{dl_time}[/]", (0, 5)))
video_track_n = 0
while (
not title.tracks.subtitles
and not no_subs
and not (hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES)
and not video_only
and not no_video
and len(title.tracks.videos) > video_track_n
and any(
x.get("codec_name", "").startswith("eia_")
for x in ffprobe(title.tracks.videos[video_track_n].path).get("streams", [])
)
):
with console.status(f"Checking Video track {video_track_n + 1} for Closed Captions..."):
try:
# TODO: Figure out the real language, it might be different
# EIA-CC tracks sadly don't carry language information :(
# TODO: Figure out if the CC language is original lang or not.
# Will need to figure out above first to do so.
video_track = title.tracks.videos[video_track_n]
track_id = f"ccextractor-{video_track.id}"
cc_lang = title.language or video_track.language
cc = video_track.ccextractor(
track_id=track_id,
out_path=config.directories.temp
/ config.filenames.subtitle.format(id=track_id, language=cc_lang),
language=cc_lang,
original=False,
)
if cc:
# will not appear in track listings as it's added after all times it lists
title.tracks.add(cc)
self.log.info(f"Extracted a Closed Caption from Video track {video_track_n + 1}")
else:
self.log.info(f"No Closed Captions were found in Video track {video_track_n + 1}")
except EnvironmentError:
self.log.error(
"Cannot extract Closed Captions as the ccextractor executable was not found..."
)
break
video_track_n += 1
# Subtitle output mode configuration (for sidecar originals)
subtitle_output_mode = config.subtitle.get("output_mode", "mux")
sidecar_format = config.subtitle.get("sidecar_format", "srt")
skip_subtitle_mux = (
subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
)
skip_subtitle_mux = subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
sidecar_subtitles: list[Subtitle] = []
sidecar_original_paths: dict[str, Path] = {}
if subtitle_output_mode in ("sidecar", "both") and not no_mux:
@@ -1912,6 +2096,75 @@ class dl:
if has_decrypted:
self.log.info(f"Decrypted tracks with {decrypt_tool}")
# Extract Closed Captions from decrypted video tracks
if (
not no_subs
and not (hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES)
and not video_only
and not no_video
):
match_func = is_exact_match if exact_lang else is_close_match
for video_track_n, video_track in enumerate(title.tracks.videos):
has_manifest_cc = bool(getattr(video_track, "closed_captions", None))
has_eia_cc = (
not has_manifest_cc
and not title.tracks.subtitles
and any(
x.get("codec_name", "").startswith("eia_")
for x in ffprobe(video_track.path).get("streams", [])
)
)
if not has_manifest_cc and not has_eia_cc:
continue
# Build list of CC entries to extract
if has_manifest_cc:
cc_entries = video_track.closed_captions
# Filter CC languages against --s-lang if specified
if s_lang and "all" not in s_lang:
cc_entries = [
entry for entry in cc_entries
if entry.get("language")
and match_func(Language.get(entry["language"]), s_lang)
]
if not cc_entries:
continue
else:
# EIA fallback: single entry with unknown language
cc_entries = [{}]
with console.status(f"Checking Video track {video_track_n + 1} for Closed Captions..."):
try:
for cc_idx, cc_entry in enumerate(cc_entries):
cc_lang = (
Language.get(cc_entry["language"])
if cc_entry.get("language")
else title.language or video_track.language
)
track_id = f"ccextractor-{video_track.id}-{cc_idx}"
cc = video_track.ccextractor(
track_id=track_id,
out_path=config.directories.temp
/ config.filenames.subtitle.format(id=track_id, language=cc_lang),
language=cc_lang,
original=False,
)
if cc:
cc.cc = True
title.tracks.add(cc)
self.log.info(
f"Extracted a Closed Caption ({cc_lang}) from Video track {video_track_n + 1}"
)
else:
self.log.info(
f"No Closed Captions were found in Video track {video_track_n + 1}"
)
except EnvironmentError:
self.log.error(
"Cannot extract Closed Captions as the ccextractor executable was not found..."
)
break
# Now repack the decrypted tracks
with console.status("Repackaging tracks with FFMPEG..."):
has_repacked = False
@@ -1987,12 +2240,14 @@ class dl:
# Hybrid mode: process DV and HDR10 tracks separately for each resolution
self.log.info("Processing Hybrid HDR10+DV tracks...")
# Group video tracks by resolution
# Group video tracks by resolution (prefer HDR10+ over HDR10 as base)
resolutions_processed = set()
hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10]
base_tracks_list = [
v for v in title.tracks.videos if v.range in (Video.Range.HDR10P, Video.Range.HDR10)
]
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
for hdr10_track in hdr10_tracks:
for hdr10_track in base_tracks_list:
resolution = hdr10_track.height
if resolution in resolutions_processed:
continue
@@ -2048,6 +2303,8 @@ class dl:
task_description += f" {video_track.height}p"
if len(range_) > 1:
task_description += f" {video_track.range.name}"
if len(vcodec) > 1:
task_description += f" {video_track.codec.name}"
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
if video_track:
@@ -2099,9 +2356,11 @@ class dl:
else:
base_filename = str(title)
sidecar_dir = config.directories.downloads
sidecar_dir = self.output_dir or config.directories.downloads
if not no_folder and isinstance(title, (Episode, Song)) and media_info:
sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
sidecar_dir /= title.get_filename(
media_info, show_service=not no_source, folder=True
)
sidecar_dir.mkdir(parents=True, exist_ok=True)
with console.status("Saving subtitle sidecar files..."):
@@ -2151,7 +2410,7 @@ class dl:
if no_mux:
# Handle individual track files without muxing
final_dir = config.directories.downloads
final_dir = self.output_dir or config.directories.downloads
if not no_folder and isinstance(title, (Episode, Song)):
# Create folder based on title
# Use first available track for filename generation
@@ -2201,9 +2460,10 @@ class dl:
self.log.debug(f"Saved: {final_path.name}")
else:
# Handle muxed files
used_final_paths: set[Path] = set()
for muxed_path in muxed_paths:
media_info = MediaInfo.parse(muxed_path)
final_dir = config.directories.downloads
final_dir = self.output_dir or config.directories.downloads
final_filename = title.get_filename(media_info, show_service=not no_source)
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
@@ -2212,20 +2472,29 @@ class dl:
final_dir.mkdir(parents=True, exist_ok=True)
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
template_type = (
"series" if isinstance(title, Episode) else "songs" if isinstance(title, Song) else "movies"
)
sep = config.get_template_separator(template_type)
if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix:
sep = "." if config.scene_naming else " "
final_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}"
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
if final_path.exists():
sep = "." if config.scene_naming else " "
if final_path in used_final_paths:
i = 2
while final_path.exists():
while final_path in used_final_paths:
final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}"
i += 1
shutil.move(muxed_path, final_path)
tags.tag_file(final_path, title, self.tmdb_id)
try:
os.replace(muxed_path, final_path)
except OSError:
if final_path.exists():
final_path.unlink()
shutil.move(muxed_path, final_path)
used_final_paths.add(final_path)
tags.tag_file(final_path, title, self.tmdb_id, self.imdb_id)
title_dl_time = time_elapsed_since(dl_start_time)
console.print(
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.0.0"
__version__ = "4.0.0"
@@ -1,5 +1,6 @@
import atexit
import logging
from datetime import datetime
import click
import urllib3
@@ -10,6 +10,7 @@ from contextlib import suppress
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
log = logging.getLogger("download_manager")
@@ -105,11 +106,44 @@ def _perform_download(
from envied.commands.dl import dl
from envied.core.config import config
from envied.core.services import Services
from envied.core.tracks import Subtitle, Video
from envied.core.utils.click_types import ContextData
from envied.core.utils.collections import merge_dict
log.info(f"Starting sync download for job {job_id}")
# Convert string parameters to enums (API receives strings, dl.result() expects enums)
vcodec_raw = params.get("vcodec")
if vcodec_raw:
if isinstance(vcodec_raw, str):
vcodec_raw = [vcodec_raw]
if isinstance(vcodec_raw, list) and vcodec_raw and not isinstance(vcodec_raw[0], Video.Codec):
codec_map = {c.name.upper(): c for c in Video.Codec}
codec_map.update({c.value.upper(): c for c in Video.Codec})
params["vcodec"] = [codec_map[v.upper()] for v in vcodec_raw if v.upper() in codec_map]
else:
params["vcodec"] = []
range_raw = params.get("range")
if range_raw:
if isinstance(range_raw, str):
range_raw = [range_raw]
if isinstance(range_raw, list) and range_raw and not isinstance(range_raw[0], Video.Range):
range_map = {r.name.upper(): r for r in Video.Range}
range_map.update({r.value.upper(): r for r in Video.Range})
params["range"] = [range_map[r.upper()] for r in range_raw if r.upper() in range_map]
else:
params["range"] = [Video.Range.SDR]
sub_format_raw = params.get("sub_format")
if sub_format_raw and isinstance(sub_format_raw, str):
sub_map = {c.name.upper(): c for c in Subtitle.Codec}
sub_map.update({c.value.upper(): c for c in Subtitle.Codec})
params["sub_format"] = sub_map.get(sub_format_raw.upper())
if params.get("export") and isinstance(params["export"], str):
params["export"] = Path(params["export"])
# Load service configuration
service_config_path = Services.get_path(service) / config.filenames.config
if service_config_path.exists():
@@ -127,10 +161,15 @@ def _perform_download(
"proxy": params.get("proxy"),
"no_proxy": params.get("no_proxy", False),
"profile": params.get("profile"),
"repack": params.get("repack", False),
"tag": params.get("tag"),
"tmdb_id": params.get("tmdb_id"),
"tmdb_name": params.get("tmdb_name", False),
"tmdb_year": params.get("tmdb_year", False),
"imdb_id": params.get("imdb_id"),
"animeapi_id": params.get("animeapi_id"),
"enrich": params.get("enrich", False),
"output_dir": Path(params["output_dir"]) if params.get("output_dir") else None,
"no_cache": params.get("no_cache", False),
"reset_cache": params.get("reset_cache", False),
}
dl_instance = dl(
@@ -138,10 +177,13 @@ def _perform_download(
no_proxy=params.get("no_proxy", False),
profile=params.get("profile"),
proxy=params.get("proxy"),
repack=params.get("repack", False),
tag=params.get("tag"),
tmdb_id=params.get("tmdb_id"),
tmdb_name=params.get("tmdb_name", False),
tmdb_year=params.get("tmdb_year", False),
imdb_id=params.get("imdb_id"),
animeapi_id=params.get("animeapi_id"),
enrich=params.get("enrich", False),
output_dir=Path(params["output_dir"]) if params.get("output_dir") else None,
)
service_module = Services.load(service)
@@ -220,14 +262,14 @@ def _perform_download(
dl_instance.result(
service=service_instance,
quality=params.get("quality", []),
vcodec=params.get("vcodec"),
vcodec=params.get("vcodec", []),
acodec=params.get("acodec"),
vbitrate=params.get("vbitrate"),
abitrate=params.get("abitrate"),
range_=params.get("range", ["SDR"]),
channels=params.get("channels"),
no_atmos=params.get("no_atmos", False),
split_audio=params.get("split_audio"),
select_titles=False,
wanted=params.get("wanted", []),
latest_episode=params.get("latest_episode", False),
lang=params.get("lang", ["orig"]),
@@ -245,6 +287,7 @@ def _perform_download(
no_subs=params.get("no_subs", False),
no_audio=params.get("no_audio", False),
no_chapters=params.get("no_chapters", False),
no_video=params.get("no_video", False),
audio_description=params.get("audio_description", False),
slow=params.get("slow", False),
list_=False,
@@ -259,6 +302,7 @@ def _perform_download(
workers=params.get("workers"),
downloads=params.get("downloads", 1),
best_available=params.get("best_available", False),
split_audio=params.get("split_audio"),
)
except SystemExit as exc:
+158 -8
View File
@@ -1,3 +1,4 @@
import enum
import logging
from typing import Any, Dict, List, Optional
@@ -42,8 +43,10 @@ DEFAULT_DOWNLOAD_PARAMS = {
"no_subs": False,
"no_audio": False,
"no_chapters": False,
"no_video": False,
"audio_description": False,
"slow": False,
"split_audio": None,
"skip_dl": False,
"export": None,
"cdm_only": None,
@@ -54,6 +57,11 @@ DEFAULT_DOWNLOAD_PARAMS = {
"workers": None,
"downloads": 1,
"best_available": False,
"repack": False,
"imdb_id": None,
"output_dir": None,
"no_cache": False,
"reset_cache": False,
}
@@ -341,6 +349,137 @@ def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict
return result
async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Handle search request."""
import inspect
import click
import yaml
from envied.commands.dl import dl
from envied.core.config import config
from envied.core.services import Services
from envied.core.utils.click_types import ContextData
from envied.core.utils.collections import merge_dict
service_tag = data.get("service")
query = data.get("query")
if not service_tag:
raise APIError(APIErrorCode.MISSING_SERVICE, "Missing required 'service' field")
if not query:
raise APIError(APIErrorCode.INVALID_PARAMETERS, "Missing required 'query' field")
normalized_service = Services.get_tag(service_tag)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
f"Service '{service_tag}' not found",
details={"service": service_tag},
)
profile = data.get("profile")
proxy_param = data.get("proxy")
no_proxy = data.get("no_proxy", False)
service_config_path = Services.get_path(normalized_service) / config.filenames.config
if service_config_path.exists():
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
else:
service_config = {}
merge_dict(config.services.get(normalized_service), service_config)
proxy_providers = []
if not no_proxy:
proxy_providers = initialize_proxy_providers()
if proxy_param and not no_proxy:
try:
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
proxy_param = resolved_proxy
except ValueError as e:
raise APIError(
APIErrorCode.INVALID_PROXY,
f"Proxy error: {e}",
details={"proxy": proxy_param, "service": normalized_service},
)
@click.command()
@click.pass_context
def dummy_service(ctx: click.Context) -> None:
pass
ctx = click.Context(dummy_service)
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
service_module = Services.load(normalized_service)
dummy_service.name = normalized_service
ctx.invoked_subcommand = normalized_service
service_ctx = click.Context(dummy_service, parent=ctx)
service_ctx.obj = ctx.obj
service_init_params = inspect.signature(service_module.__init__).parameters
service_kwargs = {"title": query}
# Extract default values from the click command
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
for param in service_module.cli.params:
if hasattr(param, "name") and param.name not in service_kwargs:
if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum):
service_kwargs[param.name] = param.default
for param_name, param_info in service_init_params.items():
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
if param_info.default is inspect.Parameter.empty:
if param_name == "meta_lang":
service_kwargs[param_name] = None
elif param_name == "movie":
service_kwargs[param_name] = False
else:
service_kwargs[param_name] = None
# Filter to only accepted params
accepted_params = set(service_init_params.keys()) - {"self", "ctx"}
service_kwargs = {k: v for k, v in service_kwargs.items() if k in accepted_params}
try:
service_instance = service_module(service_ctx, **service_kwargs)
except Exception as exc:
raise APIError(
APIErrorCode.SERVICE_ERROR,
f"Failed to initialize service: {exc}",
details={"service": normalized_service},
)
# Authenticate
cookies = dl.get_cookie_jar(normalized_service, profile)
credential = dl.get_credentials(normalized_service, profile)
service_instance.authenticate(cookies, credential)
# Search
results = []
try:
for result in service_instance.search():
results.append({
"id": result.id,
"title": result.title,
"description": result.description,
"label": result.label,
"url": result.url,
})
except NotImplementedError:
raise APIError(
APIErrorCode.SERVICE_ERROR,
f"Search is not supported by {normalized_service}",
details={"service": normalized_service},
)
return web.json_response({"results": results, "count": len(results)})
async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Handle list-titles request."""
service_tag = data.get("service")
@@ -439,7 +578,7 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques
for param in service_module.cli.params:
if hasattr(param, "name") and param.name not in service_kwargs:
# Add default value if parameter is not already provided
if hasattr(param, "default") and param.default is not None:
if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum):
service_kwargs[param.name] = param.default
# Handle required parameters that don't have click defaults
@@ -587,7 +726,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
for param in service_module.cli.params:
if hasattr(param, "name") and param.name not in service_kwargs:
# Add default value if parameter is not already provided
if hasattr(param, "default") and param.default is not None:
if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum):
service_kwargs[param.name] = param.default
# Handle required parameters that don't have click defaults
@@ -631,7 +770,10 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
try:
season_range = SeasonRange()
wanted = season_range.parse_tokens(wanted_param)
if isinstance(wanted_param, list):
wanted = season_range.parse_tokens(*wanted_param)
else:
wanted = season_range.parse_tokens(wanted_param)
log.debug(f"Parsed wanted '{wanted_param}' into {len(wanted)} episodes: {wanted[:10]}...")
except Exception as e:
raise APIError(
@@ -760,9 +902,17 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
None if valid, error message string if invalid
"""
if "vcodec" in data and data["vcodec"]:
valid_vcodecs = ["H264", "H265", "VP9", "AV1"]
if data["vcodec"].upper() not in valid_vcodecs:
return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}"
valid_vcodecs = ["H264", "H265", "H.264", "H.265", "AVC", "HEVC", "VC1", "VC-1", "VP8", "VP9", "AV1"]
if isinstance(data["vcodec"], str):
vcodec_values = [v.strip() for v in data["vcodec"].split(",") if v.strip()]
elif isinstance(data["vcodec"], list):
vcodec_values = [str(v).strip() for v in data["vcodec"] if str(v).strip()]
else:
return "vcodec must be a string or list"
invalid = [value for value in vcodec_values if value.upper() not in valid_vcodecs]
if invalid:
return f"Invalid vcodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_vcodecs)}"
if "acodec" in data and data["acodec"]:
valid_acodecs = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"]
@@ -778,7 +928,7 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
return f"Invalid acodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_acodecs)}"
if "sub_format" in data and data["sub_format"]:
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA"]
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA", "TTML", "STPP", "WVTT", "SMI", "SUB", "MPL2", "TMP"]
if data["sub_format"].upper() not in valid_sub_formats:
return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(valid_sub_formats)}"
@@ -880,7 +1030,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
# Extract default values from the service's click command
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
for param in service_module.cli.params:
if hasattr(param, "name") and hasattr(param, "default") and param.default is not None:
if hasattr(param, "name") and hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum):
# Store service-specific defaults (e.g., drm_system, hydrate_track, profile for NF)
service_specific_defaults[param.name] = param.default
+130 -11
View File
@@ -7,7 +7,8 @@ from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
from envied.core import __version__
from envied.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
from envied.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler,
list_download_jobs_handler, list_titles_handler, list_tracks_handler)
list_download_jobs_handler, list_titles_handler, list_tracks_handler,
search_handler)
from envied.core.services import Services
from envied.core.update_checker import UpdateChecker
@@ -199,6 +200,93 @@ async def services(request: web.Request) -> web.Response:
return handle_api_exception(e, context={"operation": "list_services"}, debug_mode=debug_mode)
async def search(request: web.Request) -> web.Response:
"""
Search for titles from a service.
---
summary: Search for titles
description: Search for titles by query string from a service
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- service
- query
properties:
service:
type: string
description: Service tag (e.g., NF, AMZN, ATV)
query:
type: string
description: Search query string
profile:
type: string
description: Profile to use for credentials and cookies (default - None)
proxy:
type: string
description: Proxy URI or country code (default - None)
no_proxy:
type: boolean
description: Force disable all proxy use (default - false)
responses:
'200':
description: Search results
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
id:
type: string
description: Title ID for use with other endpoints
title:
type: string
description: Title name
description:
type: string
description: Title description
label:
type: string
description: Informative label (e.g., availability, region)
url:
type: string
description: URL to the title page
count:
type: integer
description: Number of results returned
'400':
description: Invalid request
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
try:
return await search_handler(data, request)
except APIError as e:
return build_error_response(e, request.app.get("debug_api", False))
except Exception as e:
log.exception("Error in search")
debug_mode = request.app.get("debug_api", False)
return handle_api_exception(e, context={"operation": "search"}, debug_mode=debug_mode)
async def list_titles(request: web.Request) -> web.Response:
"""
List titles for a service and title ID.
@@ -409,11 +497,19 @@ async def download(request: web.Request) -> web.Response:
type: integer
description: Download resolution(s) (default - best available)
vcodec:
type: string
description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None)
oneOf:
- type: string
- type: array
items:
type: string
description: Video codec(s) to download (e.g., "H265" or ["H264", "H265"]) - accepts H264, H265, AVC, HEVC, VP8, VP9, AV1, VC1 (default - None)
acodec:
type: string
description: Audio codec(s) to download (e.g., AAC or AAC,EC3) (default - None)
oneOf:
- type: string
- type: array
items:
type: string
description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, EC3, AC4, OPUS, FLAC, ALAC, DTS, OGG (default - None)
vbitrate:
type: integer
description: Video bitrate in kbps (default - None)
@@ -424,7 +520,7 @@ async def download(request: web.Request) -> web.Response:
type: array
items:
type: string
description: Video color range (SDR, HDR10, DV) (default - ["SDR"])
description: Video color range (SDR, HDR10, HDR10+, HLG, DV, HYBRID) (default - ["SDR"])
channels:
type: number
description: Audio channels (e.g., 2.0, 5.1, 7.1) (default - None)
@@ -494,12 +590,18 @@ async def download(request: web.Request) -> web.Response:
no_chapters:
type: boolean
description: Do not download chapters (default - false)
no_video:
type: boolean
description: Do not download video tracks (default - false)
audio_description:
type: boolean
description: Download audio description tracks (default - false)
slow:
type: boolean
description: Add 60-120s delay between downloads (default - false)
split_audio:
type: boolean
description: Create separate output files per audio codec instead of merging all audio (default - null)
skip_dl:
type: boolean
description: Skip downloading, only retrieve decryption keys (default - false)
@@ -521,12 +623,12 @@ async def download(request: web.Request) -> web.Response:
tmdb_id:
type: integer
description: Use this TMDB ID for tagging (default - None)
tmdb_name:
animeapi_id:
type: string
description: Anime database ID via AnimeAPI, e.g. mal:12345 (default - None)
enrich:
type: boolean
description: Rename titles using TMDB name (default - false)
tmdb_year:
type: boolean
description: Use release year from TMDB (default - false)
description: Override show title and year from external source (default - false)
no_folder:
type: boolean
description: Disable folder creation for TV shows (default - false)
@@ -545,6 +647,21 @@ async def download(request: web.Request) -> web.Response:
best_available:
type: boolean
description: Continue with best available if requested quality unavailable (default - false)
repack:
type: boolean
description: Add REPACK tag to the output filename (default - false)
imdb_id:
type: string
description: Use this IMDB ID (e.g. tt1375666) for tagging (default - None)
output_dir:
type: string
description: Override the output directory for this download (default - None)
no_cache:
type: boolean
description: Bypass title cache for this download (default - false)
reset_cache:
type: boolean
description: Clear title cache before fetching (default - false)
responses:
'202':
description: Download job created
@@ -723,6 +840,7 @@ def setup_routes(app: web.Application) -> None:
"""Setup all API routes."""
app.router.add_get("/api/health", health)
app.router.add_get("/api/services", services)
app.router.add_post("/api/search", search)
app.router.add_post("/api/list-titles", list_titles)
app.router.add_post("/api/list-tracks", list_tracks)
app.router.add_post("/api/download", download)
@@ -748,6 +866,7 @@ def setup_swagger(app: web.Application) -> None:
[
web.get("/api/health", health),
web.get("/api/services", services),
web.post("/api/search", search),
web.post("/api/list-titles", list_titles),
web.post("/api/list-tracks", list_tracks),
web.post("/api/download", download),
+94 -3
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import re
import warnings
from pathlib import Path
from typing import Any, Optional
@@ -93,9 +95,26 @@ class Config:
self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
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)
self.dash_naming: bool = kwargs.get("dash_naming", False)
self.series_year: bool = kwargs.get("series_year", True)
self.language_tags: dict = kwargs.get("language_tags") or {}
self.output_template: dict = kwargs.get("output_template") or {}
if kwargs.get("scene_naming") is not None:
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."
)
if not self.output_template:
raise SystemExit(
"ERROR: 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."
)
self._validate_output_templates()
self.unicode_filenames: bool = kwargs.get("unicode_filenames", False)
self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default
@@ -105,6 +124,78 @@ class Config:
self.debug: bool = kwargs.get("debug", False)
self.debug_keys: bool = kwargs.get("debug_keys", False)
def _validate_output_templates(self) -> None:
"""Validate output template configurations and warn about potential issues."""
if not self.output_template:
return
valid_variables = {
"title",
"year",
"season",
"episode",
"season_episode",
"episode_name",
"quality",
"resolution",
"source",
"tag",
"track_number",
"artist",
"album",
"disc",
"audio",
"audio_channels",
"audio_full",
"atmos",
"dual",
"multi",
"video",
"hdr",
"hfr",
"edition",
"repack",
"lang_tag",
}
unsafe_chars = r'[<>:"/\\|?*]'
for template_type, template_str in self.output_template.items():
if not isinstance(template_str, str):
warnings.warn(f"Template '{template_type}' must be a string, got {type(template_str).__name__}")
continue
variables = re.findall(r"\{([^}]+)\}", template_str)
for var in variables:
var_clean = var.rstrip("?")
if var_clean not in valid_variables:
warnings.warn(f"Unknown template variable '{var}' in {template_type} template")
test_template = re.sub(r"\{[^}]+\}", "TEST", template_str)
if re.search(unsafe_chars, test_template):
warnings.warn(f"Template '{template_type}' may contain filesystem-unsafe characters")
if not template_str.strip():
warnings.warn(f"Template '{template_type}' is empty")
def get_template_separator(self, template_type: str = "movies") -> str:
"""Get the filename separator for the given template type.
Analyzes the active template to determine whether it uses dots or spaces
between variables. Falls back to dot separator (scene-style) by default.
Args:
template_type: One of "movies", "series", or "songs".
"""
template = self.output_template[template_type]
between_vars = re.findall(r"\}([^{]*)\{", template)
separator_text = "".join(between_vars)
dot_count = separator_text.count(".")
space_count = separator_text.count(" ")
return " " if space_count > dot_count else "."
@classmethod
def from_yaml(cls, path: Path) -> Config:
if not path.exists():
@@ -431,14 +431,24 @@ def download(
raise ValueError(error)
# Yield aggregate progress for this call's downloads
if total_size > 0:
# Yield both advance (bytes downloaded this iteration) and total for rich progress
if dl_speed != -1:
yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s", advance=0, completed=total_completed, total=total_size)
progress_data = {"advance": 0}
if len(gids) > 1:
# Multi-file mode (e.g., HLS): Return the count of completed segments
progress_data["completed"] = len(completed)
progress_data["total"] = len(gids)
else:
# Single-file mode: Return the total bytes downloaded
progress_data["completed"] = total_completed
if total_size > 0:
progress_data["total"] = total_size
else:
yield dict(advance=0, completed=total_completed, total=total_size)
elif dl_speed != -1:
yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s")
progress_data["total"] = None
if dl_speed != -1:
progress_data["downloaded"] = f"{filesize.decimal(dl_speed)}/s"
yield progress_data
time.sleep(1)
except KeyboardInterrupt:
@@ -191,6 +191,9 @@ def build_download_args(
args["--skip-merge"] = skip_merge
if ad_keyword:
args["--ad-keyword"] = ad_keyword
# Disable segment count validation to work around N_m3u8DL-RE's Math.Ceiling
# bug in duration-based SegmentTemplate calculation (see nilaoda/N_m3u8DL-RE#108)
args["--check-segments-count"] = False
key_args = []
if content_keys:
@@ -44,16 +44,15 @@ class PlayReady:
else:
kids = []
# Extract KIDs using pyplayready's method (may miss some KIDs)
# Extract KIDs using pyplayready's WrmHeader key_ids
if not kids:
for header in pssh.wrm_headers:
try:
signed_ids, _, _, _ = header.read_attributes()
except Exception:
continue
for signed_id in signed_ids:
for signed_key_id in getattr(header, "key_ids", []):
try:
kids.append(UUID(bytes_le=base64.b64decode(signed_id.value)))
if isinstance(signed_key_id.value, UUID):
kids.append(signed_key_id.value)
else:
kids.append(UUID(bytes_le=base64.b64decode(signed_key_id.value)))
except Exception:
continue
@@ -119,7 +118,33 @@ class PlayReady:
except Exception:
pass
# Also get individual KID
# v4.2/v4.3: DATA/PROTECTINFO/KIDS/KID
protectinfo_kids = root.findall(".//pr:DATA/pr:PROTECTINFO/pr:KIDS/pr:KID", ns)
for kid_elem in protectinfo_kids:
value = kid_elem.get("VALUE")
if value:
try:
kid_bytes = base64.b64decode(value + "==")
kid_uuid = UUID(bytes_le=kid_bytes)
if kid_uuid not in kids:
kids.append(kid_uuid)
except Exception:
pass
# v4.1: DATA/PROTECTINFO/KID
protectinfo_kid = root.findall(".//pr:DATA/pr:PROTECTINFO/pr:KID", ns)
for kid_elem in protectinfo_kid:
value = kid_elem.get("VALUE")
if value:
try:
kid_bytes = base64.b64decode(value + "==")
kid_uuid = UUID(bytes_le=kid_bytes)
if kid_uuid not in kids:
kids.append(kid_uuid)
except Exception:
pass
# v4.0: DATA/KID
individual_kids = root.findall(".//pr:DATA/pr:KID", ns)
for kid_elem in individual_kids:
if kid_elem.text:
@@ -7,7 +7,7 @@ import math
import re
import shutil
import sys
from copy import copy
from copy import copy, deepcopy
from functools import partial
from pathlib import Path
from typing import Any, Callable, Optional, Union
@@ -18,6 +18,7 @@ from zlib import crc32
import requests
from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid
from lxml import etree
from lxml.etree import Element, ElementTree
from pyplayready.system.pssh import PSSH as PR_PSSH
from pywidevine.cdm import Cdm as WidevineCdm
@@ -101,14 +102,22 @@ class DASH:
"""
tracks = Tracks()
filtered_period_ids: list[str] = []
for period in self.manifest.findall("Period"):
if callable(period_filter) and period_filter(period):
if period_id := period.get("id"):
filtered_period_ids.append(period_id)
continue
if next(iter(period.xpath("SegmentType/@value")), "content") != "content":
if period_id := period.get("id"):
filtered_period_ids.append(period_id)
continue
if "urn:amazon:primevideo:cachingBreadth" in [
x.get("schemeIdUri") for x in period.findall("SupplementalProperty")
]:
if period_id := period.get("id"):
filtered_period_ids.append(period_id)
continue
for adaptation_set in period.findall("AdaptationSet"):
@@ -235,6 +244,7 @@ class DASH:
"period": period,
"adaptation_set": adaptation_set,
"representation": rep,
"filtered_period_ids": filtered_period_ids,
}
},
**track_args,
@@ -541,6 +551,26 @@ class DASH:
skip_merge = False
if downloader.__name__ == "n_m3u8dl_re":
skip_merge = True
# When periods were filtered out during to_tracks(), n_m3u8dl_re will re-parse
# the raw MPD and download ALL periods (including ads/pre-rolls). Write a filtered
# MPD with the rejected periods removed so n_m3u8dl_re downloads the correct content.
filtered_period_ids = track.data.get("dash", {}).get("filtered_period_ids", [])
if filtered_period_ids:
filtered_manifest = deepcopy(manifest)
for child in list(filtered_manifest):
if not hasattr(child.tag, "find"):
continue
if child.tag == "Period" and child.get("id") in filtered_period_ids:
filtered_manifest.remove(child)
filtered_mpd_path = save_dir / f".{track.id}_filtered.mpd"
filtered_mpd_path.parent.mkdir(parents=True, exist_ok=True)
etree.ElementTree(filtered_manifest).write(
str(filtered_mpd_path), xml_declaration=True, encoding="utf-8"
)
track.from_file = filtered_mpd_path
downloader_args.update(
{
"filename": track.id,
@@ -578,6 +608,11 @@ class DASH:
status_update["downloaded"] = f"DASH {downloaded}"
progress(**status_update)
# Clean up filtered MPD temp file before enumerating segments
filtered_mpd_path = save_dir / f".{track.id}_filtered.mpd"
if filtered_mpd_path.exists():
filtered_mpd_path.unlink()
# see https://github.com/devine-dl/devine/issues/71
for control_file in save_dir.glob("*.aria2__temp"):
control_file.unlink()
@@ -112,6 +112,15 @@ class HLS:
session_drm = HLS.get_all_drm(session_keys)
audio_codecs_by_group_id: dict[str, Audio.Codec] = {}
cc_by_group_id: dict[str, list[dict[str, Any]]] = {}
for media in self.manifest.media:
if media.type == "CLOSED-CAPTIONS":
cc_by_group_id.setdefault(media.group_id, []).append({
"language": media.language,
"name": media.name,
"instream_id": media.instream_id,
"characteristics": media.characteristics,
})
tracks = Tracks()
for playlist in self.manifest.playlists:
@@ -161,6 +170,9 @@ class HLS:
width=playlist.stream_info.resolution[0] if playlist.stream_info.resolution else None,
height=playlist.stream_info.resolution[1] if playlist.stream_info.resolution else None,
fps=playlist.stream_info.frame_rate,
closed_captions=cc_by_group_id.get(
(playlist.stream_info.closed_captions or "").strip('"'), []
),
)
if primary_track_type is Video
else {}
@@ -145,7 +145,14 @@ class ISM:
fragment_time += duration_frag
track_id = hashlib.md5(
f"{codec}-{track_lang}-{ql.get('Bitrate') or 0}-{ql.get('Index') or 0}".encode()
"{codec}-{lang}-{bitrate}-{index}-{name}-{url}".format(
codec=codec,
lang=track_lang,
bitrate=ql.get("Bitrate") or 0,
index=ql.get("Index") or 0,
name=stream_index.get("Name") or "",
url=stream_index.get("Url") or "",
).encode()
).hexdigest()
data = {
@@ -0,0 +1,428 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import requests
from envied.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, fuzzy_match, log
from envied.core.providers.imdbapi import IMDBApiProvider
from envied.core.providers.simkl import SimklProvider
from envied.core.providers.tmdb import TMDBProvider
if TYPE_CHECKING:
from envied.core.title_cacher import TitleCacher
# Ordered by priority: IMDBApi (free), SIMKL, TMDB
ALL_PROVIDERS: list[type[MetadataProvider]] = [IMDBApiProvider, SimklProvider, TMDBProvider]
def get_available_providers() -> list[MetadataProvider]:
"""Return instantiated providers that have valid credentials."""
return [cls() for cls in ALL_PROVIDERS if cls().is_available()]
def get_provider(name: str) -> Optional[MetadataProvider]:
"""Get a specific provider by name."""
for cls in ALL_PROVIDERS:
if cls.NAME == name:
p = cls()
return p if p.is_available() else None
return None
# -- Public API (replaces tags.py functions) --
def search_metadata(
title: str,
year: Optional[int],
kind: str,
title_cacher: Optional[TitleCacher] = None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> Optional[MetadataResult]:
"""Search all available providers for metadata. Returns best match."""
# Check cache first
if title_cacher and cache_title_id:
for cls in ALL_PROVIDERS:
p = cls()
if not p.is_available():
continue
cached = title_cacher.get_cached_provider(p.NAME, cache_title_id, kind, cache_region, cache_account_hash)
if cached:
result = _cached_to_result(cached, p.NAME, kind)
if result and result.title and fuzzy_match(result.title, title):
log.debug("Using cached %s data for %r", p.NAME, title)
return result
# Search providers in priority order
for cls in ALL_PROVIDERS:
p = cls()
if not p.is_available():
continue
try:
result = p.search(title, year, kind)
except (requests.RequestException, ValueError, KeyError) as exc:
log.debug("%s search failed: %s", p.NAME, exc)
continue
if result and result.title and fuzzy_match(result.title, title):
# Enrich with cross-referenced IDs if we have IMDB but missing TMDB/TVDB
enrich_ids(result)
# Cache the result (include enriched IDs so they survive round-trip)
if title_cacher and cache_title_id and result.raw:
try:
cache_data = result.raw
if result.external_ids.tmdb_id or result.external_ids.tvdb_id:
cache_data = {
**result.raw,
"_enriched_ids": _external_ids_to_dict(result.external_ids),
}
title_cacher.cache_provider(
p.NAME, cache_title_id, cache_data, kind, cache_region, cache_account_hash
)
except Exception as exc:
log.debug("Failed to cache %s data: %s", p.NAME, exc)
return result
return None
def get_title_by_id(
tmdb_id: int,
kind: str,
title_cacher: Optional[TitleCacher] = None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> Optional[str]:
"""Get title name by TMDB ID."""
# Check cache first
if title_cacher and cache_title_id:
cached = title_cacher.get_cached_provider("tmdb", cache_title_id, kind, cache_region, cache_account_hash)
if cached and cached.get("detail"):
detail = cached["detail"]
tmdb_title = detail.get("title") or detail.get("name")
if tmdb_title:
log.debug("Using cached TMDB title: %r", tmdb_title)
return tmdb_title
tmdb = get_provider("tmdb")
if not tmdb:
return None
result = tmdb.get_by_id(tmdb_id, kind)
if not result:
return None
# Cache if possible
if title_cacher and cache_title_id and result.raw:
try:
ext_ids = tmdb.get_external_ids(tmdb_id, kind)
title_cacher.cache_provider(
"tmdb",
cache_title_id,
{"detail": result.raw, "external_ids": _external_ids_to_dict(ext_ids)},
kind,
cache_region,
cache_account_hash,
)
except Exception as exc:
log.debug("Failed to cache TMDB data: %s", exc)
return result.title
def get_year_by_id(
tmdb_id: int,
kind: str,
title_cacher: Optional[TitleCacher] = None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> Optional[int]:
"""Get release year by TMDB ID."""
# Check cache first
if title_cacher and cache_title_id:
cached = title_cacher.get_cached_provider("tmdb", cache_title_id, kind, cache_region, cache_account_hash)
if cached and cached.get("detail"):
detail = cached["detail"]
date = detail.get("release_date") or detail.get("first_air_date")
if date and len(date) >= 4 and date[:4].isdigit():
year = int(date[:4])
log.debug("Using cached TMDB year: %d", year)
return year
tmdb = get_provider("tmdb")
if not tmdb:
return None
result = tmdb.get_by_id(tmdb_id, kind)
if not result:
return None
# Cache if possible
if title_cacher and cache_title_id and result.raw:
try:
ext_ids = tmdb.get_external_ids(tmdb_id, kind)
title_cacher.cache_provider(
"tmdb",
cache_title_id,
{"detail": result.raw, "external_ids": _external_ids_to_dict(ext_ids)},
kind,
cache_region,
cache_account_hash,
)
except Exception as exc:
log.debug("Failed to cache TMDB data: %s", exc)
return result.year
def fetch_external_ids(
tmdb_id: int,
kind: str,
title_cacher: Optional[TitleCacher] = None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> ExternalIds:
"""Get external IDs by TMDB ID."""
# Check cache first
if title_cacher and cache_title_id:
cached = title_cacher.get_cached_provider("tmdb", cache_title_id, kind, cache_region, cache_account_hash)
if cached and cached.get("external_ids"):
log.debug("Using cached TMDB external IDs")
raw = cached["external_ids"]
return ExternalIds(
imdb_id=raw.get("imdb_id"),
tmdb_id=tmdb_id,
tmdb_kind=kind,
tvdb_id=raw.get("tvdb_id"),
)
tmdb = get_provider("tmdb")
if not tmdb:
return ExternalIds()
ext = tmdb.get_external_ids(tmdb_id, kind)
# Cache if possible
if title_cacher and cache_title_id:
try:
detail = None
result = tmdb.get_by_id(tmdb_id, kind)
if result and result.raw:
detail = result.raw
if detail:
title_cacher.cache_provider(
"tmdb",
cache_title_id,
{"detail": detail, "external_ids": _external_ids_to_dict(ext)},
kind,
cache_region,
cache_account_hash,
)
except Exception as exc:
log.debug("Failed to cache TMDB data: %s", exc)
return ext
# -- Internal helpers --
# Provider authority ranking for tie-breaking (lower index = more authoritative)
_ENRICHMENT_PROVIDERS = ("tmdb", "simkl")
_ENRICHMENT_AUTHORITY: dict[str, int] = {name: i for i, name in enumerate(_ENRICHMENT_PROVIDERS)}
def enrich_ids(result: MetadataResult) -> None:
"""Enrich a MetadataResult by cross-referencing IMDB ID with available providers.
Queries all available providers, cross-validates tmdb_id as anchor.
If a provider returns a different tmdb_id than the authoritative source,
ALL of that provider's data is dropped (likely resolved to wrong title).
"""
ids = result.external_ids
if not ids.imdb_id:
return
if ids.tmdb_id and ids.tvdb_id:
return # already have everything
kind = result.kind or "movie"
# Step 1: Collect enrichment results from all available providers
enrichments: list[tuple[str, ExternalIds]] = []
for provider_name in _ENRICHMENT_PROVIDERS:
p = get_provider(provider_name)
if not p:
continue
try:
enriched = p.find_by_imdb_id(ids.imdb_id, kind) # type: ignore[union-attr]
except Exception as exc:
log.debug("Enrichment via %s failed: %s", provider_name, exc)
continue
if enriched:
enrichments.append((provider_name, enriched))
if not enrichments:
return
# Step 2: Cross-validate using tmdb_id as anchor — drop providers that disagree
validated = _validate_enrichments(enrichments)
# Step 3: Merge validated data (fill gaps only)
for _provider_name, ext in validated:
if not ids.tmdb_id and ext.tmdb_id:
ids.tmdb_id = ext.tmdb_id
ids.tmdb_kind = ext.tmdb_kind or kind
if not ids.tvdb_id and ext.tvdb_id:
ids.tvdb_id = ext.tvdb_id
def _validate_enrichments(
enrichments: list[tuple[str, ExternalIds]],
) -> list[tuple[str, ExternalIds]]:
"""Drop providers whose tmdb_id conflicts with the authoritative value.
If providers disagree on tmdb_id, the more authoritative source wins
and ALL data from disagreeing providers is discarded (different tmdb_id
means the provider likely resolved to a different title entirely).
"""
from collections import Counter
# Collect tmdb_id votes
tmdb_votes: dict[str, int] = {}
for provider_name, ext in enrichments:
if ext.tmdb_id is not None:
tmdb_votes[provider_name] = ext.tmdb_id
if len(set(tmdb_votes.values())) <= 1:
return enrichments # all agree or only one voted — no conflict
# Find the authoritative tmdb_id
value_counts = Counter(tmdb_votes.values())
most_common_val, most_common_count = value_counts.most_common(1)[0]
if most_common_count > 1:
anchor_tmdb_id = most_common_val
else:
# No majority — pick the most authoritative provider
best_provider = min(
tmdb_votes.keys(),
key=lambda name: _ENRICHMENT_AUTHORITY.get(name, 99),
)
anchor_tmdb_id = tmdb_votes[best_provider]
# Drop any provider that disagrees
validated: list[tuple[str, ExternalIds]] = []
for provider_name, ext in enrichments:
if ext.tmdb_id is not None and ext.tmdb_id != anchor_tmdb_id:
log.debug(
"Dropping %s enrichment data: tmdb_id %s conflicts with "
"authoritative value %s (likely resolved to wrong title)",
provider_name,
ext.tmdb_id,
anchor_tmdb_id,
)
continue
validated.append((provider_name, ext))
return validated
def _external_ids_to_dict(ext: ExternalIds) -> dict:
"""Convert ExternalIds to a dict for caching."""
result: dict = {}
if ext.imdb_id:
result["imdb_id"] = ext.imdb_id
if ext.tmdb_id:
result["tmdb_id"] = ext.tmdb_id
if ext.tmdb_kind:
result["tmdb_kind"] = ext.tmdb_kind
if ext.tvdb_id:
result["tvdb_id"] = ext.tvdb_id
return result
def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[MetadataResult]:
"""Convert a cached provider dict back to a MetadataResult."""
if provider_name == "tmdb":
detail = cached.get("detail", {})
ext_raw = cached.get("external_ids", {})
title = detail.get("title") or detail.get("name")
date = detail.get("release_date") or detail.get("first_air_date")
year = int(date[:4]) if date and len(date) >= 4 and date[:4].isdigit() else None
tmdb_id = detail.get("id")
return MetadataResult(
title=title,
year=year,
kind=kind,
external_ids=ExternalIds(
imdb_id=ext_raw.get("imdb_id"),
tmdb_id=tmdb_id,
tmdb_kind=kind,
tvdb_id=ext_raw.get("tvdb_id"),
),
source="tmdb",
raw=cached,
)
elif provider_name == "simkl":
response = cached.get("response", cached)
if response.get("type") == "episode" and "show" in response:
info = response["show"]
elif response.get("type") == "movie" and "movie" in response:
info = response["movie"]
else:
return None
ids = info.get("ids", {})
tmdb_id = ids.get("tmdbtv") or ids.get("tmdb") or ids.get("moviedb")
if tmdb_id:
tmdb_id = int(tmdb_id)
return MetadataResult(
title=info.get("title"),
year=info.get("year"),
kind=kind,
external_ids=ExternalIds(
imdb_id=ids.get("imdb"),
tmdb_id=tmdb_id,
tmdb_kind=kind,
tvdb_id=ids.get("tvdb"),
),
source="simkl",
raw=cached,
)
elif provider_name == "imdbapi":
title = cached.get("primaryTitle") or cached.get("originalTitle")
year = cached.get("startYear")
imdb_id = cached.get("id")
# Restore enriched IDs that were saved alongside the raw data
enriched = cached.get("_enriched_ids", {})
return MetadataResult(
title=title,
year=year,
kind=kind,
external_ids=ExternalIds(
imdb_id=imdb_id,
tmdb_id=enriched.get("tmdb_id"),
tmdb_kind=enriched.get("tmdb_kind"),
tvdb_id=enriched.get("tvdb_id"),
),
source="imdbapi",
raw=cached,
)
return None
__all__ = [
"ALL_PROVIDERS",
"ExternalIds",
"MetadataProvider",
"MetadataResult",
"enrich_ids",
"fetch_external_ids",
"fuzzy_match",
"get_available_providers",
"get_provider",
"get_title_by_id",
"get_year_by_id",
"search_metadata",
]
@@ -0,0 +1,97 @@
from __future__ import annotations
import logging
import re
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from typing import Optional, Union
import requests
from requests.adapters import HTTPAdapter, Retry
log = logging.getLogger("METADATA")
HEADERS = {"User-Agent": "unshackle-tags/1.0"}
STRIP_RE = re.compile(r"[^a-z0-9]+", re.I)
YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$")
@dataclass
class ExternalIds:
"""Normalized external IDs across providers."""
imdb_id: Optional[str] = None
tmdb_id: Optional[int] = None
tmdb_kind: Optional[str] = None # "movie" or "tv"
tvdb_id: Optional[int] = None
@dataclass
class MetadataResult:
"""Unified metadata result from any provider."""
title: Optional[str] = None
year: Optional[int] = None
kind: Optional[str] = None # "movie" or "tv"
external_ids: ExternalIds = field(default_factory=ExternalIds)
source: str = "" # provider name, e.g. "tmdb", "simkl", "imdbapi"
raw: Optional[dict] = None # original API response for caching
class MetadataProvider(metaclass=ABCMeta):
"""Abstract base for metadata providers."""
NAME: str = ""
REQUIRES_KEY: bool = True
def __init__(self) -> None:
self.log = logging.getLogger(f"METADATA.{self.NAME.upper()}")
self._session: Optional[requests.Session] = None
@property
def session(self) -> requests.Session:
if self._session is None:
self._session = requests.Session()
self._session.headers.update(HEADERS)
retry = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"],
)
adapter = HTTPAdapter(max_retries=retry)
self._session.mount("https://", adapter)
self._session.mount("http://", adapter)
return self._session
@abstractmethod
def is_available(self) -> bool:
"""Return True if this provider has the credentials/keys it needs."""
@abstractmethod
def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]:
"""Search for a title and return metadata, or None on failure/no match."""
@abstractmethod
def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]:
"""Fetch metadata by this provider's native ID."""
@abstractmethod
def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds:
"""Fetch external IDs for a title by this provider's native ID."""
def _clean(s: str) -> str:
return STRIP_RE.sub("", s).lower()
def _strip_year(s: str) -> str:
return YEAR_RE.sub("", s).strip()
def fuzzy_match(a: str, b: str, threshold: float = 0.8) -> bool:
"""Return True if ``a`` and ``b`` are a close match."""
ratio = SequenceMatcher(None, _clean(a), _clean(b)).ratio()
return ratio >= threshold
@@ -0,0 +1,123 @@
from __future__ import annotations
from difflib import SequenceMatcher
from typing import Optional, Union
import requests
from envied.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, fuzzy_match
# Mapping from our kind ("movie"/"tv") to imdbapi.dev title types
KIND_TO_TYPES: dict[str, list[str]] = {
"movie": ["movie"],
"tv": ["tvSeries", "tvMiniSeries"],
}
class IMDBApiProvider(MetadataProvider):
"""IMDb metadata provider using imdbapi.dev (free, no API key)."""
NAME = "imdbapi"
REQUIRES_KEY = False
BASE_URL = "https://api.imdbapi.dev"
def is_available(self) -> bool:
return True # no key needed
def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]:
self.log.debug("Searching IMDBApi for %r (%s, %s)", title, kind, year)
try:
params: dict[str, str | int] = {"query": title, "limit": 20}
r = self.session.get(
f"{self.BASE_URL}/search/titles",
params=params,
timeout=30,
)
r.raise_for_status()
data = r.json()
except (requests.RequestException, ValueError) as exc:
self.log.debug("IMDBApi search failed: %s", exc)
return None
results = data.get("titles") or data.get("results") or []
if not results:
self.log.debug("IMDBApi returned no results for %r", title)
return None
# Filter by type if possible
type_filter = KIND_TO_TYPES.get(kind, [])
filtered = [r for r in results if r.get("type") in type_filter] if type_filter else results
candidates = filtered if filtered else results
# Find best fuzzy match, optionally filtered by year
best_match: Optional[dict] = None
best_ratio = 0.0
for candidate in candidates:
primary = candidate.get("primaryTitle") or ""
original = candidate.get("originalTitle") or ""
for name in [primary, original]:
if not name:
continue
ratio = SequenceMatcher(None, _clean(title), _clean(name)).ratio()
if ratio > best_ratio:
# If year provided, prefer matches within 1 year
candidate_year = candidate.get("startYear")
if year and candidate_year and abs(year - candidate_year) > 1:
continue
best_ratio = ratio
best_match = candidate
if not best_match:
self.log.debug("No matching result found in IMDBApi for %r", title)
return None
result_title = best_match.get("primaryTitle") or best_match.get("originalTitle")
if not result_title or not fuzzy_match(result_title, title):
self.log.debug("IMDBApi title mismatch: searched %r, got %r", title, result_title)
return None
imdb_id = best_match.get("id")
result_year = best_match.get("startYear")
self.log.debug("IMDBApi -> %s (ID %s)", result_title, imdb_id)
return MetadataResult(
title=result_title,
year=result_year,
kind=kind,
external_ids=ExternalIds(imdb_id=imdb_id),
source="imdbapi",
raw=best_match,
)
def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]:
"""Fetch metadata by IMDB ID (e.g. 'tt1375666')."""
imdb_id = str(provider_id)
self.log.debug("Fetching IMDBApi title %s", imdb_id)
try:
r = self.session.get(f"{self.BASE_URL}/titles/{imdb_id}", timeout=30)
r.raise_for_status()
data = r.json()
except (requests.RequestException, ValueError) as exc:
self.log.debug("IMDBApi get_by_id failed: %s", exc)
return None
title = data.get("primaryTitle") or data.get("originalTitle")
result_year = data.get("startYear")
return MetadataResult(
title=title,
year=result_year,
kind=kind,
external_ids=ExternalIds(imdb_id=data.get("id")),
source="imdbapi",
raw=data,
)
def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds:
"""Return external IDs. For IMDB, the provider_id IS the IMDB ID."""
return ExternalIds(imdb_id=str(provider_id))
@@ -0,0 +1,172 @@
from __future__ import annotations
from typing import Optional, Union
import requests
from envied.core.config import config
from envied.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, fuzzy_match
class SimklProvider(MetadataProvider):
"""SIMKL metadata provider (filename-based search)."""
NAME = "simkl"
REQUIRES_KEY = True
BASE_URL = "https://api.simkl.com"
def is_available(self) -> bool:
return bool(config.simkl_client_id)
def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]:
self.log.debug("Searching Simkl for %r (%s, %s)", title, kind, year)
# Construct appropriate filename based on type
filename = f"{title}"
if year:
filename = f"{title} {year}"
if kind == "tv":
filename += " S01E01.mkv"
else:
filename += " 2160p.mkv"
try:
headers = {"simkl-api-key": config.simkl_client_id}
resp = self.session.post(
f"{self.BASE_URL}/search/file", json={"file": filename}, headers=headers, timeout=30
)
resp.raise_for_status()
data = resp.json()
self.log.debug("Simkl API response received")
except (requests.RequestException, ValueError) as exc:
self.log.debug("Simkl search failed: %s", exc)
return None
# Handle case where SIMKL returns empty list (no results)
if isinstance(data, list):
self.log.debug("Simkl returned list (no matches) for %r", filename)
return None
return self._parse_response(data, title, year, kind)
def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]:
return None # SIMKL has no direct ID lookup used here
def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds:
return ExternalIds() # IDs come from search() response
def find_by_imdb_id(self, imdb_id: str, kind: str) -> Optional[ExternalIds]:
"""Look up TMDB/TVDB IDs from an IMDB ID using SIMKL's /search/id and detail endpoints."""
self.log.debug("Looking up IMDB ID %s on SIMKL", imdb_id)
headers = {"simkl-api-key": config.simkl_client_id}
try:
r = self.session.get(f"{self.BASE_URL}/search/id", params={"imdb": imdb_id}, headers=headers, timeout=30)
r.raise_for_status()
data = r.json()
except (requests.RequestException, ValueError) as exc:
self.log.debug("SIMKL search/id failed: %s", exc)
return None
if not isinstance(data, list) or not data:
self.log.debug("No SIMKL results for IMDB ID %s", imdb_id)
return None
entry = data[0]
simkl_id = entry.get("ids", {}).get("simkl")
if not simkl_id:
return None
# Map SIMKL type to endpoint
simkl_type = entry.get("type", "")
endpoint = "tv" if simkl_type in ("tv", "anime") else "movies"
# Fetch full details to get cross-referenced IDs
try:
r2 = self.session.get(
f"{self.BASE_URL}/{endpoint}/{simkl_id}",
params={"extended": "full"},
headers=headers,
timeout=30,
)
r2.raise_for_status()
detail = r2.json()
except (requests.RequestException, ValueError) as exc:
self.log.debug("SIMKL detail fetch failed: %s", exc)
return None
ids = detail.get("ids", {})
tmdb_id: Optional[int] = None
raw_tmdb = ids.get("tmdb")
if raw_tmdb:
tmdb_id = int(raw_tmdb)
tvdb_id: Optional[int] = None
raw_tvdb = ids.get("tvdb")
if raw_tvdb:
tvdb_id = int(raw_tvdb)
self.log.debug("SIMKL find -> TMDB %s, TVDB %s for IMDB %s", tmdb_id, tvdb_id, imdb_id)
return ExternalIds(
imdb_id=imdb_id,
tmdb_id=tmdb_id,
tmdb_kind=kind,
tvdb_id=tvdb_id,
)
def _parse_response(
self, data: dict, search_title: str, search_year: Optional[int], kind: str
) -> Optional[MetadataResult]:
"""Parse a SIMKL response into a MetadataResult."""
if data.get("type") == "episode" and "show" in data:
info = data["show"]
content_type = "tv"
elif data.get("type") == "movie" and "movie" in data:
info = data["movie"]
content_type = "movie"
else:
return None
result_title = info.get("title")
result_year = info.get("year")
# Verify title matches
if not result_title or not fuzzy_match(result_title, search_title):
self.log.debug("Simkl title mismatch: searched %r, got %r", search_title, result_title)
return None
# Verify year if provided (allow 1 year difference)
if search_year and result_year and abs(search_year - result_year) > 1:
self.log.debug("Simkl year mismatch: searched %d, got %d", search_year, result_year)
return None
ids = info.get("ids", {})
tmdb_id: Optional[int] = None
if content_type == "tv":
raw_tmdb = ids.get("tmdbtv")
else:
raw_tmdb = ids.get("tmdb") or ids.get("moviedb")
if raw_tmdb:
tmdb_id = int(raw_tmdb)
tvdb_id: Optional[int] = None
raw_tvdb = ids.get("tvdb")
if raw_tvdb:
tvdb_id = int(raw_tvdb)
self.log.debug("Simkl -> %s (TMDB ID %s)", result_title, tmdb_id)
return MetadataResult(
title=result_title,
year=result_year,
kind=kind,
external_ids=ExternalIds(
imdb_id=ids.get("imdb"),
tmdb_id=tmdb_id,
tmdb_kind=kind,
tvdb_id=tvdb_id,
),
source="simkl",
raw=data,
)
@@ -0,0 +1,199 @@
from __future__ import annotations
from difflib import SequenceMatcher
from typing import Optional, Union
import requests
from envied.core.config import config
from envied.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, _strip_year
class TMDBProvider(MetadataProvider):
"""TMDB (The Movie Database) metadata provider."""
NAME = "tmdb"
REQUIRES_KEY = True
BASE_URL = "https://api.themoviedb.org/3"
def is_available(self) -> bool:
return bool(config.tmdb_api_key)
@property
def _api_key(self) -> str:
return config.tmdb_api_key
def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]:
search_title = _strip_year(title)
self.log.debug("Searching TMDB for %r (%s, %s)", search_title, kind, year)
params: dict[str, str | int] = {"api_key": self._api_key, "query": search_title}
if year is not None:
params["year" if kind == "movie" else "first_air_date_year"] = year
try:
r = self.session.get(f"{self.BASE_URL}/search/{kind}", params=params, timeout=30)
r.raise_for_status()
results = r.json().get("results") or []
self.log.debug("TMDB returned %d results", len(results))
if not results:
return None
except requests.RequestException as exc:
self.log.warning("Failed to search TMDB for %s: %s", title, exc)
return None
best_ratio = 0.0
best_id: Optional[int] = None
best_title: Optional[str] = None
for result in results:
candidates = [
result.get("title"),
result.get("name"),
result.get("original_title"),
result.get("original_name"),
]
candidates = [c for c in candidates if c]
for candidate in candidates:
ratio = SequenceMatcher(None, _clean(search_title), _clean(candidate)).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_id = result.get("id")
best_title = candidate
self.log.debug("Best candidate ratio %.2f for %r (ID %s)", best_ratio, best_title, best_id)
if best_id is None:
first = results[0]
best_id = first.get("id")
best_title = first.get("title") or first.get("name")
if best_id is None:
return None
# Fetch full detail for caching
detail = self._fetch_detail(best_id, kind)
ext_raw = self._fetch_external_ids_raw(best_id, kind)
date = (detail or {}).get("release_date") or (detail or {}).get("first_air_date")
result_year = int(date[:4]) if date and len(date) >= 4 and date[:4].isdigit() else None
ext = ExternalIds(
imdb_id=ext_raw.get("imdb_id") if ext_raw else None,
tmdb_id=best_id,
tmdb_kind=kind,
tvdb_id=ext_raw.get("tvdb_id") if ext_raw else None,
)
return MetadataResult(
title=best_title,
year=result_year,
kind=kind,
external_ids=ext,
source="tmdb",
raw={"detail": detail or {}, "external_ids": ext_raw or {}},
)
def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]:
detail = self._fetch_detail(int(provider_id), kind)
if not detail:
return None
title = detail.get("title") or detail.get("name")
date = detail.get("release_date") or detail.get("first_air_date")
year = int(date[:4]) if date and len(date) >= 4 and date[:4].isdigit() else None
return MetadataResult(
title=title,
year=year,
kind=kind,
external_ids=ExternalIds(tmdb_id=int(provider_id), tmdb_kind=kind),
source="tmdb",
raw=detail,
)
def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds:
raw = self._fetch_external_ids_raw(int(provider_id), kind)
if not raw:
return ExternalIds(tmdb_id=int(provider_id), tmdb_kind=kind)
return ExternalIds(
imdb_id=raw.get("imdb_id"),
tmdb_id=int(provider_id),
tmdb_kind=kind,
tvdb_id=raw.get("tvdb_id"),
)
def find_by_imdb_id(self, imdb_id: str, kind: str) -> Optional[ExternalIds]:
"""Look up TMDB/TVDB IDs from an IMDB ID using TMDB's /find endpoint."""
self.log.debug("Looking up IMDB ID %s on TMDB", imdb_id)
try:
r = self.session.get(
f"{self.BASE_URL}/find/{imdb_id}",
params={"api_key": self._api_key, "external_source": "imdb_id"},
timeout=30,
)
r.raise_for_status()
data = r.json()
except requests.RequestException as exc:
self.log.debug("TMDB find by IMDB ID failed: %s", exc)
return None
# Check movie_results or tv_results based on kind
if kind == "movie":
results = data.get("movie_results") or []
else:
results = data.get("tv_results") or []
if not results:
# Try the other type as fallback
fallback_key = "tv_results" if kind == "movie" else "movie_results"
results = data.get(fallback_key) or []
if results:
kind = "tv" if kind == "movie" else "movie"
if not results:
self.log.debug("No TMDB results found for IMDB ID %s", imdb_id)
return None
match = results[0]
tmdb_id = match.get("id")
if not tmdb_id:
return None
self.log.debug("TMDB find -> ID %s (%s) for IMDB %s", tmdb_id, kind, imdb_id)
# Now fetch the full external IDs from TMDB to get TVDB etc.
ext_raw = self._fetch_external_ids_raw(tmdb_id, kind)
return ExternalIds(
imdb_id=imdb_id,
tmdb_id=tmdb_id,
tmdb_kind=kind,
tvdb_id=ext_raw.get("tvdb_id") if ext_raw else None,
)
def _fetch_detail(self, tmdb_id: int, kind: str) -> Optional[dict]:
try:
r = self.session.get(
f"{self.BASE_URL}/{kind}/{tmdb_id}",
params={"api_key": self._api_key},
timeout=30,
)
r.raise_for_status()
return r.json()
except requests.RequestException as exc:
self.log.debug("Failed to fetch TMDB detail: %s", exc)
return None
def _fetch_external_ids_raw(self, tmdb_id: int, kind: str) -> Optional[dict]:
try:
r = self.session.get(
f"{self.BASE_URL}/{kind}/{tmdb_id}/external_ids",
params={"api_key": self._api_key},
timeout=30,
)
r.raise_for_status()
return r.json()
except requests.RequestException as exc:
self.log.debug("Failed to fetch TMDB external IDs: %s", exc)
return None
+122 -3
View File
@@ -1,7 +1,8 @@
import base64
import logging
from abc import ABCMeta, abstractmethod
from collections.abc import Generator
from collections.abc import Callable, Generator
from dataclasses import dataclass, field
from http.cookiejar import CookieJar
from pathlib import Path
from typing import Optional, Union
@@ -24,9 +25,26 @@ from envied.core.search_result import SearchResult
from envied.core.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy
from envied.core.titles import Title_T, Titles_T
from envied.core.tracks import Chapters, Tracks
from envied.core.tracks.video import Video
from envied.core.utilities import get_cached_ip_info, get_ip_info
@dataclass
class TrackRequest:
"""Holds what the user requested for video codec and range selection.
Services read from this instead of ctx.parent.params for vcodec/range.
Attributes:
codecs: Requested codecs from CLI. Empty list means no filter (accept any).
ranges: Requested ranges from CLI. Defaults to [SDR].
"""
codecs: list[Video.Codec] = field(default_factory=list)
ranges: list[Video.Range] = field(default_factory=lambda: [Video.Range.SDR])
best_available: bool = False
def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:
"""
Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).
@@ -89,6 +107,16 @@ class Service(metaclass=ABCMeta):
self.credential = None # Will be set in authenticate()
self.current_region = None # Will be set based on proxy/geolocation
# Set track request from CLI params - services can read/override in their __init__
vcodec = ctx.parent.params.get("vcodec") if ctx.parent else None
range_ = ctx.parent.params.get("range_") if ctx.parent else None
best_available = ctx.parent.params.get("best_available", False) if ctx.parent else False
self.track_request = TrackRequest(
codecs=list(vcodec) if vcodec else [],
ranges=list(range_) if range_ else [Video.Range.SDR],
best_available=bool(best_available),
)
if not ctx.parent or not ctx.parent.params.get("no_proxy"):
if ctx.parent:
proxy = ctx.parent.params["proxy"]
@@ -205,6 +233,76 @@ class Service(metaclass=ABCMeta):
self.log.debug(f"Failed to get cached IP info: {e}")
self.current_region = None
def _get_tracks_for_variants(
self,
title: Title_T,
fetch_fn: Callable[..., Tracks],
) -> Tracks:
"""Call fetch_fn for each codec/range combo in track_request, merge results.
Services that need separate API calls per codec/range combo can use this
helper from their get_tracks() implementation.
The fetch_fn signature should be: (title, codec, range_) -> Tracks
For HYBRID range, fetch_fn is called with HDR10 and DV separately and
the DV video tracks are merged into the HDR10 result.
Args:
title: The title being processed.
fetch_fn: A callable that fetches tracks for a specific codec/range.
"""
all_tracks = Tracks()
first = True
codecs = self.track_request.codecs or [None]
ranges = self.track_request.ranges or [Video.Range.SDR]
for range_val in ranges:
if range_val == Video.Range.HYBRID:
# HYBRID: fetch HDR10 first (full tracks), then DV (video only)
for codec_val in codecs:
try:
hdr_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10)
except (ValueError, SystemExit) as e:
if self.track_request.best_available:
self.log.warning(f" - HDR10 not available for HYBRID, skipping ({e})")
continue
raise
if first:
all_tracks.add(hdr_tracks, warn_only=True)
first = False
else:
for video in hdr_tracks.videos:
all_tracks.add(video, warn_only=True)
try:
dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV)
for video in dv_tracks.videos:
all_tracks.add(video, warn_only=True)
except (ValueError, SystemExit):
self.log.info(" - No DolbyVision manifest available for HYBRID")
else:
for codec_val in codecs:
try:
tracks = fetch_fn(title, codec=codec_val, range_=range_val)
except (ValueError, SystemExit) as e:
if self.track_request.best_available:
codec_name = codec_val.name if codec_val else "default"
self.log.warning(
f" - {range_val.name}/{codec_name} not available, skipping ({e})"
)
continue
raise
if first:
all_tracks.add(tracks, warn_only=True)
first = False
else:
for video in tracks.videos:
all_tracks.add(video, warn_only=True)
return all_tracks
# Optional Abstract functions
# The following functions may be implemented by the Service.
# Otherwise, the base service code (if any) of the function will be executed on call.
@@ -222,7 +320,7 @@ class Service(metaclass=ABCMeta):
session.mount(
"https://",
HTTPAdapter(
max_retries=Retry(total=15, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
pool_block=True,
),
)
@@ -296,6 +394,27 @@ class Service(metaclass=ABCMeta):
Decode the data, return as is to reduce unnecessary computations.
"""
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
"""
Get a PlayReady License message by sending a License Request (challenge).
This License message contains the encrypted Content Decryption Keys and will be
read by the CDM and decrypted.
This is a very important request to get correct. A bad, unexpected, or missing
value in the request can cause your key to be detected and promptly banned,
revoked, disabled, or downgraded.
:param challenge: The license challenge from the PlayReady CDM.
:param title: The current `Title` from get_titles that is being executed. This is provided in
case it has data needed to be used, e.g. for a HTTP request.
:param track: The current `Track` needing decryption. Provided for same reason as `title`.
:return: The License response as Bytes or a Base64 string. Don't Base64 Encode or
Decode the data, return as is to reduce unnecessary computations.
"""
# Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided.
return self.get_widevine_license(challenge=challenge, title=title, track=track)
# Required Abstract functions
# The following functions *must* be implemented by the Service.
# The functions will be executed in shown order.
@@ -461,4 +580,4 @@ class Service(metaclass=ABCMeta):
"""
__all__ = ("Service",)
__all__ = ("Service", "TrackRequest")
+14 -3
View File
@@ -44,6 +44,17 @@ FINGERPRINT_PRESETS = {
"akamai": "4:16777216|16711681|0|m,p,a,s",
"description": "OkHttp 5.x (BoringSSL TLS stack)",
},
"shield_okhttp": {
"ja3": (
"771," # TLS 1.2
"4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53," # Ciphers (OkHttp 4.11)
"0-23-65281-10-11-35-16-5-13-51-45-43-21," # Extensions (incl padding ext 21)
"29-23-24," # Named groups (x25519, secp256r1, secp384r1)
"0" # EC point formats
),
"akamai": "4:16777216|16711681|0|m,p,a,s",
"description": "NVIDIA SHIELD Android TV OkHttp 4.11 (captured JA3)",
},
}
@@ -56,7 +67,7 @@ class MaxRetriesError(exceptions.RequestException):
class CurlSession(Session):
def __init__(
self,
max_retries: int = 10,
max_retries: int = 5,
backoff_factor: float = 0.2,
max_backoff: float = 60.0,
status_forcelist: list[int] | None = None,
@@ -150,7 +161,7 @@ def session(
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
fingerprint preset name (e.g. "okhttp4").
Uses the configured default from curl_impersonate.browser if not specified.
Available presets: okhttp4
Available presets: okhttp4, okhttp5
See https://github.com/lexiforest/curl_cffi#sessions for browser options.
ja3: Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats").
When provided, curl_cffi will use this exact TLS fingerprint instead of the browser's default.
@@ -172,7 +183,7 @@ def session(
- cert: Client certificate (str or tuple)
Extra arguments for retry handler:
- max_retries: Maximum number of retries (int, default 10)
- max_retries: Maximum number of retries (int, default 5)
- backoff_factor: Backoff factor (float, default 0.2)
- max_backoff: Maximum backoff time (float, default 60.0)
- status_forcelist: List of status codes to force retry (list, default [429, 500, 502, 503, 504])
+74 -131
View File
@@ -26,6 +26,7 @@ class TitleCacher:
self.log = logging.getLogger(f"{service_name}.TitleCache")
self.cacher = Cacher(service_name)
self.stats = {"hits": 0, "misses": 0, "fallbacks": 0}
self.no_cache = False
def _generate_cache_key(
self, title_id: str, region: Optional[str] = None, account_hash: Optional[str] = None
@@ -59,9 +60,6 @@ class TitleCacher:
# Join with underscores
cache_key = "_".join(key_parts)
# Log the mapping for debugging
self.log.debug(f"Cache key mapping: {title_id} -> {cache_key}")
return cache_key
def get_cached_titles(
@@ -89,6 +87,7 @@ class TitleCacher:
"""
# If caching is globally disabled or no_cache flag is set
if not config.title_cache_enabled or no_cache:
self.no_cache = True
self.log.debug("Cache bypassed, fetching fresh titles")
return fetch_function()
@@ -113,7 +112,7 @@ class TitleCacher:
# Cache miss or expired, try to fetch fresh data
self.stats["misses"] += 1
self.log.debug(f"Cache miss for {title_id}, fetching fresh data")
self.log.debug(f"Cache miss for {title_id} fetching fresh data")
try:
# Attempt to fetch fresh titles
@@ -180,22 +179,18 @@ class TitleCacher:
"hit_rate": f"{hit_rate:.1f}%",
}
def get_cached_tmdb(
self, title_id: str, kind: str, region: Optional[str] = None, account_hash: Optional[str] = None
# -- Generic provider cache methods --
def get_cached_provider(
self,
provider_name: str,
title_id: str,
kind: Optional[str] = None,
region: Optional[str] = None,
account_hash: Optional[str] = None,
) -> Optional[dict]:
"""
Get cached TMDB data for a title.
Args:
title_id: The title identifier
kind: "movie" or "tv"
region: The region/proxy identifier
account_hash: Hash of account credentials
Returns:
Dict with 'detail' and 'external_ids' if cached and valid, None otherwise
"""
if not config.title_cache_enabled:
"""Get cached metadata for any provider."""
if not config.title_cache_enabled or self.no_cache:
return None
cache_key = self._generate_cache_key(title_id, region, account_hash)
@@ -204,142 +199,90 @@ class TitleCacher:
if not cache or not cache.data:
return None
tmdb_data = getattr(cache.data, "tmdb_data", None)
if not tmdb_data:
provider_data = getattr(cache.data, f"{provider_name}_data", None)
if not provider_data:
return None
tmdb_expiration = tmdb_data.get("expires_at")
if not tmdb_expiration or datetime.now() >= tmdb_expiration:
self.log.debug(f"TMDB cache expired for {title_id}")
expiration = provider_data.get("expires_at")
if not expiration or datetime.now() >= expiration:
self.log.debug(f"{provider_name} cache expired for {title_id}")
return None
if tmdb_data.get("kind") != kind:
self.log.debug(f"TMDB cache kind mismatch for {title_id}: cached {tmdb_data.get('kind')}, requested {kind}")
if kind and provider_data.get("kind") != kind:
self.log.debug(
f"{provider_name} cache kind mismatch for {title_id}: "
f"cached {provider_data.get('kind')}, requested {kind}"
)
return None
self.log.debug(f"TMDB cache hit for {title_id}")
return {
"detail": tmdb_data.get("detail"),
"external_ids": tmdb_data.get("external_ids"),
"fetched_at": tmdb_data.get("fetched_at"),
}
self.log.debug(f"{provider_name} cache hit for {title_id}")
def cache_tmdb(
# Return the inner data (provider-specific format)
response = provider_data.get("response")
if response is not None:
return response
# For TMDB-style caches that store detail + external_ids at top level
result: dict = {}
if "detail" in provider_data:
result["detail"] = provider_data["detail"]
if "external_ids" in provider_data:
result["external_ids"] = provider_data["external_ids"]
if "fetched_at" in provider_data:
result["fetched_at"] = provider_data["fetched_at"]
return result if result else provider_data
def cache_provider(
self,
provider_name: str,
title_id: str,
detail_response: dict,
external_ids_response: dict,
kind: str,
data: dict,
kind: Optional[str] = None,
region: Optional[str] = None,
account_hash: Optional[str] = None,
ttl_days: int = 7,
) -> None:
"""
Cache TMDB data for a title.
Args:
title_id: The title identifier
detail_response: Full TMDB detail API response
external_ids_response: Full TMDB external_ids API response
kind: "movie" or "tv"
region: The region/proxy identifier
account_hash: Hash of account credentials
"""
if not config.title_cache_enabled:
"""Cache metadata from any provider."""
if not config.title_cache_enabled or self.no_cache:
return
cache_key = self._generate_cache_key(title_id, region, account_hash)
cache = self.cacher.get(cache_key, version=1)
if not cache or not cache.data:
self.log.debug(f"Cannot cache TMDB data: no title cache exists for {title_id}")
self.log.debug(f"Cannot cache {provider_name} data: no title cache exists for {title_id}")
return
now = datetime.now()
tmdb_data = {
"detail": detail_response,
"external_ids": external_ids_response,
"kind": kind,
"fetched_at": now,
"expires_at": now + timedelta(days=7), # 7-day expiration
}
cache.data.tmdb_data = tmdb_data
# Build cache entry in a format compatible with legacy methods
if provider_name == "tmdb" and "detail" in data:
# TMDB stores detail + external_ids at top level
cache_entry = {
**data,
"kind": kind,
"fetched_at": now,
"expires_at": now + timedelta(days=ttl_days),
}
elif provider_name == "simkl":
# SIMKL wraps in a "response" key
cache_entry = {
"response": data,
"fetched_at": now,
"expires_at": now + timedelta(days=ttl_days),
}
else:
# Generic format: store data directly with metadata
cache_entry = {
"response": data,
"kind": kind,
"fetched_at": now,
"expires_at": now + timedelta(days=ttl_days),
}
setattr(cache.data, f"{provider_name}_data", cache_entry)
cache.set(cache.data, expiration=cache.expiration)
self.log.debug(f"Cached TMDB data for {title_id} (kind={kind})")
def get_cached_simkl(
self, title_id: str, region: Optional[str] = None, account_hash: Optional[str] = None
) -> Optional[dict]:
"""
Get cached Simkl data for a title.
Args:
title_id: The title identifier
region: The region/proxy identifier
account_hash: Hash of account credentials
Returns:
Simkl response dict if cached and valid, None otherwise
"""
if not config.title_cache_enabled:
return None
cache_key = self._generate_cache_key(title_id, region, account_hash)
cache = self.cacher.get(cache_key, version=1)
if not cache or not cache.data:
return None
simkl_data = getattr(cache.data, "simkl_data", None)
if not simkl_data:
return None
simkl_expiration = simkl_data.get("expires_at")
if not simkl_expiration or datetime.now() >= simkl_expiration:
self.log.debug(f"Simkl cache expired for {title_id}")
return None
self.log.debug(f"Simkl cache hit for {title_id}")
return simkl_data.get("response")
def cache_simkl(
self,
title_id: str,
simkl_response: dict,
region: Optional[str] = None,
account_hash: Optional[str] = None,
) -> None:
"""
Cache Simkl data for a title.
Args:
title_id: The title identifier
simkl_response: Full Simkl API response
region: The region/proxy identifier
account_hash: Hash of account credentials
"""
if not config.title_cache_enabled:
return
cache_key = self._generate_cache_key(title_id, region, account_hash)
cache = self.cacher.get(cache_key, version=1)
if not cache or not cache.data:
self.log.debug(f"Cannot cache Simkl data: no title cache exists for {title_id}")
return
now = datetime.now()
simkl_data = {
"response": simkl_response,
"fetched_at": now,
"expires_at": now + timedelta(days=7),
}
cache.data.simkl_data = simkl_data
cache.set(cache.data, expiration=cache.expiration)
self.log.debug(f"Cached Simkl data for {title_id}")
self.log.debug(f"Cached {provider_name} data for {title_id}")
def get_region_from_proxy(proxy_url: Optional[str]) -> Optional[str]:
+40 -153
View File
@@ -9,9 +9,9 @@ from rich.tree import Tree
from sortedcontainers import SortedKeyList
from envied.core.config import config
from envied.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
from envied.core.titles.title import Title
from envied.core.utilities import sanitize_filename
from envied.core.utils.template_formatter import TemplateFormatter
class Episode(Title):
@@ -78,173 +78,60 @@ class Episode(Title):
self.year = year
self.description = description
def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict:
"""Build template context dictionary from MediaInfo."""
context = self._build_base_template_context(media_info, show_service)
context["title"] = self.title.replace("$", "S")
context["year"] = self.year or ""
context["season"] = f"S{self.season:02}"
context["episode"] = f"E{self.number:02}"
context["season_episode"] = f"S{self.season:02}E{self.number:02}"
context["episode_name"] = self.name or ""
return context
def __str__(self) -> str:
return "{title}{year} S{season:02}E{number:02} {name}".format(
title=self.title,
year=f" {self.year}" if self.year and config.series_year else "",
year=f" {self.year}" if self.year else "",
season=self.season,
number=self.number,
name=self.name or "",
).strip()
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
primary_video_track = next(iter(media_info.video_tracks), None)
primary_audio_track = None
if media_info.audio_tracks:
sorted_audio = sorted(
media_info.audio_tracks,
key=lambda x: (
float(x.bit_rate) if x.bit_rate else 0,
bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures),
),
reverse=True,
)
primary_audio_track = sorted_audio[0]
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
def _get_resolution_token(track: Any) -> str:
if not track or not getattr(track, "height", None):
return ""
resolution = track.height
try:
dar = getattr(track, "other_display_aspect_ratio", None) or []
if dar and dar[0]:
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
resolution = int(track.width * (9 / 16))
except Exception:
pass
scan_suffix = "p"
scan_type = getattr(track, "scan_type", None)
if scan_type and str(scan_type).lower() == "interlaced":
scan_suffix = "i"
return f"{resolution}{scan_suffix}"
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
if folder:
name = f"{self.title}"
if self.year and config.series_year:
name += f" {self.year}"
name += f" S{self.season:02}"
else:
if config.dash_naming:
# Format: Title - SXXEXX - Episode Name
name = self.title.replace("$", "S") # e.g., Arli$$
series_template = config.output_template.get("series")
if series_template:
folder_template = series_template
folder_template = re.sub(r'\{episode\}', '', folder_template)
folder_template = re.sub(r'\{episode_name\?\}', '', folder_template)
folder_template = re.sub(r'\{episode_name\}', '', folder_template)
folder_template = re.sub(r'\{season_episode\}', '{season}', folder_template)
# Add year if configured
if self.year and config.series_year:
name += f" {self.year}"
folder_template = re.sub(r'\.{2,}', '.', folder_template)
folder_template = re.sub(r'\s{2,}', ' ', folder_template)
folder_template = re.sub(r'^[\.\s]+|[\.\s]+$', '', folder_template)
# Add season and episode
name += f" - S{self.season:02}E{self.number:02}"
formatter = TemplateFormatter(folder_template)
context = self._build_template_context(media_info, show_service)
context['season'] = f"S{self.season:02}"
# Add episode name with dash separator
if self.name:
name += f" - {self.name}"
folder_name = formatter.format(context)
name = name.strip()
else:
# Standard format without extra dashes
name = "{title}{year} S{season:02}E{number:02} {name}".format(
title=self.title.replace("$", "S"), # e.g., Arli$$
year=f" {self.year}" if self.year and config.series_year else "",
season=self.season,
number=self.number,
name=self.name or "",
).strip()
if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track)
if resolution_token:
name += f" {resolution_token}"
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# 'WEB-DL'
name += " WEB-DL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
)
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or primary_audio_track.joc:
name += " Atmos"
# Video (dynamic range + hfr +) Codec
if primary_video_track:
codec = primary_video_track.format
hdr_format = primary_video_track.hdr_format_commercial
hdr_format_full = primary_video_track.hdr_format or ""
trc = (
primary_video_track.transfer_characteristics
or primary_video_track.transfer_characteristics_original
or ""
)
frame_rate = float(primary_video_track.frame_rate)
def _append_token(current: str, token: Optional[str]) -> str:
token = (token or "").strip()
current = current.rstrip()
if not token:
return current
if current.endswith(f" {token}"):
return current
return f"{current} {token}"
# Primary HDR format detection
if hdr_format:
if hdr_format_full.startswith("Dolby Vision"):
name = _append_token(name, "DV")
if any(
indicator in (hdr_format_full + " " + hdr_format)
for indicator in ["HDR10", "SMPTE ST 2086"]
):
name = _append_token(name, "HDR")
elif "HDR Vivid" in hdr_format:
name = _append_token(name, "HDR")
if '.' in series_template and ' ' not in series_template:
return sanitize_filename(folder_name, ".")
else:
dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or ""
name = _append_token(name, dynamic_range)
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
name += " HLG"
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
name += " HDR"
if frame_rate > 30:
name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
return sanitize_filename(folder_name, " ")
else:
name = f"{self.title}"
if self.year:
name += f" {self.year}"
name += f" S{self.season:02}"
return sanitize_filename(name, " ")
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name, "." if config.scene_naming else " ")
formatter = TemplateFormatter(config.output_template["series"])
context = self._build_template_context(media_info, show_service)
return formatter.format(context)
class Series(SortedKeyList, ABC):
@@ -254,7 +141,7 @@ class Series(SortedKeyList, ABC):
def __str__(self) -> str:
if not self:
return super().__str__()
return self[0].title + (f" ({self[0].year})" if self[0].year and config.series_year else "")
return self[0].title + (f" ({self[0].year})" if self[0].year else "")
def tree(self, verbose: bool = False) -> Tree:
seasons = Counter(x.season for x in self)
+16 -129
View File
@@ -7,9 +7,9 @@ from rich.tree import Tree
from sortedcontainers import SortedKeyList
from envied.core.config import config
from envied.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
from envied.core.titles.title import Title
from envied.core.utilities import sanitize_filename
from envied.core.utils.template_formatter import TemplateFormatter
class Movie(Title):
@@ -45,141 +45,28 @@ class Movie(Title):
self.year = year
self.description = description
def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict:
"""Build template context dictionary from MediaInfo."""
context = self._build_base_template_context(media_info, show_service)
context["title"] = self.name.replace("$", "S")
context["year"] = self.year or ""
return context
def __str__(self) -> str:
if self.year:
if config.dash_naming:
return f"{self.name} - {self.year}"
return f"{self.name} ({self.year})"
return self.name
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
primary_video_track = next(iter(media_info.video_tracks), None)
primary_audio_track = None
if media_info.audio_tracks:
sorted_audio = sorted(
media_info.audio_tracks,
key=lambda x: (
float(x.bit_rate) if x.bit_rate else 0,
bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures),
),
reverse=True,
)
primary_audio_track = sorted_audio[0]
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
if folder:
name = f"{self.name}"
if self.year:
name += f" ({self.year})"
return sanitize_filename(name, " ")
def _get_resolution_token(track: Any) -> str:
if not track or not getattr(track, "height", None):
return ""
resolution = track.height
try:
dar = getattr(track, "other_display_aspect_ratio", None) or []
if dar and dar[0]:
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
resolution = int(track.width * (9 / 16))
except Exception:
pass
scan_suffix = "p"
scan_type = getattr(track, "scan_type", None)
if scan_type and str(scan_type).lower() == "interlaced":
scan_suffix = "i"
return f"{resolution}{scan_suffix}"
# Name (Year)
name = str(self).replace("$", "S") # e.g., Arli$$
if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track)
if resolution_token:
name += f" {resolution_token}"
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# 'WEB-DL'
name += " WEB-DL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
)
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or primary_audio_track.joc:
name += " Atmos"
# Video (dynamic range + hfr +) Codec
if primary_video_track:
codec = primary_video_track.format
hdr_format = primary_video_track.hdr_format_commercial
hdr_format_full = primary_video_track.hdr_format or ""
trc = (
primary_video_track.transfer_characteristics
or primary_video_track.transfer_characteristics_original
or ""
)
frame_rate = float(primary_video_track.frame_rate)
def _append_token(current: str, token: Optional[str]) -> str:
token = (token or "").strip()
current = current.rstrip()
if not token:
return current
if current.endswith(f" {token}"):
return current
return f"{current} {token}"
# Primary HDR format detection
if hdr_format:
if hdr_format_full.startswith("Dolby Vision"):
name = _append_token(name, "DV")
if any(
indicator in (hdr_format_full + " " + hdr_format)
for indicator in ["HDR10", "SMPTE ST 2086"]
):
name = _append_token(name, "HDR")
elif "HDR Vivid" in hdr_format:
name = _append_token(name, "HDR")
else:
dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or ""
name = _append_token(name, dynamic_range)
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
name += " HLG"
elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower():
name += " HDR"
if frame_rate > 30:
name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name, "." if config.scene_naming else " ")
formatter = TemplateFormatter(config.output_template["movies"])
context = self._build_template_context(media_info, show_service)
return formatter.format(context)
class Movies(SortedKeyList, ABC):
+20 -39
View File
@@ -7,9 +7,9 @@ from rich.tree import Tree
from sortedcontainers import SortedKeyList
from envied.core.config import config
from envied.core.constants import AUDIO_CODEC_MAP
from envied.core.titles.title import Title
from envied.core.utilities import sanitize_filename
from envied.core.utils.template_formatter import TemplateFormatter
class Song(Title):
@@ -41,7 +41,7 @@ class Song(Title):
if not album:
raise ValueError("Song album must be provided")
if not isinstance(album, str):
raise TypeError(f"Expected album to be a str, not {name!r}")
raise TypeError(f"Expected album to be a str, not {album!r}")
if not track:
raise ValueError("Song track must be provided")
@@ -81,46 +81,27 @@ class Song(Title):
artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name
).strip()
def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict:
"""Build template context dictionary from MediaInfo."""
context = self._build_base_template_context(media_info, show_service)
context["title"] = self.name.replace("$", "S")
context["year"] = self.year or ""
context["track_number"] = f"{self.track:02}"
context["artist"] = self.artist.replace("$", "S")
context["album"] = self.album.replace("$", "S")
context["disc"] = f"{self.disc:02}" if self.disc > 1 else ""
return context
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
audio_track = next(iter(media_info.audio_tracks), None)
codec = audio_track.format
channel_layout = audio_track.channel_layout or audio_track.channellayout_original
if channel_layout:
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
else:
channel_count = audio_track.channel_s or audio_track.channels or 0
channels = float(channel_count)
features = audio_track.format_additionalfeatures or ""
if folder:
# Artist - Album (Year)
name = str(self).split(" / ")[0]
else:
# NN. Song Name
name = str(self).split(" / ")[1]
name = f"{self.artist} - {self.album}"
if self.year:
name += f" ({self.year})"
return sanitize_filename(name, " ")
# Service (use track source if available)
if show_service:
source_name = None
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and hasattr(first_track, "source") and first_track.source:
source_name = first_track.source
name += f" {source_name or self.service.__name__}"
# 'WEB-DL'
name += " WEB-DL"
# Audio Codec + Channels (+ feature)
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or audio_track.joc:
name += " Atmos"
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name, " ")
formatter = TemplateFormatter(config.output_template["songs"])
context = self._build_template_context(media_info, show_service)
return formatter.format(context)
class Album(SortedKeyList, ABC):
@@ -6,6 +6,8 @@ from typing import Any, Optional, Union
from langcodes import Language
from pymediainfo import MediaInfo
from envied.core.config import config
from envied.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
from envied.core.tracks import Tracks
@@ -51,6 +53,127 @@ class Title:
def __eq__(self, other: Title) -> bool:
return self.id == other.id
def _build_base_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict:
"""Build base template context dictionary from MediaInfo.
Extracts video, audio, HDR, HFR, and multi-language information shared
across all title types. Subclasses should call this and extend the
returned dict with their specific fields (e.g., season/episode).
"""
primary_video_track = next(iter(media_info.video_tracks), None)
primary_audio_track = next(iter(media_info.audio_tracks), None)
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
context: dict[str, Any] = {
"source": self.service.__name__ if show_service else "",
"tag": config.tag or "",
"repack": "REPACK" if getattr(config, "repack", False) else "",
"quality": "",
"resolution": "",
"audio": "",
"audio_channels": "",
"audio_full": "",
"atmos": "",
"dual": "",
"multi": "",
"video": "",
"hdr": "",
"hfr": "",
"edition": "",
"lang_tag": "",
}
if self.tracks:
first_track = next(iter(self.tracks), None)
if first_track and first_track.edition:
context["edition"] = " ".join(first_track.edition)
if primary_video_track:
width = getattr(primary_video_track, "width", primary_video_track.height)
resolution = min(width, primary_video_track.height)
try:
dar = getattr(primary_video_track, "other_display_aspect_ratio", None) or []
if dar and dar[0]:
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
ratio = aspect_ratio[0] / aspect_ratio[1]
if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4):
resolution = int(max(width, primary_video_track.height) * (9 / 16))
except Exception:
pass
scan_suffix = "i" if str(getattr(primary_video_track, "scan_type", "")).lower() == "interlaced" else "p"
context.update(
{
"quality": f"{resolution}{scan_suffix}",
"resolution": str(resolution),
"video": VIDEO_CODEC_MAP.get(primary_video_track.format, 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
if hdr_format:
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
context["hdr"] = "DV"
base_layer = DYNAMIC_RANGE_MAP.get(hdr_format)
if base_layer and base_layer != "DV":
context["hdr"] += f".{base_layer}"
elif (primary_video_track.hdr_format or "").startswith("HDR Vivid"):
context["hdr"] = "HDR"
else:
context["hdr"] = DYNAMIC_RANGE_MAP.get(hdr_format, "")
elif trc and "HLG" in trc:
context["hdr"] = "HLG"
else:
context["hdr"] = ""
frame_rate = float(primary_video_track.frame_rate) if primary_video_track.frame_rate else 0.0
context["hfr"] = "HFR" if frame_rate > 30 else ""
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
context.update(
{
"audio": AUDIO_CODEC_MAP.get(codec, codec),
"audio_channels": f"{channels:.1f}",
"audio_full": f"{AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}",
"atmos": "Atmos" if ("JOC" in features or primary_audio_track.joc) else "",
}
)
if unique_audio_languages == 2:
context["dual"] = "DUAL"
context["multi"] = ""
elif unique_audio_languages > 2:
context["dual"] = ""
context["multi"] = "MULTi"
else:
context["dual"] = ""
context["multi"] = ""
lang_tag_rules = config.language_tags.get("rules") if config.language_tags else None
if lang_tag_rules and self.tracks:
from envied.core.utils.language_tags import evaluate_language_tag
audio_langs = [a.language for a in self.tracks.audio]
sub_langs = [s.language for s in self.tracks.subtitles]
context["lang_tag"] = evaluate_language_tag(lang_tag_rules, audio_langs, sub_langs)
return context
@abstractmethod
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
"""
@@ -2,6 +2,7 @@ from __future__ import annotations
import mimetypes
import os
import re
from pathlib import Path
from typing import Optional, Union
from urllib.parse import urlparse
@@ -56,7 +57,8 @@ class Attachment:
# Use provided name for the file if available
if name:
file_name = f"{name.replace(' ', '_')}{os.path.splitext(file_name)[1]}"
safe_name = re.sub(r'[<>:"/\\|?*]', "", name).replace(" ", "_")
file_name = f"{safe_name}{os.path.splitext(file_name)[1]}"
download_path = config.directories.temp / file_name
@@ -151,7 +151,7 @@ class Audio(Track):
),
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
self.get_track_name(),
self.edition,
", ".join(self.edition) if self.edition else None,
],
)
)
+438 -53
View File
@@ -1,6 +1,8 @@
import json
import logging
import os
import random
import re
import subprocess
import sys
from pathlib import Path
@@ -8,14 +10,16 @@ from pathlib import Path
from rich.padding import Padding
from rich.rule import Rule
from envied.core.binaries import FFMPEG, DoviTool, HDR10PlusTool
from envied.core.binaries import FFMPEG, DoviTool, FFProbe, HDR10PlusTool
from envied.core.config import config
from envied.core.console import console
from envied.core.utilities import get_debug_logger
class Hybrid:
def __init__(self, videos, source) -> None:
self.log = logging.getLogger("hybrid")
self.debug_logger = get_debug_logger()
"""
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks.
@@ -41,6 +45,19 @@ class Hybrid:
console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2)))
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_init",
message="Starting HDR10+DV hybrid processing",
context={
"source": source,
"resolution": self.resolution,
"video_count": len(videos),
"video_ranges": [str(v.range) for v in videos],
},
)
for video in self.videos:
if not video.path or not os.path.exists(video.path):
raise ValueError(f"Video track {video.id} was not downloaded before injection.")
@@ -50,18 +67,18 @@ class Hybrid:
has_hdr10 = any(video.range == Video.Range.HDR10 for video in self.videos)
has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos)
if not has_hdr10:
raise ValueError("No HDR10 track available for hybrid processing.")
if not has_hdr10 and not has_hdr10p:
raise ValueError("No HDR10 or HDR10+ track available for hybrid processing.")
# If we have HDR10+ but no DV, we can convert HDR10+ to DV
if not has_dv and has_hdr10p:
self.log.info("No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
console.status("No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
self.hdr10plus_to_dv = True
elif not has_dv:
raise ValueError("No DV track available and no HDR10+ to convert.")
if os.path.isfile(config.directories.temp / self.hevc_file):
self.log.info("Already Injected")
console.status("Already Injected")
return
for video in videos:
@@ -89,14 +106,34 @@ class Hybrid:
self.extract_rpu(dv_video)
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
self.rpu_file = "RPU_UNT.bin"
self.level_6()
# Mode 3 conversion already done during extraction when not untouched
elif os.path.isfile(config.directories.temp / "RPU.bin"):
# RPU already extracted with mode 3
pass
# Edit L6 with actual luminance values from RPU, then L5 active area
self.level_6()
base_video = next(
(v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)), None
)
if base_video and base_video.path:
self.level_5(base_video.path)
self.injecting()
if self.debug_logger:
self.debug_logger.log(
level="INFO",
operation="hybrid_complete",
message="Injection Completed",
context={
"hdr_type": self.hdr_type,
"resolution": self.resolution,
"hdr10plus_to_dv": self.hdr10plus_to_dv,
"rpu_file": self.rpu_file,
"output_file": self.hevc_file,
},
)
self.log.info("✓ Injection Completed")
if self.source == ("itunes" or "appletvplus"):
Path.unlink(config.directories.temp / "hdr10.mkv")
@@ -104,6 +141,10 @@ class Hybrid:
Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True)
Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True)
Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True)
Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True)
Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True)
Path.unlink(config.directories.temp / "L5.json", missing_ok=True)
Path.unlink(config.directories.temp / "L6.json", missing_ok=True)
def ffmpeg_simple(self, save_path, output):
"""Simple ffmpeg execution without progress tracking"""
@@ -121,20 +162,41 @@ class Hybrid:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return p.returncode
return p
def extract_stream(self, save_path, type_):
output = Path(config.directories.temp / f"{type_}.hevc")
with console.status(f"Extracting {type_} stream...", spinner="dots"):
returncode = self.ffmpeg_simple(save_path, output)
result = self.ffmpeg_simple(save_path, output)
if returncode:
if result.returncode:
output.unlink(missing_ok=True)
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_extract_stream",
message=f"Failed extracting {type_} stream",
context={
"type": type_,
"input": str(save_path),
"output": str(output),
"returncode": result.returncode,
"stderr": (result.stderr or b"").decode(errors="replace"),
"stdout": (result.stdout or b"").decode(errors="replace"),
},
)
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
self.log.info(f"Extracted {type_} stream")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_extract_stream",
message=f"Extracted {type_} stream",
context={"type": type_, "input": str(save_path), "output": str(output)},
success=True,
)
def extract_rpu(self, video, untouched=False):
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
@@ -161,58 +223,326 @@ class Hybrid:
stderr=subprocess.PIPE,
)
rpu_name = "RPU" if not untouched else "RPU_UNT"
if rpu_extraction.returncode:
Path.unlink(config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin")
Path.unlink(config.directories.temp / f"{rpu_name}.bin")
stderr_text = rpu_extraction.stderr.decode(errors="replace") if rpu_extraction.stderr else ""
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_extract_rpu",
message=f"Failed extracting{' untouched ' if untouched else ' '}RPU",
context={
"untouched": untouched,
"returncode": rpu_extraction.returncode,
"stderr": stderr_text,
"args": [str(a) for a in extraction_args],
},
)
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
self.extract_rpu(video, untouched=True)
elif b"Invalid PPS index" in rpu_extraction.stderr:
raise ValueError("Dolby Vision VideoTrack seems to be corrupt")
else:
raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
elif self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_extract_rpu",
message=f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream",
context={"untouched": untouched, "output": f"{rpu_name}.bin"},
success=True,
)
self.log.info(f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
def level_5(self, input_video):
"""Generate Level 5 active area metadata via crop detection on the HDR10 stream.
def level_6(self):
"""Edit RPU Level 6 values"""
with open(config.directories.temp / "L6.json", "w+") as level6_file:
level6 = {
"cm_version": "V29",
"length": 0,
"level6": {
"max_display_mastering_luminance": 1000,
"min_display_mastering_luminance": 1,
"max_content_light_level": 0,
"max_frame_average_light_level": 0,
},
}
This resolves mismatches where DV has no black bars but HDR10 does (or vice versa)
by telling the display the correct active area.
"""
if os.path.isfile(config.directories.temp / "RPU_L5.bin"):
return
json.dump(level6, level6_file, indent=3)
ffprobe_bin = str(FFProbe) if FFProbe else "ffprobe"
ffmpeg_bin = str(FFMPEG) if FFMPEG else "ffmpeg"
if not os.path.isfile(config.directories.temp / "RPU_L6.bin"):
with console.status("Editing RPU Level 6 values...", spinner="dots"):
level6 = subprocess.run(
# Get video duration for random sampling
with console.status("Detecting active area (crop detection)...", spinner="dots"):
result_duration = subprocess.run(
[ffprobe_bin, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(input_video)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result_duration.returncode != 0:
if self.debug_logger:
self.debug_logger.log(
level="WARNING",
operation="hybrid_level5",
message="Could not probe video duration",
context={"returncode": result_duration.returncode, "stderr": (result_duration.stderr or "")},
)
self.log.warning("Could not probe video duration, skipping L5 crop detection")
return
duration_info = json.loads(result_duration.stdout)
duration = float(duration_info["format"]["duration"])
# Get video resolution for proper border calculation
result_streams = subprocess.run(
[
ffprobe_bin,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"json",
str(input_video),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result_streams.returncode != 0:
if self.debug_logger:
self.debug_logger.log(
level="WARNING",
operation="hybrid_level5",
message="Could not probe video resolution",
context={"returncode": result_streams.returncode, "stderr": (result_streams.stderr or "")},
)
self.log.warning("Could not probe video resolution, skipping L5 crop detection")
return
stream_info = json.loads(result_streams.stdout)
original_width = int(stream_info["streams"][0]["width"])
original_height = int(stream_info["streams"][0]["height"])
# Sample 10 random timestamps and run cropdetect on each
random_times = sorted(random.uniform(0, duration) for _ in range(10))
crop_results = []
for t in random_times:
result_cropdetect = subprocess.run(
[
str(DoviTool),
"editor",
ffmpeg_bin,
"-y",
"-nostdin",
"-loglevel",
"info",
"-ss",
f"{t:.2f}",
"-i",
config.directories.temp / self.rpu_file,
"-j",
config.directories.temp / "L6.json",
"-o",
config.directories.temp / "RPU_L6.bin",
str(input_video),
"-vf",
"cropdetect=round=2",
"-vframes",
"10",
"-f",
"null",
"-",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if level6.returncode:
Path.unlink(config.directories.temp / "RPU_L6.bin")
raise ValueError("Failed editing RPU Level 6 values")
# cropdetect outputs crop=w:h:x:y
crop_match = re.search(
r"crop=(\d+):(\d+):(\d+):(\d+)",
(result_cropdetect.stdout or "") + (result_cropdetect.stderr or ""),
)
if crop_match:
w, h = int(crop_match.group(1)), int(crop_match.group(2))
x, y = int(crop_match.group(3)), int(crop_match.group(4))
# Calculate actual border sizes from crop geometry
left = x
top = y
right = original_width - w - x
bottom = original_height - h - y
crop_results.append((left, top, right, bottom))
self.log.info("Edited RPU Level 6 values")
if not crop_results:
if self.debug_logger:
self.debug_logger.log(
level="WARNING",
operation="hybrid_level5",
message="No crop data detected, skipping L5",
context={"samples": len(random_times)},
)
self.log.warning("No crop data detected, skipping L5")
return
# Update rpu_file to use the edited version
self.rpu_file = "RPU_L6.bin"
# Find the most common crop values
crop_counts = {}
for crop in crop_results:
crop_counts[crop] = crop_counts.get(crop, 0) + 1
most_common = max(crop_counts, key=crop_counts.get)
left, top, right, bottom = most_common
# If all borders are 0 there's nothing to correct
if left == 0 and top == 0 and right == 0 and bottom == 0:
return
l5_json = {
"active_area": {
"crop": False,
"presets": [{"id": 0, "left": left, "right": right, "top": top, "bottom": bottom}],
"edits": {"all": 0},
}
}
l5_path = config.directories.temp / "L5.json"
with open(l5_path, "w") as f:
json.dump(l5_json, f, indent=4)
with console.status("Editing RPU Level 5 active area...", spinner="dots"):
result = subprocess.run(
[
str(DoviTool),
"editor",
"-i",
str(config.directories.temp / self.rpu_file),
"-j",
str(l5_path),
"-o",
str(config.directories.temp / "RPU_L5.bin"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_level5",
message="Failed editing RPU Level 5 values",
context={"returncode": result.returncode, "stderr": (result.stderr or b"").decode(errors="replace")},
)
Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True)
raise ValueError("Failed editing RPU Level 5 values")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_level5",
message="Edited RPU Level 5 active area",
context={"crop": {"left": left, "right": right, "top": top, "bottom": bottom}, "samples": len(crop_results)},
success=True,
)
self.rpu_file = "RPU_L5.bin"
def level_6(self):
"""Edit RPU Level 6 values using actual luminance data from the RPU."""
if os.path.isfile(config.directories.temp / "RPU_L6.bin"):
return
with console.status("Reading RPU luminance metadata...", spinner="dots"):
result = subprocess.run(
[str(DoviTool), "info", "-i", str(config.directories.temp / self.rpu_file), "-s"],
capture_output=True,
text=True,
)
if result.returncode != 0:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_level6",
message="Failed reading RPU metadata for Level 6 values",
context={"returncode": result.returncode, "stderr": (result.stderr or "")},
)
raise ValueError("Failed reading RPU metadata for Level 6 values")
max_cll = None
max_fall = None
max_mdl = None
min_mdl = None
for line in result.stdout.splitlines():
if "RPU content light level (L1):" in line:
parts = line.split("MaxCLL:")[1].split(",")
max_cll = int(float(parts[0].strip().split()[0]))
if len(parts) > 1 and "MaxFALL:" in parts[1]:
max_fall = int(float(parts[1].split("MaxFALL:")[1].strip().split()[0]))
elif "RPU mastering display:" in line:
mastering = line.split(":", 1)[1].strip()
min_lum, max_lum = mastering.split("/")[0], mastering.split("/")[1].split(" ")[0]
min_mdl = int(float(min_lum) * 10000)
max_mdl = int(float(max_lum))
if any(v is None for v in (max_cll, max_fall, max_mdl, min_mdl)):
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_level6",
message="Could not extract Level 6 luminance data from RPU",
context={"max_cll": max_cll, "max_fall": max_fall, "max_mdl": max_mdl, "min_mdl": min_mdl},
)
raise ValueError("Could not extract Level 6 luminance data from RPU")
level6_data = {
"level6": {
"remove_cmv4": False,
"remove_mapping": False,
"max_display_mastering_luminance": max_mdl,
"min_display_mastering_luminance": min_mdl,
"max_content_light_level": max_cll,
"max_frame_average_light_level": max_fall,
}
}
l6_path = config.directories.temp / "L6.json"
with open(l6_path, "w") as f:
json.dump(level6_data, f, indent=4)
with console.status("Editing RPU Level 6 values...", spinner="dots"):
result = subprocess.run(
[
str(DoviTool),
"editor",
"-i",
str(config.directories.temp / self.rpu_file),
"-j",
str(l6_path),
"-o",
str(config.directories.temp / "RPU_L6.bin"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_level6",
message="Failed editing RPU Level 6 values",
context={"returncode": result.returncode, "stderr": (result.stderr or b"").decode(errors="replace")},
)
Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True)
raise ValueError("Failed editing RPU Level 6 values")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_level6",
message="Edited RPU Level 6 luminance values",
context={
"max_cll": max_cll,
"max_fall": max_fall,
"max_mdl": max_mdl,
"min_mdl": min_mdl,
},
success=True,
)
self.rpu_file = "RPU_L6.bin"
def injecting(self):
if os.path.isfile(config.directories.temp / self.hevc_file):
@@ -228,12 +558,6 @@ class Hybrid:
config.directories.temp / self.rpu_file,
]
# If we converted from HDR10+, optionally remove HDR10+ metadata during injection
# Default to removing HDR10+ metadata since we're converting to DV
if self.hdr10plus_to_dv:
inject_cmd.append("--drop-hdr10plus")
self.log.info(" - Removing HDR10+ metadata during injection")
inject_cmd.extend(["-o", config.directories.temp / self.hevc_file])
inject = subprocess.run(
@@ -243,10 +567,29 @@ class Hybrid:
)
if inject.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_inject_rpu",
message="Failed injecting Dolby Vision metadata into HDR10 stream",
context={
"returncode": inject.returncode,
"stderr": (inject.stderr or b"").decode(errors="replace"),
"stdout": (inject.stdout or b"").decode(errors="replace"),
"cmd": [str(a) for a in inject_cmd],
},
)
Path.unlink(config.directories.temp / self.hevc_file)
raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream")
self.log.info(f"Injected Dolby Vision metadata into {self.hdr_type} stream")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_inject_rpu",
message=f"Injected Dolby Vision metadata into {self.hdr_type} stream",
context={"hdr_type": self.hdr_type, "rpu_file": self.rpu_file, "output": self.hevc_file, "drop_hdr10plus": self.hdr10plus_to_dv},
success=True,
)
def extract_hdr10plus(self, _video):
"""Extract HDR10+ metadata from the video stream"""
@@ -271,13 +614,39 @@ class Hybrid:
)
if extraction.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_extract_hdr10plus",
message="Failed extracting HDR10+ metadata",
context={
"returncode": extraction.returncode,
"stderr": (extraction.stderr or b"").decode(errors="replace"),
"stdout": (extraction.stdout or b"").decode(errors="replace"),
},
)
raise ValueError("Failed extracting HDR10+ metadata")
# Check if the extracted file has content
if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0:
file_size = os.path.getsize(config.directories.temp / self.hdr10plus_file)
if file_size == 0:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_extract_hdr10plus",
message="No HDR10+ metadata found in the stream",
context={"file_size": 0},
)
raise ValueError("No HDR10+ metadata found in the stream")
self.log.info("Extracted HDR10+ metadata")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_extract_hdr10plus",
message="Extracted HDR10+ metadata",
context={"output": self.hdr10plus_file, "file_size": file_size},
success=True,
)
def convert_hdr10plus_to_dv(self):
"""Convert HDR10+ metadata to Dolby Vision RPU"""
@@ -317,10 +686,26 @@ class Hybrid:
)
if conversion.returncode:
if self.debug_logger:
self.debug_logger.log(
level="ERROR",
operation="hybrid_convert_hdr10plus",
message="Failed converting HDR10+ to Dolby Vision",
context={
"returncode": conversion.returncode,
"stderr": (conversion.stderr or b"").decode(errors="replace"),
"stdout": (conversion.stdout or b"").decode(errors="replace"),
},
)
raise ValueError("Failed converting HDR10+ to Dolby Vision")
self.log.info("Converted HDR10+ metadata to Dolby Vision")
self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8")
if self.debug_logger:
self.debug_logger.log(
level="DEBUG",
operation="hybrid_convert_hdr10plus",
message="Converted HDR10+ metadata to Dolby Vision Profile 8",
success=True,
)
# Clean up temporary files
Path.unlink(config.directories.temp / "extra.json")
+10 -21
View File
@@ -24,7 +24,7 @@ from envied.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
from envied.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
from envied.core.drm import DRM_T, PlayReady, Widevine
from envied.core.events import events
from envied.core.utilities import get_boxes, get_extension, try_ensure_utf8
from envied.core.utilities import get_boxes, try_ensure_utf8
from envied.core.utils.subprocess import ffprobe
@@ -66,8 +66,8 @@ class Track:
raise TypeError(f"Expected name to be a {str}, not {type(name)}")
if not isinstance(id_, (str, type(None))):
raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}")
if not isinstance(edition, (str, type(None))):
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
if not isinstance(edition, (str, list, type(None))):
raise TypeError(f"Expected edition to be a {str}, {list}, or None, not {type(edition)}")
if not isinstance(downloader, (Callable, type(None))):
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
if not isinstance(downloader_args, (dict, type(None))):
@@ -103,7 +103,7 @@ class Track:
self.needs_repack = needs_repack
self.name = name
self.drm = drm
self.edition: str = edition
self.edition: list[str] = [edition] if isinstance(edition, str) else (edition or [])
self.downloader = downloader
self.downloader_args = downloader_args
self.from_file = from_file
@@ -210,23 +210,12 @@ class Track:
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
if track_type == "Subtitle":
save_path = save_path.with_suffix(f".{self.codec.extension}")
# n_m3u8dl_re doesn't support directly downloading subtitles from URLs
# or when the subtitle has a direct file extension
if self.downloader.__name__ == "n_m3u8dl_re" and (
self.descriptor == self.Descriptor.URL
or get_extension(self.url)
in {
".srt",
".vtt",
".ttml",
".ssa",
".ass",
".stpp",
".wvtt",
".xml",
}
):
self.downloader = requests
if self.downloader.__name__ == "n_m3u8dl_re" and (
self.descriptor == self.Descriptor.URL
or track_type in ("Subtitle", "Attachment")
):
self.downloader = requests
if self.descriptor != self.Descriptor.URL:
save_dir = save_path.with_name(save_path.name + "_segments")
@@ -103,53 +103,78 @@ class Tracks:
tree = Tree("", hide_root=True)
for track_type in self.TRACK_ORDER_MAP:
tracks = list(x for x in all_tracks if isinstance(x, track_type))
if not tracks:
continue
num_tracks = len(tracks)
track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "")
tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}")
for track in tracks:
if add_progress and track_type not in (Chapter, Attachment):
progress = Progress(
SpinnerColumn(finished_text=""),
BarColumn(),
"",
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
"",
TextColumn("[progress.data.speed]{task.fields[downloaded]}"),
console=console,
speed_estimate_period=10,
if tracks:
num_tracks = len(tracks)
track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "")
tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}")
for track in tracks:
if add_progress and track_type not in (Chapter, Attachment):
progress = Progress(
SpinnerColumn(finished_text=""),
BarColumn(),
"",
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
"",
TextColumn("[progress.data.speed]{task.fields[downloaded]}"),
console=console,
speed_estimate_period=10,
)
task = progress.add_task("", downloaded="-")
state = {"total": 100.0}
def update_track_progress(
task_id: int = task,
_state: dict[str, float] = state,
_progress: Progress = progress,
**kwargs,
) -> None:
"""
Ensure terminal status states render as a fully completed bar.
Some downloaders can report completed slightly below total
before emitting the final "Downloaded" state.
"""
if "total" in kwargs and kwargs["total"] is not None:
_state["total"] = kwargs["total"]
downloaded_state = kwargs.get("downloaded")
if downloaded_state in {"Downloaded", "Decrypted", "[yellow]SKIPPED"}:
kwargs["completed"] = _state["total"]
_progress.update(task_id=task_id, **kwargs)
progress_callables.append(update_track_progress)
track_table = Table.grid()
track_table.add_row(str(track)[6:], style="text2")
track_table.add_row(progress)
tracks_tree.add(track_table)
else:
tracks_tree.add(str(track)[6:], style="text2")
# Show Closed Captions right after Subtitles (even if no subtitle tracks exist)
if track_type is Subtitle:
seen_cc: set[str] = set()
unique_cc: list[str] = []
for video in (x for x in all_tracks if isinstance(x, Video)):
for cc in getattr(video, "closed_captions", []):
lang = cc.get("language", "und")
name = cc.get("name", "")
instream_id = cc.get("instream_id", "")
key = f"{lang}|{instream_id}"
if key in seen_cc:
continue
seen_cc.add(key)
parts = [f"[CC] | {lang}"]
if name:
parts.append(name)
if instream_id:
parts.append(instream_id)
unique_cc.append(" | ".join(parts))
if unique_cc:
cc_tree = tree.add(
f"[repr.number]{len(unique_cc)}[/] Closed Caption{'s' if len(unique_cc) != 1 else ''}"
)
task = progress.add_task("", downloaded="-")
state = {"total": 100.0}
def update_track_progress(
task_id: int = task,
_state: dict[str, float] = state,
_progress: Progress = progress,
**kwargs,
) -> None:
"""
Ensure terminal status states render as a fully completed bar.
Some downloaders can report completed slightly below total
before emitting the final "Downloaded" state.
"""
if "total" in kwargs and kwargs["total"] is not None:
_state["total"] = kwargs["total"]
downloaded_state = kwargs.get("downloaded")
if downloaded_state in {"Downloaded", "Decrypted", "[yellow]SKIPPED"}:
kwargs["completed"] = _state["total"]
_progress.update(task_id=task_id, **kwargs)
progress_callables.append(update_track_progress)
track_table = Table.grid()
track_table.add_row(str(track)[6:], style="text2")
track_table.add_row(progress)
tracks_tree.add(track_table)
else:
tracks_tree.add(str(track)[6:], style="text2")
for cc_str in unique_cc:
cc_tree.add(cc_str, style="text2")
return tree, progress_callables
@@ -278,23 +303,30 @@ class Tracks:
self.subtitles = list(filter(x, self.subtitles))
def select_hybrid(self, tracks, quality):
hdr10_tracks = [
v
for v in tracks
if v.range == Video.Range.HDR10 and (v.height in quality or int(v.width * 9 / 16) in quality)
]
hdr10 = []
# Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata)
base_ranges = (Video.Range.HDR10P, Video.Range.HDR10)
base_tracks = []
for range_type in base_ranges:
base_tracks = [
v
for v in tracks
if v.range == range_type and (v.height in quality or int(v.width * 9 / 16) in quality)
]
if base_tracks:
break
base_selected = []
for res in quality:
candidates = [v for v in hdr10_tracks if v.height == res or int(v.width * 9 / 16) == res]
candidates = [v for v in base_tracks if v.height == res or int(v.width * 9 / 16) == res]
if candidates:
best = max(candidates, key=lambda v: v.bitrate) # assumes .bitrate exists
hdr10.append(best)
best = max(candidates, key=lambda v: v.bitrate)
base_selected.append(best)
dv_tracks = [v for v in tracks if v.range == Video.Range.DV]
lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None
def select(x):
if x in hdr10:
if x in base_selected:
return True
if lowest_dv and x is lowest_dv:
return True
+22 -15
View File
@@ -200,6 +200,7 @@ class Video(Track):
height: Optional[int] = None,
fps: Optional[Union[str, int, float]] = None,
scan_type: Optional[Video.ScanType] = None,
closed_captions: Optional[list[dict[str, Any]]] = None,
**kwargs: Any,
) -> None:
"""
@@ -264,6 +265,7 @@ class Video(Track):
raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e))
self.scan_type = scan_type
self.closed_captions: list[dict[str, Any]] = closed_captions or []
self.needs_duration_fix = False
def __str__(self) -> str:
@@ -291,7 +293,7 @@ class Video(Track):
],
)
),
self.edition,
", ".join(self.edition) if self.edition else None,
],
)
)
@@ -346,22 +348,27 @@ class Video(Track):
if not binaries.CCExtractor:
raise EnvironmentError("ccextractor executable was not found.")
# ccextractor often fails in weird ways unless we repack
self.repackage()
out_path = Path(out_path)
try:
subprocess.run(
[binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as e:
out_path.unlink(missing_ok=True)
if not e.returncode == 10: # No captions found
raise
def _run_ccextractor() -> bool:
try:
subprocess.run(
[binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as e:
out_path.unlink(missing_ok=True)
if e.returncode != 10: # 10 = No captions found
raise
return out_path.exists()
# Try on the original file first (preserves container-level CC data like c608 boxes),
# then fall back to repacked file (ccextractor can fail on some container formats).
if not _run_ccextractor():
self.repackage()
_run_ccextractor()
if out_path.exists():
cc_track = Subtitle(
+1 -1
View File
@@ -136,7 +136,7 @@ def sanitize_filename(filename: str, spacer: str = ".") -> str:
if spacer == ".":
filename = re.sub(r" - ", spacer, filename) # title separators to spacer (avoids .-. pattern)
filename = re.sub(r"[:; ]", spacer, filename) # structural chars to (spacer)
filename = re.sub(r"[\\*!?¿,'\"" "()<>|$#~]", "", filename) # not filename safe chars
filename = re.sub(r"[\\*!?¿,'\"" "<>|$#~]", "", filename) # not filename safe chars
filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s
return filename
@@ -0,0 +1,92 @@
from __future__ import annotations
import logging
from typing import Optional
from envied.core.providers._base import ExternalIds
log = logging.getLogger("ANIMEAPI")
PLATFORM_MAP: dict[str, str] = {
"mal": "myanimelist",
"anilist": "anilist",
"kitsu": "kitsu",
"tmdb": "themoviedb",
"trakt": "trakt",
"tvdb": "thetvdb",
}
def resolve_animeapi(value: str) -> tuple[Optional[str], ExternalIds]:
"""Resolve an anime database ID via AnimeAPI to a title and external IDs.
Accepts formats like 'mal:12345', 'anilist:98765', or just '12345' (defaults to MAL).
Returns (anime_title, ExternalIds) with any TMDB/IMDB/TVDB IDs found.
"""
import animeapi
platform_str, id_str = _parse_animeapi_value(value)
platform_enum = _get_platform(platform_str)
if platform_enum is None:
log.warning("Unknown AnimeAPI platform: %s (supported: %s)", platform_str, ", ".join(PLATFORM_MAP))
return None, ExternalIds()
log.info("Resolving AnimeAPI %s:%s", platform_str, id_str)
try:
with animeapi.AnimeAPI() as api:
relation = api.get_anime_relations(id_str, platform_enum)
except Exception as exc:
log.warning("AnimeAPI lookup failed for %s:%s: %s", platform_str, id_str, exc)
return None, ExternalIds()
title = getattr(relation, "title", None)
tmdb_id = getattr(relation, "themoviedb", None)
tmdb_type = getattr(relation, "themoviedb_type", None)
imdb_id = getattr(relation, "imdb", None)
tvdb_id = getattr(relation, "thetvdb", None)
tmdb_kind: Optional[str] = None
if tmdb_type is not None:
tmdb_kind = tmdb_type.value if hasattr(tmdb_type, "value") else str(tmdb_type).lower()
if tmdb_kind not in ("movie", "tv"):
tmdb_kind = "tv"
external_ids = ExternalIds(
tmdb_id=int(tmdb_id) if tmdb_id is not None else None,
tmdb_kind=tmdb_kind,
imdb_id=str(imdb_id) if imdb_id is not None else None,
tvdb_id=int(tvdb_id) if tvdb_id is not None else None,
)
log.info(
"AnimeAPI resolved: title=%r, tmdb=%s, imdb=%s, tvdb=%s",
title,
external_ids.tmdb_id,
external_ids.imdb_id,
external_ids.tvdb_id,
)
return title, external_ids
def _parse_animeapi_value(value: str) -> tuple[str, str]:
"""Parse 'platform:id' format. Defaults to 'mal' if no prefix."""
if ":" in value:
platform, _, id_str = value.partition(":")
return platform.lower().strip(), id_str.strip()
return "mal", value.strip()
def _get_platform(platform_str: str) -> object | None:
"""Map a platform string to an animeapi.Platform enum value."""
import animeapi
canonical = PLATFORM_MAP.get(platform_str)
if canonical is None:
return None
platform_name = canonical.upper()
return getattr(animeapi.Platform, platform_name, None)
@@ -44,6 +44,33 @@ class VideoCodecChoice(click.Choice):
self.fail(f"'{value}' is not a valid video codec", param, ctx)
class MultipleVideoCodecChoice(VideoCodecChoice):
"""
A multiple-value variant of VideoCodecChoice that accepts comma-separated codecs.
Accepts both enum names and values, e.g.: ``-v hevc,avc`` or ``-v H.264,H.265``
"""
name = "multiple_video_codec_choice"
def convert(
self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None
) -> list[Any]:
if not value:
return []
if isinstance(value, list):
values = value
elif isinstance(value, str):
values = value.split(",")
else:
self.fail(f"{value!r} is not a supported value.", param, ctx)
chosen_values: list[Any] = []
for v in values:
chosen_values.append(super().convert(v.strip(), param, ctx))
return chosen_values
class SubtitleCodecChoice(click.Choice):
"""
A custom Choice type for subtitle codecs that accepts both enum names, values, and common aliases.
@@ -0,0 +1,79 @@
"""Language tag rule engine for output filename templates."""
from __future__ import annotations
import logging
from typing import Any, Sequence
from langcodes import Language
from envied.core.utilities import is_close_match
log = logging.getLogger(__name__)
def evaluate_language_tag(
rules: list[dict[str, Any]],
audio_languages: Sequence[Language],
subtitle_languages: Sequence[Language],
) -> str:
"""Evaluate language tag rules against selected tracks.
Rules are evaluated in order; the first matching rule's tag is returned.
Returns empty string if no rules match.
Args:
rules: List of rule dicts from config, each with conditions and a ``tag``.
audio_languages: Languages of the selected audio tracks.
subtitle_languages: Languages of the selected subtitle tracks.
Returns:
The tag string from the first matching rule, or ``""`` if none match.
"""
for rule in rules:
tag = rule.get("tag")
if not tag:
log.warning("Language tag rule missing 'tag' field, skipping: %s", rule)
continue
if _rule_matches(rule, audio_languages, subtitle_languages):
log.debug("Language tag rule matched: %s -> %s", rule, tag)
return str(tag)
return ""
def _rule_matches(
rule: dict[str, Any],
audio_languages: Sequence[Language],
subtitle_languages: Sequence[Language],
) -> bool:
"""Check if all conditions in a rule are satisfied."""
has_condition = False
audio_lang = rule.get("audio")
if audio_lang is not None:
has_condition = True
if not is_close_match(audio_lang, list(audio_languages)):
return False
subs_contain = rule.get("subs_contain")
if subs_contain is not None:
has_condition = True
if not is_close_match(subs_contain, list(subtitle_languages)):
return False
subs_contain_all = rule.get("subs_contain_all")
if subs_contain_all is not None:
has_condition = True
if not isinstance(subs_contain_all, list):
subs_contain_all = [subs_contain_all]
for lang in subs_contain_all:
if not is_close_match(lang, list(subtitle_languages)):
return False
if not has_condition:
log.warning("Language tag rule has no conditions, skipping: %s", rule)
return False
return True
+272 -102
View File
@@ -1,21 +1,25 @@
import click
import sys
import click
from rich.console import Group
from rich.live import Live
from rich.padding import Padding
from rich.table import Table
from rich.text import Text
from envied.core.console import console
IS_WINDOWS = sys.platform == "win32"
if IS_WINDOWS:
import msvcrt
class Selector:
"""
A custom interactive selector class using the Rich library.
Allows for multi-selection of items with pagination.
Allows for multi-selection of items with pagination and collapsible headers.
"""
def __init__(
self,
options: list[str],
@@ -24,7 +28,7 @@ class Selector:
page_size: int = 8,
minimal_count: int = 0,
dependencies: dict[int, list[int]] = None,
prefixes: list[str] = None
collapse_on_start: bool = False,
):
"""
Initialize the Selector.
@@ -36,6 +40,7 @@ class Selector:
page_size: Number of items to show per page.
minimal_count: Minimum number of items that must be selected.
dependencies: Dictionary mapping parent index to list of child indices.
collapse_on_start: If True, child items are hidden initially.
"""
self.options = options
self.cursor_style = cursor_style
@@ -43,81 +48,164 @@ class Selector:
self.page_size = page_size
self.minimal_count = minimal_count
self.dependencies = dependencies or {}
# Parent-Child mapping for quick lookup
self.child_to_parent = {}
for parent, children in self.dependencies.items():
for child in children:
self.child_to_parent[child] = parent
self.cursor_index = 0
self.selected_indices = set()
self.scroll_offset = 0
# Tree view state
self.expanded_headers = set()
if not collapse_on_start:
# Expand all by default
self.expanded_headers.update(self.dependencies.keys())
def get_visible_indices(self) -> list[int]:
"""
Returns a sorted list of indices that should be currently visible.
A child is visible only if its parent is in self.expanded_headers.
"""
visible = []
for idx in range(len(self.options)):
# If it's a child, check if parent is expanded
if idx in self.child_to_parent:
parent = self.child_to_parent[idx]
if parent in self.expanded_headers:
visible.append(idx)
else:
# It's a header or independent item, always visible
visible.append(idx)
return visible
def get_renderable(self):
"""
Constructs and returns the renderable object (Table + Info) for the current state.
"""
visible_indices = self.get_visible_indices()
# Adjust scroll offset to ensure cursor is visible
if self.cursor_index not in visible_indices:
# Fallback if cursor got hidden (should be handled in move, but safety check)
self.cursor_index = visible_indices[0] if visible_indices else 0
try:
cursor_visual_pos = visible_indices.index(self.cursor_index)
except ValueError:
cursor_visual_pos = 0
self.cursor_index = visible_indices[0]
# Calculate logical page start/end based on VISIBLE items
start_idx = self.scroll_offset
end_idx = start_idx + self.page_size
# Dynamic scroll adjustment
if cursor_visual_pos < start_idx:
self.scroll_offset = cursor_visual_pos
elif cursor_visual_pos >= end_idx:
self.scroll_offset = cursor_visual_pos - self.page_size + 1
# Re-calc render range
render_indices = visible_indices[self.scroll_offset : self.scroll_offset + self.page_size]
table = Table(show_header=False, show_edge=False, box=None, pad_edge=False, padding=(0, 1, 0, 0))
table.add_column("Indicator", justify="right", no_wrap=True)
table.add_column("Option", overflow="ellipsis", no_wrap=True)
for i in range(self.page_size):
idx = self.scroll_offset + i
if idx < len(self.options):
option = self.options[idx]
is_cursor = (idx == self.cursor_index)
is_selected = (idx in self.selected_indices)
for idx in render_indices:
option = self.options[idx]
is_cursor = idx == self.cursor_index
is_selected = idx in self.selected_indices
symbol = "[X]" if is_selected else "[ ]"
style = self.cursor_style if is_cursor else self.text_style
indicator_text = Text(f"{symbol}", style=style)
symbol = "[X]" if is_selected else "[ ]"
style = self.cursor_style if is_cursor else self.text_style
indicator_text = Text(f"{symbol}", style=style)
content_text = Text.from_markup(option)
content_text.style = style
content_text = Text.from_markup(f"{option}")
content_text.style = style
table.add_row(indicator_text, content_text)
else:
table.add_row(Text(" "), Text(" "))
table.add_row(indicator_text, content_text)
total_pages = (len(self.options) + self.page_size - 1) // self.page_size
# Fill empty rows to maintain height
rows_rendered = len(render_indices)
for _ in range(self.page_size - rows_rendered):
table.add_row(Text(" "), Text(" "))
total_visible = len(visible_indices)
total_pages = (total_visible + self.page_size - 1) // self.page_size
if total_pages == 0:
total_pages = 1
current_page = (self.scroll_offset // self.page_size) + 1
info_text = Text(
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})",
style="gray"
)
if self.dependencies:
info_text = Text(
f"\n[Space]: Toggle [a]: All [e]: Fold/Unfold [E]: All Fold/Unfold\n[Enter]: Confirm [↑/↓]: Move [←/→]: Page (Page {current_page}/{total_pages})",
style="gray",
)
else:
info_text = Text(
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})",
style="gray",
)
return Padding(Group(table, info_text), (0, 5))
def move_cursor(self, delta: int):
"""
Moves the cursor up or down by the specified delta.
Updates the scroll offset if the cursor moves out of the current view.
Moves the cursor up or down through VISIBLE items only.
"""
self.cursor_index = (self.cursor_index + delta) % len(self.options)
new_page_idx = self.cursor_index // self.page_size
self.scroll_offset = new_page_idx * self.page_size
visible_indices = self.get_visible_indices()
if not visible_indices:
return
try:
current_visual_idx = visible_indices.index(self.cursor_index)
except ValueError:
current_visual_idx = 0
new_visual_idx = (current_visual_idx + delta) % len(visible_indices)
self.cursor_index = visible_indices[new_visual_idx]
def change_page(self, delta: int):
"""
Changes the current page view by the specified delta (previous/next page).
Also moves the cursor to the first item of the new page.
"""
visible_indices = self.get_visible_indices()
if not visible_indices:
return
total_visible = len(visible_indices)
# Calculate current logical page
current_page = self.scroll_offset // self.page_size
total_pages = (len(self.options) + self.page_size - 1) // self.page_size
total_pages = (total_visible + self.page_size - 1) // self.page_size
new_page = current_page + delta
if 0 <= new_page < total_pages:
self.scroll_offset = new_page * self.page_size
first_idx_of_page = self.scroll_offset
if first_idx_of_page < len(self.options):
self.cursor_index = first_idx_of_page
else:
self.cursor_index = len(self.options) - 1
# Move cursor to top of new page
try:
# Calculate what visual index corresponds to the start of the new page
new_visual_cursor = self.scroll_offset
if new_visual_cursor < len(visible_indices):
self.cursor_index = visible_indices[new_visual_cursor]
else:
self.cursor_index = visible_indices[-1]
except IndexError:
pass
def toggle_selection(self):
"""
Toggles the selection state of the item currently under the cursor.
Propagates selection to children if defined in dependencies.
"""
target_indices = {self.cursor_index}
if self.cursor_index in self.dependencies:
target_indices.update(self.dependencies[self.cursor_index])
@@ -128,10 +216,33 @@ class Selector:
else:
self.selected_indices.difference_update(target_indices)
def toggle_expand(self):
"""
Expands or collapses the current header.
"""
if self.cursor_index in self.dependencies:
if self.cursor_index in self.expanded_headers:
self.expanded_headers.remove(self.cursor_index)
else:
self.expanded_headers.add(self.cursor_index)
def toggle_expand_all(self):
"""
Toggles expansion state of ALL headers.
If all are expanded -> Collapse all.
Otherwise -> Expand all.
"""
if not self.dependencies:
return
all_headers = set(self.dependencies.keys())
if self.expanded_headers == all_headers:
self.expanded_headers.clear()
else:
self.expanded_headers = all_headers.copy()
def toggle_all(self):
"""
Toggles the selection of all items.
If all are selected, clears selection. Otherwise, selects all.
Toggles the selection of all items.
"""
if len(self.selected_indices) == len(self.options):
self.selected_indices.clear()
@@ -144,28 +255,49 @@ class Selector:
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
"""
key = msvcrt.getch()
if key == b'\x03' or key == b'\x1b':
return 'CANCEL'
if key == b'\xe0' or key == b'\x00':
# Ctrl+C (0x03) or ESC (0x1b)
if key == b"\x03" or key == b"\x1b":
return "CANCEL"
# Special keys prefix (Arrow keys, etc., send 0xe0 or 0x00 first)
if key == b"\xe0" or key == b"\x00":
try:
key = msvcrt.getch()
if key == b'H': return 'UP'
if key == b'P': return 'DOWN'
if key == b'K': return 'LEFT'
if key == b'M': return 'RIGHT'
except: pass
try: char = key.decode('utf-8', errors='ignore')
except: return None
if char in ('\r', '\n'): return 'ENTER'
if char == ' ': return 'SPACE'
if char in ('q', 'Q'): return 'QUIT'
if char in ('a', 'A'): return 'ALL'
if char in ('w', 'W', 'k', 'K'): return 'UP'
if char in ('s', 'S', 'j', 'J'): return 'DOWN'
if char in ('h', 'H'): return 'LEFT'
if char in ('d', 'D', 'l', 'L'): return 'RIGHT'
if key == b"H": # Arrow Up
return "UP"
if key == b"P": # Arrow Down
return "DOWN"
if key == b"K": # Arrow Left
return "LEFT"
if key == b"M": # Arrow Right
return "RIGHT"
except Exception:
pass
try:
char = key.decode("utf-8", errors="ignore")
except Exception:
return None
if char in ("\r", "\n"):
return "ENTER"
if char == " ":
return "SPACE"
if char in ("q", "Q"):
return "QUIT"
if char in ("a", "A"):
return "ALL"
if char == "e":
return "EXPAND"
if char == "E":
return "EXPAND_ALL"
if char in ("w", "W", "k", "K"):
return "UP"
if char in ("s", "S", "j", "J"):
return "DOWN"
if char in ("h", "H"):
return "LEFT"
if char in ("d", "D", "l", "L"):
return "RIGHT"
return None
def get_input_unix(self):
@@ -174,44 +306,65 @@ class Selector:
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
"""
char = click.getchar()
if char == '\x03':
return 'CANCEL'
# Ctrl+C
if char == "\x03":
return "CANCEL"
# ANSI Escape Sequences for Arrow Keys
mapping = {
'\x1b[A': 'UP',
'\x1b[B': 'DOWN',
'\x1b[C': 'RIGHT',
'\x1b[D': 'LEFT',
"\x1b[A": "UP", # Escape + [ + A
"\x1b[B": "DOWN", # Escape + [ + B
"\x1b[C": "RIGHT", # Escape + [ + C
"\x1b[D": "LEFT", # Escape + [ + D
}
if char in mapping:
return mapping[char]
if char == '\x1b':
# Handling manual Escape sequences
if char == "\x1b": # ESC
try:
next1 = click.getchar()
if next1 in ('[', 'O'):
if next1 in ("[", "O"): # Sequence indicators
next2 = click.getchar()
if next2 == 'A': return 'UP'
if next2 == 'B': return 'DOWN'
if next2 == 'C': return 'RIGHT'
if next2 == 'D': return 'LEFT'
return 'CANCEL'
except:
return 'CANCEL'
if next2 == "A": # Arrow Up
return "UP"
if next2 == "B": # Arrow Down
return "DOWN"
if next2 == "C": # Arrow Right
return "RIGHT"
if next2 == "D": # Arrow Left
return "LEFT"
return "CANCEL"
except Exception:
return "CANCEL"
if char in ('\r', '\n'): return 'ENTER'
if char == ' ': return 'SPACE'
if char in ('q', 'Q'): return 'QUIT'
if char in ('a', 'A'): return 'ALL'
if char in ('w', 'W', 'k', 'K'): return 'UP'
if char in ('s', 'S', 'j', 'J'): return 'DOWN'
if char in ('h', 'H'): return 'LEFT'
if char in ('d', 'D', 'l', 'L'): return 'RIGHT'
if char in ("\r", "\n"):
return "ENTER"
if char == " ":
return "SPACE"
if char in ("q", "Q"):
return "QUIT"
if char in ("a", "A"):
return "ALL"
if char == "e":
return "EXPAND"
if char == "E":
return "EXPAND_ALL"
if char in ("w", "W", "k", "K"):
return "UP"
if char in ("s", "S", "j", "J"):
return "DOWN"
if char in ("h", "H"):
return "LEFT"
if char in ("d", "D", "l", "L"):
return "RIGHT"
return None
def run(self) -> list[int]:
"""
Starts the main event loop for the selector.
Renders the UI and processes input until confirmed or cancelled.
Returns:
list[int]: A sorted list of selected indices.
"""
@@ -219,39 +372,55 @@ class Selector:
with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live:
while True:
live.update(self.get_renderable(), refresh=True)
if IS_WINDOWS: action = self.get_input_windows()
else: action = self.get_input_unix()
if action == 'UP': self.move_cursor(-1)
elif action == 'DOWN': self.move_cursor(1)
elif action == 'LEFT': self.change_page(-1)
elif action == 'RIGHT': self.change_page(1)
elif action == 'SPACE': self.toggle_selection()
elif action == 'ALL': self.toggle_all()
elif action in ('ENTER', 'QUIT'):
if IS_WINDOWS:
action = self.get_input_windows()
else:
action = self.get_input_unix()
if action == "UP":
self.move_cursor(-1)
elif action == "DOWN":
self.move_cursor(1)
elif action == "LEFT":
self.change_page(-1)
elif action == "RIGHT":
self.change_page(1)
elif action == "EXPAND":
self.toggle_expand()
elif action == "EXPAND_ALL":
self.toggle_expand_all()
elif action == "SPACE":
self.toggle_selection()
elif action == "ALL":
self.toggle_all()
elif action in ("ENTER", "QUIT"):
if len(self.selected_indices) >= self.minimal_count:
return sorted(list(self.selected_indices))
elif action == 'CANCEL': raise KeyboardInterrupt
elif action == "CANCEL":
raise KeyboardInterrupt
except KeyboardInterrupt:
return []
def select_multiple(
options: list[str],
minimal_count: int = 1,
page_size: int = 8,
return_indices: bool = True,
cursor_style: str = "pink",
**kwargs
collapse_on_start: bool = False,
**kwargs,
) -> list[int]:
"""
Drop-in replacement using custom Selector with global console.
Args:
options: List of options to display.
minimal_count: Minimum number of selections required.
page_size: Number of items per page.
return_indices: If True, returns indices; otherwise returns the option strings.
cursor_style: Style color for the cursor.
collapse_on_start: If True, child items are hidden initially.
"""
selector = Selector(
options=options,
@@ -259,11 +428,12 @@ def select_multiple(
text_style="text",
page_size=page_size,
minimal_count=minimal_count,
**kwargs
collapse_on_start=collapse_on_start,
**kwargs,
)
selected_indices = selector.run()
if return_indices:
return selected_indices
return [options[i] for i in selected_indices]
+59 -570
View File
@@ -1,488 +1,23 @@
from __future__ import annotations
import logging
import re
import subprocess
import tempfile
from difflib import SequenceMatcher
from pathlib import Path
from typing import Optional, Tuple
from typing import Optional
from xml.sax.saxutils import escape
import requests
from requests.adapters import HTTPAdapter, Retry
from envied.core import binaries
from envied.core.config import config
from envied.core.providers import (ExternalIds, MetadataResult, enrich_ids, fetch_external_ids, fuzzy_match,
get_available_providers, get_provider, search_metadata)
from envied.core.titles.episode import Episode
from envied.core.titles.movie import Movie
from envied.core.titles.title import Title
STRIP_RE = re.compile(r"[^a-z0-9]+", re.I)
YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$")
HEADERS = {"User-Agent": "unshackle-tags/1.0"}
log = logging.getLogger("TAGS")
def _get_session() -> requests.Session:
"""Create a requests session with retry logic for network failures."""
session = requests.Session()
session.headers.update(HEADERS)
retry = Retry(
total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "POST"]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def _api_key() -> Optional[str]:
return config.tmdb_api_key
def _simkl_client_id() -> Optional[str]:
return config.simkl_client_id
def _clean(s: str) -> str:
return STRIP_RE.sub("", s).lower()
def _strip_year(s: str) -> str:
return YEAR_RE.sub("", s).strip()
def fuzzy_match(a: str, b: str, threshold: float = 0.8) -> bool:
"""Return True if ``a`` and ``b`` are a close match."""
ratio = SequenceMatcher(None, _clean(a), _clean(b)).ratio()
return ratio >= threshold
def search_simkl(
title: str,
year: Optional[int],
kind: str,
title_cacher=None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> Tuple[Optional[dict], Optional[str], Optional[int]]:
"""Search Simkl API for show information by filename."""
if title_cacher and cache_title_id:
cached_simkl = title_cacher.get_cached_simkl(cache_title_id, cache_region, cache_account_hash)
if cached_simkl:
log.debug("Using cached Simkl data")
if cached_simkl.get("type") == "episode" and "show" in cached_simkl:
show_info = cached_simkl["show"]
show_title = show_info.get("title")
tmdb_id = show_info.get("ids", {}).get("tmdbtv")
if tmdb_id:
tmdb_id = int(tmdb_id)
return cached_simkl, show_title, tmdb_id
elif cached_simkl.get("type") == "movie" and "movie" in cached_simkl:
movie_info = cached_simkl["movie"]
movie_title = movie_info.get("title")
ids = movie_info.get("ids", {})
tmdb_id = ids.get("tmdb") or ids.get("moviedb")
if tmdb_id:
tmdb_id = int(tmdb_id)
return cached_simkl, movie_title, tmdb_id
log.debug("Searching Simkl for %r (%s, %s)", title, kind, year)
client_id = _simkl_client_id()
if not client_id:
log.debug("No SIMKL client ID configured; skipping SIMKL search")
return None, None, None
# Construct appropriate filename based on type
filename = f"{title}"
if year:
filename = f"{title} {year}"
if kind == "tv":
filename += " S01E01.mkv"
else: # movie
filename += " 2160p.mkv"
try:
session = _get_session()
headers = {"simkl-api-key": client_id}
resp = session.post("https://api.simkl.com/search/file", json={"file": filename}, headers=headers, timeout=30)
resp.raise_for_status()
data = resp.json()
log.debug("Simkl API response received")
# Handle case where SIMKL returns empty list (no results)
if isinstance(data, list):
log.debug("Simkl returned list (no matches) for %r", filename)
return None, None, None
# Handle TV show responses
if data.get("type") == "episode" and "show" in data:
show_info = data["show"]
show_title = show_info.get("title")
show_year = show_info.get("year")
# Verify title matches and year if provided
if not fuzzy_match(show_title, title):
log.debug("Simkl title mismatch: searched %r, got %r", title, show_title)
return None, None, None
if year and show_year and abs(year - show_year) > 1: # Allow 1 year difference
log.debug("Simkl year mismatch: searched %d, got %d", year, show_year)
return None, None, None
if title_cacher and cache_title_id:
try:
title_cacher.cache_simkl(cache_title_id, data, cache_region, cache_account_hash)
except Exception as exc:
log.debug("Failed to cache Simkl data: %s", exc)
tmdb_id = show_info.get("ids", {}).get("tmdbtv")
if tmdb_id:
tmdb_id = int(tmdb_id)
log.debug("Simkl -> %s (TMDB ID %s)", show_title, tmdb_id)
return data, show_title, tmdb_id
elif data.get("type") == "movie" and "movie" in data:
movie_info = data["movie"]
movie_title = movie_info.get("title")
movie_year = movie_info.get("year")
if not fuzzy_match(movie_title, title):
log.debug("Simkl title mismatch: searched %r, got %r", title, movie_title)
return None, None, None
if year and movie_year and abs(year - movie_year) > 1: # Allow 1 year difference
log.debug("Simkl year mismatch: searched %d, got %d", year, movie_year)
return None, None, None
if title_cacher and cache_title_id:
try:
title_cacher.cache_simkl(cache_title_id, data, cache_region, cache_account_hash)
except Exception as exc:
log.debug("Failed to cache Simkl data: %s", exc)
ids = movie_info.get("ids", {})
tmdb_id = ids.get("tmdb") or ids.get("moviedb")
if tmdb_id:
tmdb_id = int(tmdb_id)
log.debug("Simkl -> %s (TMDB ID %s)", movie_title, tmdb_id)
return data, movie_title, tmdb_id
except (requests.RequestException, ValueError, KeyError) as exc:
log.debug("Simkl search failed: %s", exc)
return None, None, None
def search_show_info(
title: str,
year: Optional[int],
kind: str,
title_cacher=None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> Tuple[Optional[int], Optional[str], Optional[str]]:
"""Search for show information, trying Simkl first, then TMDB fallback. Returns (tmdb_id, title, source)."""
simkl_data, simkl_title, simkl_tmdb_id = search_simkl(
title, year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
)
if simkl_data and simkl_title and fuzzy_match(simkl_title, title):
return simkl_tmdb_id, simkl_title, "simkl"
tmdb_id, tmdb_title = search_tmdb(title, year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash)
return tmdb_id, tmdb_title, "tmdb"
def _fetch_tmdb_detail(tmdb_id: int, kind: str) -> Optional[dict]:
"""Fetch full TMDB detail response for caching."""
api_key = _api_key()
if not api_key:
return None
try:
session = _get_session()
r = session.get(
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}",
params={"api_key": api_key},
timeout=30,
)
r.raise_for_status()
return r.json()
except requests.RequestException as exc:
log.debug("Failed to fetch TMDB detail: %s", exc)
return None
def _fetch_tmdb_external_ids(tmdb_id: int, kind: str) -> Optional[dict]:
"""Fetch full TMDB external_ids response for caching."""
api_key = _api_key()
if not api_key:
return None
try:
session = _get_session()
r = session.get(
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}/external_ids",
params={"api_key": api_key},
timeout=30,
)
r.raise_for_status()
return r.json()
except requests.RequestException as exc:
log.debug("Failed to fetch TMDB external IDs: %s", exc)
return None
def search_tmdb(
title: str,
year: Optional[int],
kind: str,
title_cacher=None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> Tuple[Optional[int], Optional[str]]:
if title_cacher and cache_title_id:
cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash)
if cached_tmdb and cached_tmdb.get("detail"):
detail = cached_tmdb["detail"]
tmdb_id = detail.get("id")
tmdb_title = detail.get("title") or detail.get("name")
log.debug("Using cached TMDB data: %r (ID %s)", tmdb_title, tmdb_id)
return tmdb_id, tmdb_title
api_key = _api_key()
if not api_key:
return None, None
search_title = _strip_year(title)
log.debug("Searching TMDB for %r (%s, %s)", search_title, kind, year)
params = {"api_key": api_key, "query": search_title}
if year is not None:
params["year" if kind == "movie" else "first_air_date_year"] = year
try:
session = _get_session()
r = session.get(
f"https://api.themoviedb.org/3/search/{kind}",
params=params,
timeout=30,
)
r.raise_for_status()
js = r.json()
results = js.get("results") or []
log.debug("TMDB returned %d results", len(results))
if not results:
return None, None
except requests.RequestException as exc:
log.warning("Failed to search TMDB for %s: %s", title, exc)
return None, None
best_ratio = 0.0
best_id: Optional[int] = None
best_title: Optional[str] = None
for result in results:
candidates = [
result.get("title"),
result.get("name"),
result.get("original_title"),
result.get("original_name"),
]
candidates = [c for c in candidates if c] # Filter out None/empty values
if not candidates:
continue
# Find the best matching candidate from all available titles
for candidate in candidates:
ratio = SequenceMatcher(None, _clean(search_title), _clean(candidate)).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_id = result.get("id")
best_title = candidate
log.debug(
"Best candidate ratio %.2f for %r (ID %s)",
best_ratio,
best_title,
best_id,
)
if best_id is not None:
if title_cacher and cache_title_id:
try:
detail_response = _fetch_tmdb_detail(best_id, kind)
external_ids_response = _fetch_tmdb_external_ids(best_id, kind)
if detail_response and external_ids_response:
title_cacher.cache_tmdb(
cache_title_id, detail_response, external_ids_response, kind, cache_region, cache_account_hash
)
except Exception as exc:
log.debug("Failed to cache TMDB data: %s", exc)
return best_id, best_title
first = results[0]
return first.get("id"), first.get("title") or first.get("name")
def get_title(
tmdb_id: int,
kind: str,
title_cacher=None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> Optional[str]:
"""Fetch the name/title of a TMDB entry by ID."""
if title_cacher and cache_title_id:
cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash)
if cached_tmdb and cached_tmdb.get("detail"):
detail = cached_tmdb["detail"]
tmdb_title = detail.get("title") or detail.get("name")
log.debug("Using cached TMDB title: %r", tmdb_title)
return tmdb_title
api_key = _api_key()
if not api_key:
return None
try:
session = _get_session()
r = session.get(
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}",
params={"api_key": api_key},
timeout=30,
)
r.raise_for_status()
js = r.json()
if title_cacher and cache_title_id:
try:
external_ids_response = _fetch_tmdb_external_ids(tmdb_id, kind)
if external_ids_response:
title_cacher.cache_tmdb(
cache_title_id, js, external_ids_response, kind, cache_region, cache_account_hash
)
except Exception as exc:
log.debug("Failed to cache TMDB data: %s", exc)
return js.get("title") or js.get("name")
except requests.RequestException as exc:
log.debug("Failed to fetch TMDB title: %s", exc)
return None
def get_year(
tmdb_id: int,
kind: str,
title_cacher=None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> Optional[int]:
"""Fetch the release year of a TMDB entry by ID."""
if title_cacher and cache_title_id:
cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash)
if cached_tmdb and cached_tmdb.get("detail"):
detail = cached_tmdb["detail"]
date = detail.get("release_date") or detail.get("first_air_date")
if date and len(date) >= 4 and date[:4].isdigit():
year = int(date[:4])
log.debug("Using cached TMDB year: %d", year)
return year
api_key = _api_key()
if not api_key:
return None
try:
session = _get_session()
r = session.get(
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}",
params={"api_key": api_key},
timeout=30,
)
r.raise_for_status()
js = r.json()
if title_cacher and cache_title_id:
try:
external_ids_response = _fetch_tmdb_external_ids(tmdb_id, kind)
if external_ids_response:
title_cacher.cache_tmdb(
cache_title_id, js, external_ids_response, kind, cache_region, cache_account_hash
)
except Exception as exc:
log.debug("Failed to cache TMDB data: %s", exc)
date = js.get("release_date") or js.get("first_air_date")
if date and len(date) >= 4 and date[:4].isdigit():
return int(date[:4])
return None
except requests.RequestException as exc:
log.debug("Failed to fetch TMDB year: %s", exc)
return None
def external_ids(
tmdb_id: int,
kind: str,
title_cacher=None,
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
) -> dict:
if title_cacher and cache_title_id:
cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash)
if cached_tmdb and cached_tmdb.get("external_ids"):
log.debug("Using cached TMDB external IDs")
return cached_tmdb["external_ids"]
api_key = _api_key()
if not api_key:
return {}
url = f"https://api.themoviedb.org/3/{kind}/{tmdb_id}/external_ids"
log.debug("Fetching external IDs for %s %s", kind, tmdb_id)
try:
session = _get_session()
r = session.get(
url,
params={"api_key": api_key},
timeout=30,
)
r.raise_for_status()
js = r.json()
log.debug("External IDs response: %s", js)
if title_cacher and cache_title_id:
try:
detail_response = _fetch_tmdb_detail(tmdb_id, kind)
if detail_response:
title_cacher.cache_tmdb(cache_title_id, detail_response, js, kind, cache_region, cache_account_hash)
except Exception as exc:
log.debug("Failed to cache TMDB data: %s", exc)
return js
except requests.RequestException as exc:
log.warning("Failed to fetch external IDs for %s %s: %s", kind, tmdb_id, exc)
return {}
def apply_tags(path: Path, tags: dict[str, str]) -> None:
if not tags:
return
@@ -509,9 +44,26 @@ def apply_tags(path: Path, tags: dict[str, str]) -> None:
tmp_path.unlink(missing_ok=True)
def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) -> None:
def _build_tags_from_ids(ids: ExternalIds, kind: str) -> dict[str, str]:
"""Build standard MKV tags from external IDs."""
tags: dict[str, str] = {}
if ids.imdb_id:
tags["IMDB"] = ids.imdb_id
if ids.tmdb_id and ids.tmdb_kind:
tags["TMDB"] = f"{ids.tmdb_kind}/{ids.tmdb_id}"
if ids.tvdb_id:
prefix = "movies" if kind == "movie" else "series"
tags["TVDB2"] = f"{prefix}/{ids.tvdb_id}"
return tags
def tag_file(
path: Path,
title: Title,
tmdb_id: Optional[int] = None,
imdb_id: Optional[str] = None,
) -> None:
log.debug("Tagging file %s with title %r", path, title)
standard_tags: dict[str, str] = {}
custom_tags: dict[str, str] = {}
if config.tag and config.tag_group_name:
@@ -537,115 +89,52 @@ def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) ->
apply_tags(path, custom_tags)
return
if config.tag_imdb_tmdb:
# Check if we have any API keys available for metadata lookup
api_key = _api_key()
simkl_client = _simkl_client_id()
standard_tags: dict[str, str] = {}
if not api_key and not simkl_client:
log.debug("No TMDB API key or Simkl client ID configured; skipping IMDB/TMDB tag lookup")
if config.tag_imdb_tmdb:
providers = get_available_providers()
if not providers:
log.debug("No metadata providers available; skipping tag lookup")
apply_tags(path, custom_tags)
return
result: Optional[MetadataResult] = None
# Direct ID lookup path
if imdb_id:
imdbapi = get_provider("imdbapi")
if imdbapi:
result = imdbapi.get_by_id(imdb_id, kind)
if result:
result.external_ids.imdb_id = imdb_id
enrich_ids(result)
elif tmdb_id is not None:
tmdb = get_provider("tmdb")
if tmdb:
result = tmdb.get_by_id(tmdb_id, kind)
if result:
ext = tmdb.get_external_ids(tmdb_id, kind)
result.external_ids = ext
else:
# If tmdb_id is provided (via --tmdb), skip Simkl and use TMDB directly
if tmdb_id is not None:
log.debug("Using provided TMDB ID %s for tags", tmdb_id)
else:
# Try Simkl first for automatic lookup (only if client ID is available)
if simkl_client:
simkl_data, simkl_title, simkl_tmdb_id = search_simkl(name, year, kind)
# Search across providers in priority order
result = search_metadata(name, year, kind)
if simkl_data and simkl_title and fuzzy_match(simkl_title, name):
log.debug("Using Simkl data for tags")
if simkl_tmdb_id:
tmdb_id = simkl_tmdb_id
# If we got a TMDB ID from search but no full external IDs, fetch them
if result and result.external_ids.tmdb_id and not result.external_ids.imdb_id:
ext = fetch_external_ids(result.external_ids.tmdb_id, kind)
if ext.imdb_id:
result.external_ids.imdb_id = ext.imdb_id
if ext.tvdb_id:
result.external_ids.tvdb_id = ext.tvdb_id
# Handle TV show data from Simkl
if simkl_data.get("type") == "episode" and "show" in simkl_data:
show_ids = simkl_data.get("show", {}).get("ids", {})
if show_ids.get("imdb"):
standard_tags["IMDB"] = show_ids["imdb"]
if show_ids.get("tvdb"):
standard_tags["TVDB2"] = f"series/{show_ids['tvdb']}"
if show_ids.get("tmdbtv"):
standard_tags["TMDB"] = f"tv/{show_ids['tmdbtv']}"
if result and result.external_ids:
standard_tags = _build_tags_from_ids(result.external_ids, kind)
# Handle movie data from Simkl
elif simkl_data.get("type") == "movie" and "movie" in simkl_data:
movie_ids = simkl_data.get("movie", {}).get("ids", {})
if movie_ids.get("imdb"):
standard_tags["IMDB"] = movie_ids["imdb"]
if movie_ids.get("tvdb"):
standard_tags["TVDB2"] = f"movies/{movie_ids['tvdb']}"
if movie_ids.get("tmdb"):
standard_tags["TMDB"] = f"movie/{movie_ids['tmdb']}"
# Use TMDB API for additional metadata (either from provided ID or Simkl lookup)
if api_key:
tmdb_title: Optional[str] = None
if tmdb_id is None:
tmdb_id, tmdb_title = search_tmdb(name, year, kind)
log.debug("TMDB search result: %r (ID %s)", tmdb_title, tmdb_id)
if not tmdb_id or not tmdb_title or not fuzzy_match(tmdb_title, name):
log.debug("TMDB search did not match; skipping external ID lookup")
else:
prefix = "movie" if kind == "movie" else "tv"
standard_tags["TMDB"] = f"{prefix}/{tmdb_id}"
try:
ids = external_ids(tmdb_id, kind)
except requests.RequestException as exc:
log.debug("Failed to fetch external IDs: %s", exc)
ids = {}
else:
log.debug("External IDs found: %s", ids)
imdb_id = ids.get("imdb_id")
if imdb_id:
standard_tags["IMDB"] = imdb_id
tvdb_id = ids.get("tvdb_id")
if tvdb_id:
if kind == "movie":
standard_tags["TVDB2"] = f"movies/{tvdb_id}"
else:
standard_tags["TVDB2"] = f"series/{tvdb_id}"
elif tmdb_id is not None:
# tmdb_id was provided or found via Simkl
prefix = "movie" if kind == "movie" else "tv"
standard_tags["TMDB"] = f"{prefix}/{tmdb_id}"
try:
ids = external_ids(tmdb_id, kind)
except requests.RequestException as exc:
log.debug("Failed to fetch external IDs: %s", exc)
ids = {}
else:
log.debug("External IDs found: %s", ids)
imdb_id = ids.get("imdb_id")
if imdb_id:
standard_tags["IMDB"] = imdb_id
tvdb_id = ids.get("tvdb_id")
if tvdb_id:
if kind == "movie":
standard_tags["TVDB2"] = f"movies/{tvdb_id}"
else:
standard_tags["TVDB2"] = f"series/{tvdb_id}"
else:
log.debug("No TMDB API key configured; skipping TMDB external ID lookup")
merged_tags = {
**custom_tags,
**standard_tags,
}
apply_tags(path, merged_tags)
apply_tags(path, {**custom_tags, **standard_tags})
__all__ = [
"search_simkl",
"search_show_info",
"search_tmdb",
"get_title",
"get_year",
"external_ids",
"tag_file",
"apply_tags",
"fuzzy_match",
"tag_file",
]
@@ -0,0 +1,162 @@
from __future__ import annotations
import logging
import re
from typing import Any
from envied.core.utilities import sanitize_filename
log = logging.getLogger(__name__)
class TemplateFormatter:
"""
Template formatter for custom filename patterns.
Supports variable substitution and conditional variables.
Example: '{title}.{year}.{quality?}.{source}-{tag}'
"""
def __init__(self, template: str):
"""Initialize the template formatter.
Args:
template: Template string with variables in {variable} format
"""
self.template = template
self.variables = self._extract_variables()
def _extract_variables(self) -> list[str]:
"""Extract all variables from the template."""
pattern = r"\{([^}]+)\}"
matches = re.findall(pattern, self.template)
return [match.strip() for match in matches]
def format(self, context: dict[str, Any]) -> str:
"""Format the template with the provided context.
Args:
context: Dictionary containing variable values
Returns:
Formatted filename string
Raises:
ValueError: If required template variables are missing from context
"""
is_valid, missing_vars = self.validate(context)
if not is_valid:
error_msg = f"Missing required template variables: {', '.join(missing_vars)}"
log.error(error_msg)
raise ValueError(error_msg)
try:
result = self.template
for variable in self.variables:
placeholder = "{" + variable + "}"
is_conditional = variable.endswith("?")
if is_conditional:
var_name = variable[:-1]
value = context.get(var_name, "")
if value:
safe_value = str(value).strip()
result = result.replace(placeholder, safe_value)
else:
# Remove the placeholder and consume the adjacent separator on one side
# e.g. "{disc?}-{track}" → "{track}" when disc is empty
# e.g. "{title}.{edition?}.{quality}" → "{title}.{quality}" when edition is empty
def _remove_conditional(m: re.Match) -> str:
s = m.group(0)
has_left = s[0] in ".- "
has_right = s[-1] in ".- "
if has_left and has_right:
return s[0] # keep left separator
return ""
result = re.sub(
rf"[\.\s\-]?{re.escape(placeholder)}[\.\s\-]?",
_remove_conditional,
result,
count=1,
)
else:
value = context.get(variable, "")
if value is None:
log.warning(f"Template variable '{variable}' is None, using empty string")
value = ""
safe_value = str(value).strip()
result = result.replace(placeholder, safe_value)
# Clean up multiple consecutive dots/separators and other artifacts
result = re.sub(r"\.{2,}", ".", result) # Multiple dots -> single dot
result = re.sub(r"\s{2,}", " ", result) # Multiple spaces -> single space
result = re.sub(r"-{2,}", "-", result) # Multiple dashes -> single dash
result = re.sub(r"^[\.\s\-]+|[\.\s\-]+$", "", result) # Remove leading/trailing dots, spaces, dashes
result = re.sub(r"\.-", "-", result) # Remove dots before dashes (for dot-based templates)
result = re.sub(r"[\.\s]+\)", ")", result) # Remove dots/spaces before closing parentheses
result = re.sub(r"\(\s*\)", "", result) # Remove empty parentheses (empty conditional)
# Determine the appropriate separator based on template style
# Count separator characters between variables (between } and {)
between_vars = re.findall(r"\}([^{]*)\{", self.template)
separator_text = "".join(between_vars)
dot_count = separator_text.count(".")
space_count = separator_text.count(" ")
if space_count > dot_count:
result = sanitize_filename(result, spacer=" ")
else:
result = sanitize_filename(result, spacer=".")
if not result or result.isspace():
log.warning("Template formatting resulted in empty filename, using fallback")
return "untitled"
log.debug(f"Template formatted successfully: '{self.template}' -> '{result}'")
return result
except (KeyError, ValueError, re.error) as e:
log.error(f"Error formatting template '{self.template}': {e}")
fallback = f"error_formatting_{hash(self.template) % 10000}"
log.warning(f"Using fallback filename: {fallback}")
return fallback
def validate(self, context: dict[str, Any]) -> tuple[bool, list[str]]:
"""Validate that all required variables are present in context.
Args:
context: Dictionary containing variable values
Returns:
Tuple of (is_valid, missing_variables)
"""
missing = []
for variable in self.variables:
is_conditional = variable.endswith("?")
var_name = variable[:-1] if is_conditional else variable
if not is_conditional and var_name not in context:
missing.append(var_name)
return len(missing) == 0, missing
def get_required_variables(self) -> list[str]:
"""Get list of required (non-conditional) variables."""
required = []
for variable in self.variables:
if not variable.endswith("?"):
required.append(variable)
return required
def get_optional_variables(self) -> list[str]:
"""Get list of optional (conditional) variables."""
optional = []
for variable in self.variables:
if variable.endswith("?"):
optional.append(variable[:-1]) # Remove the ?
return optional
@@ -11,8 +11,66 @@ muxing:
# shakapackager or mp4decrypt
decryption: shakapackager
scene_naming: false
series_year: false
# Custom output templates for filenames
# Configure output_template in your envied.yaml to control filename format.
# If not configured, default scene-style templates are used and a warning is shown.
# Available variables: {title}, {year}, {season}, {episode}, {season_episode}, {episode_name},
# {quality}, {resolution}, {source}, {audio}, {audio_channels}, {audio_full},
# {video}, {hdr}, {hfr}, {atmos}, {dual}, {multi}, {tag}, {edition}, {repack},
# {lang_tag}
# Conditional variables (included only if present): Add ? suffix like {year?}, {episode_name?}, {hdr?}
# Customize the templates below:
#
# Example outputs:
# Scene movies: 'The.Matrix.1999.1080p.SERVICE.WEB-DL.DDP5.1.H.264-EXAMPLE'
# Scene movies (HDR): 'Dune.2021.2160p.SERVICE.WEB-DL.DDP5.1.HDR10.H.265-EXAMPLE'
# Scene movies (REPACK): 'Dune.2021.REPACK.2160p.SERVICE.WEB-DL.DDP5.1.H.265-EXAMPLE'
# Scene series: 'Breaking.Bad.2008.S01E01.Pilot.1080p.SERVICE.WEB-DL.DDP5.1.H.264-EXAMPLE'
# Plex movies: 'The Matrix (1999)'
# Plex series: 'Breaking Bad S01E01 Pilot'
output_template:
# Scene-style naming (dot-separated)
#movies: '{title}.{year}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
#series: '{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
#songs: '{track_number}.{title}.{repack?}.{edition?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}'
#
# Plex-friendly naming (space-separated, clean format)
movies: '{title} ({year})'
series: '{title} {season_episode} {episode_name?}'
songs: '{track_number}. {title}'
#
# Minimal naming (basic info only)
# movies: '{title}.{year}.{quality}'
# series: '{title}.{season_episode}.{episode_name?}'
#
# Custom scene-style with specific elements
# movies: '{title}.{year}.{quality}.{hdr?}.{source}.WEB-DL.{audio_full}.{video}-{tag}'
# series: '{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}-{tag}'
# Language-based tagging for output filenames
# Automatically adds language identifiers (e.g., DANiSH, NORDiC, DKsubs) based on
# audio and subtitle track languages. Rules are evaluated in order; first match wins.
# Use {lang_tag?} in your output_template to place the tag in the filename.
#
# Conditions (all conditions in a rule must match):
# audio: <lang> - any audio track matches this language
# subs_contain: <lang> - any subtitle matches this language
# subs_contain_all: [lang, ...] - subtitles include ALL listed languages
#
# language_tags:
# rules:
# - audio: da
# tag: DANiSH
# - audio: sv
# tag: SWEDiSH
# - audio: nb
# tag: NORWEGiAN
# - audio: en
# subs_contain_all: [da, sv, nb]
# tag: NORDiC
# - audio: en
# subs_contain: da
# tag: DKsubs
# Login credentials for each Service
credentials:
@@ -59,12 +117,12 @@ key_vaults:
#- type: SQLite
# name: Local vault
# path: key_store.db
- type: HTTPAPI
name: drmlab
- type: HTTP
name: DRMLab
host: http://api.drmlab.io/vault/
password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
api_mode: json
no_push: false
# Choose what software to use to download data
downloader: n_m3u8dl_re
+284 -116
View File
@@ -1,3 +1,4 @@
from hashlib import md5
import re
import time
import uuid
@@ -9,13 +10,16 @@ import jwt
from langcodes import Language
from envied.core.manifests import DASH
from envied.core.manifests.hls import HLS
from envied.core.search_result import SearchResult
from envied.core.service import Service
from envied.core.session import session
from envied.core.titles import Episode, Series
from envied.core.tracks import Attachment, Chapters, Tracks
from envied.core.tracks.audio import Audio
from envied.core.tracks.chapter import Chapter
from envied.core.tracks.subtitle import Subtitle
from envied.core.tracks.video import Video
class CR(Service):
@@ -23,18 +27,21 @@ class CR(Service):
Service code for Crunchyroll streaming service (https://www.crunchyroll.com).
\b
Version: 2.0.0
Author: sp4rk.y
Date: 2025-11-01
Version: 2.1.1
Author: sp4rk.y & Dex
Date: 2026-02-28
Authorization: Credentials
Robustness:
Widevine:
L3: 1080p, AAC2.0
PlayReady:
SL3000: 1080p, AAC2.0
SL2000: 1080p, AAC2.0
\b
Tips:
- Input should be complete URL or series ID
- Input should be complete URL or series ID or Episode ID
https://www.crunchyroll.com/series/GRMG8ZQZR/series-name OR GRMG8ZQZR
https://www.crunchyroll.com/en-US/watch/G2XU02X75/ryomen-sukuna OR G2XU02X75
- Supports multiple audio and subtitle languages
- Device ID is cached for consistent authentication across runs
@@ -44,30 +51,46 @@ class CR(Service):
- Manages concurrent stream limits automatically
"""
TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:series|watch)/)?(?P<id>[A-Z0-9]+)"
TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:[a-z]{2}(?:-[a-z]{2})?/)?(?:series|watch)/)?(?P<id>[A-Z0-9]+)"
LICENSE_LOCK = Lock()
MAX_CONCURRENT_STREAMS = 3
ACTIVE_STREAMS: list[tuple[str, str]] = []
@staticmethod
def get_session():
return session("okhttp4")
# Reverse map for audio_locale to match 2 chars languages in a_lang param
LANG_MAP: dict = {
"es-LA": "es-419",
"ar-ME": "ar-SA",
"de-DE": "de",
"en-US": "en",
"es-ES": "es",
"fr-FR": "fr",
"hi-IN": "hi",
"it-IT": "it",
"ja-JP": "ja",
"ko-KR": "kr",
"ru-RU": "ru",
"zh-CN": "zh",
"en-GB": "en"
}
@staticmethod
@click.command(name="CR", short_help="https://crunchyroll.com")
@click.argument("title", type=str, required=True)
@click.option("-hb", "--high-audio-bitrate", is_flag=True, default=False, help="Fetch audio tracks with highest bitrate (192kbps AAC)")
@click.pass_context
def cli(ctx, **kwargs) -> "CR":
return CR(ctx, **kwargs)
def __init__(self, ctx, title: str):
def __init__(self, ctx, title: str, high_audio_bitrate: bool):
super().__init__(ctx)
self.title = title
self.account_id: Optional[str] = None
self.access_token: Optional[str] = None
self.token_expiration: Optional[int] = None
self.anonymous_id = str(uuid.uuid4())
super().__init__(ctx)
self.audio_lang = ctx.parent.params.get("lang") or ctx.parent.params.get("a_lang")
self.high_audio_bitrate = high_audio_bitrate
device_cache_key = "cr_device_id"
cached_device = self.cache.get(device_cache_key)
@@ -87,6 +110,10 @@ class CR(Service):
self.session.headers.update(self.config.get("headers", {}))
self.session.headers["etp-anonymous-id"] = self.anonymous_id
@staticmethod
def get_session():
return session("okhttp4")
@property
def auth_header(self) -> dict:
"""Return authorization header dict."""
@@ -127,11 +154,7 @@ class CR(Service):
self.token_expiration = cached.data.get("token_expiration")
else:
if not credential:
class HardcodedCreds:
username = "akjrtx@gmail.com"
password = "Ariyan@45"
sha1 = "dummy_hash"
credential = HardcodedCreds()
raise ValueError("Username and password credential required for authentication")
response = self.session.post(
url=self.config["endpoints"]["token"],
@@ -203,9 +226,27 @@ class CR(Service):
params={"locale": self.config["params"]["locale"]},
).json()
if "error" in series_response:
raise ValueError(f"Series not found: {series_id}")
if "code" in series_response:
# Fallback to try with episode id
episode_response = self.session.get(
url=self.config["endpoints"]["episodes"].format(episode_id=series_id),
params={"locale": self.config["params"]["locale"]},
).json()
if "code" in episode_response:
raise ValueError(f"Series or episode not found: {series_id}")
# Extract series id from episodes response
series_id = episode_response["data"][0]["episode_metadata"]["series_id"]
series_response = self.session.get(
url=self.config["endpoints"]["series"].format(series_id=series_id),
params={"locale": self.config["params"]["locale"]},
).json()
if "code" in series_response:
raise ValueError(f"Series not found from episode metadata: {series_id}")
series_data = (
series_response.get("data", [{}])[0] if isinstance(series_response.get("data"), list) else series_response
)
@@ -297,14 +338,16 @@ class CR(Service):
def set_track_metadata(self, tracks: Tracks, episode_id: str, is_original: bool) -> None:
"""Set metadata for video and audio tracks."""
for video in tracks.videos:
video.needs_repack = True
video.data["episode_id"] = episode_id
video.is_original_lang = is_original
for audio in tracks.audio:
audio.data["episode_id"] = episode_id
audio.is_original_lang = is_original
for track in tracks:
if isinstance(track, Video):
track.needs_repack = True
if isinstance(track, (Audio,Video)):
track.data["episode_id"] = episode_id
track.is_original_lang = is_original
if is_original:
track.name = f"{track.name} [Original]"
def get_tracks(self, title: Episode) -> Tracks:
"""Fetch video, audio, and subtitle tracks for an episode."""
self.ensure_authenticated()
@@ -325,106 +368,169 @@ class CR(Service):
tracks = None
#endpoints_playback = ["playback"]
sdh_list = []
for idx, version in enumerate(versions):
audio_locale = version.get("audio_locale")
version_guid = version.get("guid")
is_original = version.get("original", False)
if not audio_locale:
# Verify mapped audio locale to match CR locales
mapped_locale = self.LANG_MAP.get(audio_locale, audio_locale)
if self.audio_lang and ("all" in self.audio_lang or "best" in self.audio_lang or ("orig" in self.audio_lang and is_original)):
pass
elif not audio_locale or (audio_locale not in self.audio_lang and mapped_locale not in self.audio_lang):
continue
request_episode_id = version_guid if version_guid else episode_id
if idx == 0 and not version_guid:
version_response = initial_response
version_token = version_response.get("token")
else:
if idx == 1 and not versions[0].get("guid"):
initial_token = initial_response.get("token")
if initial_token:
self.close_stream(episode_id, initial_token)
try:
version_response = self.get_playback_data(request_episode_id, track_stream=False)
except ValueError as e:
self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}")
continue
version_token = version_response.get("token")
hard_subs = version_response.get("hardSubs", {})
dash_url = None
if "none" in hard_subs:
dash_url = hard_subs["none"].get("url")
elif hard_subs:
first_key = list(hard_subs.keys())[0]
dash_url = hard_subs[first_key].get("url")
if not dash_url:
self.log.warning(f"No DASH manifest found for audio {audio_locale}, skipping")
if version_token:
self.close_stream(request_episode_id, version_token)
continue
#for endpoint in endpoints_playback:
try:
version_tracks = DASH.from_url(
url=dash_url,
session=self.session,
).to_tracks(language=audio_locale)
if tracks is None:
tracks = version_tracks
self.set_track_metadata(tracks, request_episode_id, is_original)
if idx == 0 and not version_guid: # and endpoint == "playback":
version_response = initial_response
version_token = version_response.get("token")
else:
self.set_track_metadata(version_tracks, request_episode_id, is_original)
for video in version_tracks.videos:
tracks.add(video)
for audio in version_tracks.audio:
tracks.add(audio)
if idx == 1 and not versions[0].get("guid"): # and endpoint == "playback":
initial_token = initial_response.get("token")
if initial_token:
self.close_stream(episode_id, initial_token)
except Exception as e:
self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}")
if version_token:
self.close_stream(request_episode_id, version_token)
continue
if is_original:
captions = version_response.get("captions", {})
subtitles_data = version_response.get("subtitles", {})
all_subs = {**captions, **subtitles_data}
for lang_code, sub_data in all_subs.items():
if lang_code == "none":
try:
version_response = self.get_playback_data(request_episode_id, track_stream=False)
except ValueError as e:
self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}")
continue
if isinstance(sub_data, dict) and "url" in sub_data:
version_token = version_response.get("token")
hard_subs = version_response.get("hardSubs", {})
dash_url = None
root_url = version_response.get("url")
if root_url and "/clean/" in root_url:
dash_url = root_url
elif "none" in hard_subs:
dash_url = hard_subs["none"].get("url")
elif hard_subs:
first_key = list(hard_subs.keys())[0]
dash_url = hard_subs[first_key].get("url")
if not dash_url:
self.log.warning(f"No DASH manifest found for audio {audio_locale}, skipping")
if version_token:
self.close_stream(request_episode_id, version_token)
continue
try:
version_tracks = (HLS if "m3u8" in dash_url else DASH).from_url(
url=dash_url,
session=self.session,
).to_tracks(language=audio_locale)
# Select high audio bitrate replacing 0 and 1 in mpd url
if self.high_audio_bitrate:
try:
lang = Language.get(lang_code)
except (ValueError, LookupError):
lang = Language.get("en")
version_tracks.audio.clear();
high_bitrate_tracks = (HLS if "m3u8" in dash_url else DASH).from_url(
url=dash_url.replace("/0/", "/1/"),
session=self.session,
).to_tracks(language=audio_locale)
version_tracks.audio = high_bitrate_tracks.audio
except Exception as e:
self.log.warning(f"Failed to fetch high bitrate audio for {audio_locale}: {e}")
if tracks is None:
tracks = version_tracks
self.set_track_metadata(tracks, request_episode_id, is_original)
else:
self.set_track_metadata(version_tracks, request_episode_id, is_original)
# Add videos
for video in version_tracks.videos:
if not any(v.id == video.id for v in tracks.videos):
tracks.add(video)
# Add audios non-existing in tracks or with higher bitrate
for audio in version_tracks.audio:
if audio.channels == "1.0":
audio.channels = "2.0" # change audio channels for correct channel
existing_audio = next((a for a in tracks.audio if a.language == audio.language), None)
if existing_audio is None or (hasattr(audio, 'bitrate') and hasattr(existing_audio, 'bitrate') and audio.bitrate > existing_audio.bitrate):
tracks.add(audio)
elif existing_audio is None:
tracks.add(audio)
subtitle_format = sub_data.get("format", "vtt").lower()
if subtitle_format == "ass" or subtitle_format == "ssa":
codec = Subtitle.Codec.SubStationAlphav4
else:
codec = Subtitle.Codec.WebVTT
except Exception as e:
self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}")
if version_token:
self.close_stream(request_episode_id, version_token)
continue
#if endpoint == "playback":
# Handling subtitles code
try:
lang_audio = [x for x in version_response.get("versions", []) if x["guid"] == version_guid][0]["audio_locale"]
except (IndexError, KeyError, TypeError):
lang_audio = title.language
captions = version_response.get("captions", {})
subtitles_data = version_response.get("subtitles", {})
if captions:
for subtitle in captions.values():
if subtitle["language"] == "none":
continue
lang = subtitle["language"]
subtitle_format = subtitle.get("format", "vtt").lower()
codec = Subtitle.Codec.SubStationAlphav4 if subtitle_format in ["ass", "ssa"] else Subtitle.Codec.WebVTT
sdh_list.append(lang)
tracks.add(
Subtitle(
id_=f"subtitle-{audio_locale}-{lang_code}",
url=sub_data["url"],
id_=md5(subtitle["url"].encode()).hexdigest()[0:6],
url=subtitle["url"],
codec=codec,
language=lang,
forced=False,
language=Language.get(lang),
sdh=True,
),
warn_only=True,
)
if subtitles_data:
for subtitle in subtitles_data.values():
if subtitle["language"] == "none":
continue
lang = subtitle["language"]
subtitle_format = subtitle.get("format", "vtt").lower()
codec = Subtitle.Codec.SubStationAlphav4 if subtitle_format in ["ass", "ssa"] else Subtitle.Codec.WebVTT
tracks.add(
Subtitle(
id_=md5(subtitle["url"].encode()).hexdigest()[0:6],
url=subtitle["url"],
codec=codec,
language=Language.get(lang),
forced=False if (str(lang_audio) == str(title.language)) and (lang not in sdh_list) else True,
sdh=False,
),
warn_only=True,
)
if version_token:
self.close_stream(request_episode_id, version_token)
if version_token:
self.close_stream(request_episode_id, version_token)
# continuar si no se pudo procesar el endpoint
except Exception as e:
self.log.warning(f"Error processing version {idx}: {e}")
continue
if versions and versions[0].get("guid"):
initial_token = initial_response.get("token")
if initial_token:
@@ -433,15 +539,6 @@ class CR(Service):
if tracks is None:
raise ValueError(f"Failed to fetch any tracks for episode: {episode_id}")
for track in tracks.audio + tracks.subtitles:
if track.language:
try:
lang_obj = Language.get(str(track.language))
base_lang = Language.get(lang_obj.language)
lang_display = base_lang.language_name()
track.name = lang_display
except (ValueError, LookupError):
pass
images = title.data.get("images", {})
thumbnails = images.get("thumbnail", [])
@@ -451,10 +548,65 @@ class CR(Service):
thumb_index = min(7, len(thumb_variants) - 1)
thumb = thumb_variants[thumb_index]
if isinstance(thumb, dict) and "source" in thumb:
thumbnail_name = f"{title.name or title.title} - S{title.season:02d}E{title.number:02d}"
raw_name = f"{title.name or title.title} - S{title.season:02d}E{title.number:02d}"
thumbnail_name = self.sanitize_filename(raw_name)
tracks.add(Attachment.from_url(url=thumb["source"], name=thumbnail_name))
return tracks
def get_playready_license(self, challenge: bytes, title: Episode, track) -> bytes:
"""
Get PlayReady license for decryption.
Creates a fresh playback session for each track, gets the license, then immediately
closes the stream. This prevents hitting the 3 concurrent stream limit.
CDN authorization is embedded in the manifest URLs, not tied to active sessions.
"""
self.ensure_authenticated()
track_episode_id = track.data.get("episode_id", title.id)
with self.LICENSE_LOCK:
playback_token = None
try:
playback_data = self.get_playback_data(track_episode_id, track_stream=True)
playback_token = playback_data.get("token")
if not playback_token:
raise ValueError(f"No playback token in response for {track_episode_id}")
track.data["playback_token"] = playback_token
license_response = self.session.post(
url=self.config["endpoints"]["license_playready"],
params={"specConform": "true"},
data=challenge,
headers={
**self.auth_header,
"content-type": "application/octet-stream",
"accept": "application/octet-stream",
"x-cr-content-id": track_episode_id,
"x-cr-video-token": playback_token,
},
)
if license_response.status_code != 200:
self.log.error(f"License request failed with status {license_response.status_code}")
self.log.error(f"Response: {license_response.text[:500]}")
self.close_stream(track_episode_id, playback_token)
raise ValueError(f"License request failed: {license_response.status_code}")
self.close_stream(track_episode_id, playback_token)
return license_response.content
except Exception:
if playback_token:
try:
self.close_stream(track_episode_id, playback_token)
except Exception:
pass
raise
def get_widevine_license(self, challenge: bytes, title: Episode, track) -> bytes:
"""
Get Widevine license for decryption.
@@ -738,9 +890,21 @@ class CR(Service):
response = self.session.get(
url=self.config["endpoints"]["playback"].format(episode_id=episode_id),
params={"queue": "false"},
).json()
)
if "error" in response:
# Handle 429 response status code before json parsing
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
self.log.warning(f"Rate limited (429). Waiting {retry_after}s before retry... (attempt {attempt + 1}/{max_retries + 1})")
if attempt < max_retries:
cleared = self.clear_all_sessions()
time.sleep(retry_after)
continue
raise ValueError(f"Rate limit exceeded for episode: {episode_id}")
response = response.json()
if "code" in response:
error_code = response.get("code", "")
error_msg = response.get("message", response.get("error", "Unknown error"))
@@ -775,3 +939,7 @@ class CR(Service):
if not match:
raise ValueError(f"Could not parse series ID from: {title_input}")
return match.group("id")
@staticmethod
def sanitize_filename(name):
return re.sub(r'[<>:"/\\|?*¿¡]', '', name).strip()
@@ -15,14 +15,17 @@ endpoints:
# Content Metadata
series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}"
seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons"
episodes: "https://www.crunchyroll.com/content/v2/cms/objects/{episode_id}"
season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes"
skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json"
# Playback
playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play"
#playback_download: "https://www.crunchyroll.com/playback/v2/{episode_id}/android/phone/download"
playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}"
playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming"
license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine"
license_playready: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/playReady"
# Discovery
search: "https://www.crunchyroll.com/content/v2/discover/search"
@@ -38,7 +41,7 @@ headers:
# Query parameters
params:
locale: "en-US"
locale: "es-419"
# Device parameters for authentication
device:
@@ -0,0 +1,43 @@
# DisneyPlus(디즈니플러스)
```
uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 2160 -v h.265 -r HDR10 DSNP entity-4d12671a-f0ad-4c3f-8526-09ae6772390b
```
## Information
- Authorization: Credentials, Web Token
- Security: UHD@L1/SL3000, FHD@L1/SL3000, HD@L3/SL2000
- Working Client Agent: AndroidTV
- Support Codec
- Video: H264, H265
- Audio: AAC, AC3, ATMOS, DTS:X(P2:IMAX)
- Range: SDR, HDR10, DV
## Support Args
- `-i`, `--imax`: Prefer IMAX Enhanced version if available.
- `-r`, `--remastered-ar`: Prefer Remastered Aspect Ratio if available.
- `-e`, `--extras`: Select a extras video if available.
- `-tu`, `--tier-unlimits`: Remove stream quality restrictions for a specific account.
## Tips
- Configure user settings within the `envied.yaml` file.
```
services:
DSNP:
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
# Automatically select a profile or language when commenting.
# Language setting is only available in languages supported by Disney+.
preferences:
profile: 0
# language: "ko"
```
- To enable the tier_unlimits command by default, add the following to `envied.yaml`.
```
dl:
...
DSNP:
tier_unlimits: True
```
File diff suppressed because it is too large Load Diff
@@ -9,17 +9,17 @@ certificate: |
Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw==
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
# Browser (windows, chrome) : /browser/v34.2/windows/chrome/prod.json
# Android Phone : /android/v15.0.0/google/handset/prod.json
# Android TV : /android/v15.0.0/google/tv/prod.json
# Amazon Fire TV : /android/v15.0.0/amazon/tv/prod.json
# Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json
# Android Phone : /android/v16.0.0/google/handset/prod.json
# Android TV : /android/v16.0.0/google/tv/prod.json
# Amazon Fire TV : /android/v16.0.0/amazon/tv/prod.json
endpoints:
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v15.0.0/google/tv/prod.json"
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v16.0.0/google/tv/prod.json"
## user_agent (okhttp/5.0.0-alpha.14) ##
# android-phone : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; phone)
# android-tv : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)
# android-phone : BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; phone)
# android-tv : BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; tv)
## api_key ##
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
@@ -30,11 +30,11 @@ endpoints:
# android : 624b805dafc5c73635b1a216
bamsdk:
sdk_version: "15.0.1"
application_version: "4.21.1+rc3-2026.01.06.0"
sdk_version: "16.0.0"
application_version: "26.0.2+rc1-2026.01.29.0"
explore_version: "v1.13"
client: "disney-svod-3d9324fc"
user_agent: "BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)"
user_agent: "BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; tv)"
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
yp_service_id: "624b805dafc5c73635b1a216"
@@ -46,9 +46,10 @@ device:
applicationRuntime: "android"
operatingSystem: "Android"
operatingSystemVersion: "16"
deviceLanguage: "ko" # Device language data independent of account language data
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
# Automatically select a profile when commenting.
# profile:
# index: 0
# Automatically select a profile or language when commenting.
# Language setting is only available in languages supported by Disney+.
# preferences:
# profile: 0
# language: "ko"
@@ -9,5 +9,6 @@ REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) { register
REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SET_APP_LANGUAGE = """mutation updateProfileAppLanguage($input: UpdateProfileAppLanguageInput!, $includeProfile: Boolean!) { updateProfileAppLanguage(updateProfileAppLanguage: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled isGeminiOnboarded profileLinked languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } linkedProfile { pinProtected } } }"""
SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { switchProfile(switchProfile: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}"""
UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}"""
@@ -11,7 +11,7 @@ directories:
```
See help text for each service:
`unshackle dl SERVICE --help`
`envied dl SERVICE --help`
## Notes:
Some versions of the dependencies work better than others. These are the recommended versions as of 25/11/11:
@@ -0,0 +1,472 @@
import base64
import hashlib
import json
import os
import re
import secrets
import time
import uuid
from http.cookiejar import CookieJar
from pathlib import Path
from typing import Optional, Union
from urllib.parse import parse_qs, urlparse
import click
from langcodes import Language
from envied.core.config import config
from envied.core.constants import AnyTrack
from envied.core.credential import Credential
from envied.core.manifests import DASH
from envied.core.service import Service
from envied.core.session import session as create_curl_session
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks
class RTDE(Service):
"""
Service for RTL+ Germany (https://plus.rtl.de)
\b
Author: Claude
Authorization: None (free) / Credential (premium)
Robustness: HD@L3
\b
"""
ALIASES = ("RTDE", "RTLPlus", "rtl+", "rtlplus")
GEOFENCE = ("de",)
TITLE_RE = r"https?://(?:www\.)?plus\.rtl\.de/video-tv/(?:shows|filme|serien)/.*?-(\d+)"
GRAPHQL_URL = "https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql"
PLAYOUT_URL = "https://stus.player.streamingtech.de/watch-playout-variants"
AUTH_URL = "https://auth.rtl.de/auth/realms/rtlplus/protocol/openid-connect/token"
GRAPHQL_HASHES = {
"Format": "d112638c0184ab5698af7b69532dfe2f12973f7af9cb137b9f70278130b1eafa",
"SeasonWithFormatAndEpisodes": "cc0fbbe17143f549a35efa6f8665ceb9b1cfae44b590f0b2381a9a304304c584",
"Episode": "87dbde15a0d269b11606f5ff458d555e98eb493bb4fb6ddc150d812d5e9a9cf8",
"EpisodeDetail": "2e5ef142c79f8620e8e93c8f21b31a463b16d89a557f7f5f0c4a7e063be96a8a",
}
@staticmethod
def get_session():
return create_curl_session()
@staticmethod
@click.command(name="RTLPlus", short_help="https://plus.rtl.de")
@click.argument("title", type=str)
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if content is a movie.")
@click.pass_context
def cli(ctx, **kwargs):
return RTDE(ctx, **kwargs)
def __init__(self, ctx, title: str, movie: bool = False):
self.title = title
self.movie = movie
super().__init__(ctx)
m = re.search(self.TITLE_RE, title)
if m:
self.format_id = m.group(1)
else:
# Assume raw ID was passed
self.format_id = title.rstrip("/").split("-")[-1]
if not self.format_id.isdigit():
self.format_id = title
self.device_id = str(uuid.uuid4())
self.auth_token = None
self.license_url = None
self.pr_license_url = None
self.cache_file = Path(config.directories.cache / "RTDE" / "token.json")
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
if credential:
self.auth_token = self._login(credential)
self.log.info("Authenticated with credentials")
else:
cached = self._load_cached_token()
if cached:
self.auth_token = cached
self.log.info("Using cached auth token")
else:
self.auth_token = self._get_anonymous_token()
self.log.info("Using anonymous token (free content only)")
self.session.headers.update({
"Authorization": f"Bearer {self.auth_token}",
"x-auth-token": self.auth_token,
"x-device-id": self.device_id,
"x-device-name": "Mac OS Chrome",
"x-device-type": "web",
"rtlplus-client-id": "rci:rtlplus:web",
"rtlplus-client-version": "2026.2.17.0",
})
def _load_cached_token(self) -> Optional[str]:
if not self.cache_file.exists():
return None
try:
data = json.loads(self.cache_file.read_text())
token = data.get("token")
if token and self._is_token_valid(token):
return token
except (json.JSONDecodeError, KeyError):
pass
return None
def _save_token(self, token: str):
self.cache_file.write_text(json.dumps({"token": token}, indent=2))
@staticmethod
def _is_token_valid(token: str) -> bool:
try:
payload = token.split(".")[1] + "=="
data = json.loads(base64.urlsafe_b64decode(payload))
return data.get("exp", 0) > time.time() + 60
except Exception:
return False
def _get_anonymous_token(self) -> str:
r = self.session.post(
self.AUTH_URL,
data={
"grant_type": "client_credentials",
"client_id": "anonymous-user",
"client_secret": "4bfeb73f-1c4a-4e9f-a7fa-96aa1ad3d94c",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
r.raise_for_status()
token = r.json()["access_token"]
self._save_token(token)
return token
def _login(self, credential: Credential) -> str:
# Authorization Code Flow with PKCE
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
state = str(uuid.uuid4())
nonce = str(uuid.uuid4())
# Step 1: GET authorization page
auth_endpoint = "https://auth.rtl.de/auth/realms/rtlplus/protocol/openid-connect/auth"
r = self.session.get(auth_endpoint, params={
"client_id": "rtlplus-web",
"redirect_uri": "https://plus.rtl.de/",
"state": state,
"response_mode": "query",
"response_type": "code",
"scope": "openid email profile",
"nonce": nonce,
"prompt": "login",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}, allow_redirects=True)
if not r.ok:
raise ConnectionError(f"Auth page failed: {r.status_code}")
# Parse form action URL and hidden fields
html = r.text
action_match = re.search(r'action="([^"]+)"', html)
if not action_match:
raise ConnectionError("Could not find login form action URL")
action_url = action_match.group(1).replace("&amp;", "&")
# Step 2: POST login credentials
r = self.session.post(
action_url,
data={
"username": credential.username,
"password": credential.password,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
allow_redirects=False,
)
# Follow redirects manually to capture the authorization code
while r.status_code in (301, 302, 303):
location = r.headers.get("Location", "")
parsed = urlparse(location)
params = parse_qs(parsed.query)
if "code" in params:
auth_code = params["code"][0]
break
r = self.session.get(location, allow_redirects=False)
else:
raise ConnectionError(f"Login failed: no authorization code in redirect (HTTP {r.status_code})")
# Step 3: Exchange code for tokens
r = self.session.post(
self.AUTH_URL,
data={
"code": auth_code,
"grant_type": "authorization_code",
"client_id": "rtlplus-web",
"redirect_uri": "https://plus.rtl.de/",
"code_verifier": code_verifier,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if not r.ok:
raise ConnectionError(f"Token exchange failed: {r.status_code} {r.text[:200]}")
token = r.json()["access_token"]
self._save_token(token)
return token
def _graphql(self, operation: str, variables: dict) -> dict:
r = self.session.get(
self.GRAPHQL_URL,
params={
"operationName": operation,
"variables": json.dumps(variables),
"extensions": json.dumps({
"persistedQuery": {
"version": 1,
"sha256Hash": self.GRAPHQL_HASHES[operation],
}
}),
},
headers={
"accept": "application/json",
"content-type": "application/json",
"referer": "https://plus.rtl.de/",
"origin": "https://plus.rtl.de",
},
)
r.raise_for_status()
data = r.json()
if "errors" in data:
raise ValueError(f"GraphQL error: {data['errors']}")
return data["data"]
def get_titles(self) -> Titles_T:
rrn = f"rrn:watch:videohub:format:{self.format_id}"
data = self._graphql("Format", {"id": rrn})
fmt = data["format"]
title_name = fmt["title"]
year = fmt.get("productionYear", "")
if self.movie:
# For movies, the format itself contains a single episode
episodes = fmt.get("episodes", [])
if episodes:
ep = episodes[0]
ep_id = ep["id"].split(":")[-1]
else:
ep_id = self.format_id
return Movies([
Movie(
id_=ep_id,
name=title_name,
year=year,
language="de",
service=self.__class__,
data={"rrn": f"rrn:watch:videohub:episode:{ep_id}", "format": fmt},
)
])
# Series: iterate all seasons
seasons = fmt.get("seasons", [])
all_episodes = []
for season in seasons:
season_id = season["id"]
season_ordinal = season.get("ordinal", 0)
season_title_override = season.get("titleOverride")
num_episodes = season.get("numberOfEpisodes", 0)
offset = 0
while offset < num_episodes:
result = self._graphql("SeasonWithFormatAndEpisodes", {
"seasonId": season_id,
"offset": offset,
"limit": 50,
})
eps = result.get("season", {}).get("episodes", [])
if not eps:
break
for ep in eps:
ep_id = ep["id"].split(":")[-1]
ep_number = ep.get("number", 0)
ep_title = ep.get("title", "")
all_episodes.append(Episode(
id_=ep_id,
service=self.__class__,
title=title_name,
year=year,
season=season_ordinal,
number=ep_number,
name=ep_title,
language="de",
data={
"rrn": ep["id"],
"tier": ep.get("tier", "FREE"),
"season_title": season_title_override,
},
))
offset += len(eps)
return Series(all_episodes)
def get_tracks(self, title: Title_T) -> Tracks:
rrn = title.data.get("rrn", f"rrn:watch:videohub:episode:{title.id}")
r = self.session.get(
f"{self.PLAYOUT_URL}/{rrn}",
params={"platform": "web"},
headers={
"accept": "application/json",
"content-type": "application/json",
"origin": "https://plus.rtl.de",
"referer": "https://plus.rtl.de/",
},
)
r.raise_for_status()
variants = r.json()
# Prefer HD DASH variant, fall back to SD
dash_variant = None
for name in ("dashhd", "dashsd"):
dash_variant = next((v for v in variants if v["name"] == name), None)
if dash_variant:
break
if not dash_variant:
raise ValueError("No DASH variant found in playout response")
# Get MPD URL (prefer MAIN source)
sources = dash_variant.get("sources", [])
mpd_url = None
subtitles_data = []
for src in sources:
if src.get("priority") == "MAIN" or not mpd_url:
mpd_url = src["url"]
subtitles_data = src.get("subtitles", [])
if not mpd_url:
raise ValueError("No MPD URL found in DASH variant")
self.log.debug(f"MPD URL: {mpd_url}")
# Store license URLs
for lic in dash_variant.get("licenses", []):
lic_type = lic.get("type", "")
lic_url = lic.get("uri", {}).get("href", "")
if lic_type == "WIDEVINE":
self.license_url = lic_url
elif lic_type == "PLAYREADY":
self.pr_license_url = lic_url
# Parse DASH manifest
tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(language=Language.get("de"))
# Add subtitles from playout response
for sub in (subtitles_data or []):
sub_url = sub.get("url", "")
lang_code = sub.get("languageCode", "deu")
output_set = sub.get("outputSet", "")
if output_set == "WEBVTT_TEXT":
codec = Subtitle.Codec.WebVTT
elif output_set == "TTML_XML":
codec = Subtitle.Codec.TimedTextMarkupLang
else:
continue
tracks.add(Subtitle(
id_=hashlib.md5(sub_url.encode()).hexdigest()[:6],
url=sub_url,
codec=codec,
language=lang_code,
is_original_lang=True,
))
return tracks
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Union[bytes, str]:
if not self.license_url:
raise ValueError("No Widevine license URL available")
r = self.session.post(
self.license_url,
data=challenge,
headers={
"x-auth-token": self.auth_token,
"x-device-id": self.device_id,
"x-device-name": "Mac OS Chrome",
"origin": "https://plus.rtl.de",
"referer": "https://plus.rtl.de/",
},
)
if not r.ok:
raise ConnectionError(f"License request failed: HTTP {r.status_code} {r.text[:200]}")
return r.content
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Union[bytes, str]:
if not self.pr_license_url:
raise ValueError("No PlayReady license URL available")
if isinstance(challenge, bytes):
challenge = challenge.decode("utf-8", errors="replace")
# Strip anything before the SOAP envelope
soap_idx = challenge.find("<soap:Envelope")
if soap_idx >= 0:
challenge = challenge[soap_idx:]
r = self.session.post(
self.pr_license_url,
data=challenge,
headers={
"Content-Type": "text/xml; charset=utf-8",
"x-auth-token": self.auth_token,
"x-device-id": self.device_id,
"x-device-name": "Mac OS Chrome",
"origin": "https://plus.rtl.de",
"referer": "https://plus.rtl.de/",
},
)
if not r.ok:
raise ConnectionError(f"PlayReady license request failed: HTTP {r.status_code} {r.text[:200]}")
return r.content
def get_widevine_service_certificate(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
if not self.license_url:
return None
r = self.session.post(
self.license_url,
data=challenge,
headers={
"x-auth-token": self.auth_token,
"x-device-id": self.device_id,
"x-device-name": "Mac OS Chrome",
"origin": "https://plus.rtl.de",
"referer": "https://plus.rtl.de/",
},
)
if not r.ok:
return None
return r.content
def get_chapters(self, title: Title_T) -> Chapters:
return Chapters()
@@ -0,0 +1,12 @@
headers:
user-agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
graphql_url: "https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql"
playout_url: "https://stus.player.streamingtech.de/watch-playout-variants"
auth_url: "https://auth.rtl.de/auth/realms/rtlplus"
graphql_hashes:
Format: "d112638c0184ab5698af7b69532dfe2f12973f7af9cb137b9f70278130b1eafa"
SeasonWithFormatAndEpisodes: "cc0fbbe17143f549a35efa6f8665ceb9b1cfae44b590f0b2381a9a304304c584"
Episode: "87dbde15a0d269b11606f5ff458d555e98eb493bb4fb6ddc150d812d5e9a9cf8"
EpisodeDetail: "2e5ef142c79f8620e8e93c8f21b31a463b16d89a557f7f5f0c4a7e063be96a8a"
@@ -0,0 +1,19 @@
# U-NEXT(유넥스트)
```
uv run unshackle dl --list -vl all -al orig -sl all -q 2160 -v h.265 -r HDR10 UNXT SID0087607
```
## Information
- Authorization: Credentials
- Security: UHD@L3/SL2000, FHD@L3/SL2000
- Working Client Agent: AndroidTV
- Support Codec
- Video: H264, H265
- Audio: AAC, AC3, ATMOS
- Range: SDR, HDR10, DV
## Support Args
- `-t`, `--type`: Prefer subtitle or dubbing version for content type if available.
@@ -0,0 +1,662 @@
from __future__ import annotations
import click
import re
import sys
import time
import uuid
from click import Context
from collections.abc import Generator
from datetime import datetime
from http.cookiejar import CookieJar
from langcodes import Language
from pathlib import Path
from requests import Request
from typing import Any, Optional, Union, List, Literal
from urllib.parse import urlparse, parse_qs, urlencode
from envied.core.cdm.detect import is_playready_cdm
from envied.core.constants import AnyTrack
from envied.core.credential import Credential
from envied.core.manifests import DASH, ISM
from envied.core.search_result import SearchResult
from envied.core.service import Service
from envied.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series
from envied.core.tracks import Chapters, Tracks, Video, Audio, Subtitle, Attachment, Chapter
from envied.core.utilities import get_ip_info
class UNXT(Service):
"""
Service code for U-NEXT Streaming Service (https://video.unext.jp).\n
Version: 26.03.01
Author: Made by CodeName393 with Special Thanks to narakama\n
Authorization: Credentials\n
Security: UHD@L3/SL2000, FHD@L3/SL2000
"""
ALIASES = ("UNXT", "UNEXT", "U-NEXT")
GEOFENCE = ("JP",)
TITLE_RE = (r"https?://(?:[\w-]+\.)?unext\.jp/.*?(?:/|td=)(?P<id>SID[0-9]+)",)
@staticmethod
@click.command(name="U-NEXT", short_help="https://video.unext.jp", help=__doc__)
@click.argument("title", type=str)
@click.option(
"-t",
"--type",
type=click.Choice(["sub", "dub"], case_sensitive=False),
default="sub",
help="Prefer subtitle or dubbing version for content type if available.",
)
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> UNXT:
return UNXT(ctx, **kwargs)
def __init__(self, ctx: Context, title: str, type: Literal["sub", "dub"]):
self.title = title
super().__init__(ctx)
self.title_id = self.title
for pattern in self.TITLE_RE:
match = re.match(pattern, self.title)
if match:
self.title_id = match.group("id")
break
self.dub_type = type
self.cdm = ctx.obj.cdm
self.device_id = None
self.account_tokens = {}
self.active_session = {}
self.playback_data = {}
self.log.info("Preparing...")
ip_info = get_ip_info(self.session)
country_key = None
possible_keys = ["countryCode", "country", "country_code", "country-code"]
for key in possible_keys:
if key in ip_info:
country_key = key
break
if country_key:
region = str(ip_info[country_key]).upper()
self.log.info(f" + IP Region: {region}")
else:
self.log.warning(f" - The region could not be determined from IP information: {ip_info}")
region = "US"
self.log.info(f" + IP Region: {region} (By Default)")
if region != "JP":
self.log.error(" - It is not currently available in the country.", exc_info=False)
sys.exit(1)
self.session.headers.update(
{
"User-Agent": self.config["device"]["user_agent"],
"Content-Type": "application/json; charset=utf-8",
"Accept-Encoding": "gzip",
}
)
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
self.credentials = credential
if not credential:
raise EnvironmentError("Service requires Credentials for Authentication.")
self.log.info("Logging into U-NEXT...")
self._login()
self.log.debug(self.active_session)
self.log.info(f" + Account ID: {self.active_session['cuid']}")
self.log.info(f" + Platform ID: {self.active_session['pfid']}")
self.log.info(f" + Subscribed: {self.active_session['contractStatusCode'] == 'ACTIVE'}")
def _login(self) -> None:
cache = self.cache.get(f"tokens_{self.credentials.sha1}")
if cache:
try:
self.log.info(" + Using cached tokens...")
self.account_tokens = cache.data.get("tokens", {})
self.device_id = cache.data.get("device_id")
required_keys = ["userToken", "userTokenExpire", "securityToken"]
if not self.account_tokens or not all(k in self.account_tokens for k in required_keys) or not self.device_id:
raise ValueError("Invalid token data in cache")
except (KeyError, ValueError, TypeError) as e:
self.log.warning(f" - Cached token data is invalid or corrupted ({e}). Getting new tokens...")
self._perform_full_login()
try:
self._refresh()
except Exception as e:
self.log.warning(f" - Failed to refresh token ({e}). Getting new tokens...")
self._perform_full_login()
else:
self.log.info(" + Getting new tokens...")
self._perform_full_login()
self.log.info(" + Fetching session data...")
self.active_session = self._get_account_info()
self.log.info("Session data setup successfully.")
def _perform_full_login(self) -> None:
if not self.device_id:
self.device_id = str(uuid.uuid4())
user_info = self._login_with_password(self.credentials.username, self.credentials.password)
token_data = {
"userToken": user_info["userToken"],
"userTokenExpire": user_info["userTokenExpire"],
"securityToken": user_info["securityToken"],
}
self._apply_new_tokens(token_data)
def _refresh(self) -> None:
cache = self.cache.get(f"tokens_{self.credentials.sha1}")
if not cache.expired:
self.log.debug(f" + Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}")
return
self.log.warning(" + Token expired. Refreshing...")
try:
user_info = self._refresh_token()
token_data = {
"userToken": user_info["userToken"],
"userTokenExpire": user_info["userTokenExpire"],
"securityToken": user_info["securityToken"],
}
self._apply_new_tokens(token_data)
except Exception as e:
self.log.error(f"Refresh failed: {e}.", exc_info=False)
raise e
def _apply_new_tokens(self, token_data: dict) -> None:
self.account_tokens = token_data
expire_time = token_data["userTokenExpire"]
valid_duration = max(0, expire_time - int(time.time()))
cache_data = {
"tokens": self.account_tokens,
"device_id": self.device_id,
}
cache = self.cache.get(f"tokens_{self.credentials.sha1}")
cache.set(cache_data, valid_duration - 60)
self.log.debug(f" + New Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}")
def search(self) -> Generator[SearchResult, None, None]:
data = self._get_search(self.title)
if not data["title_list"]:
return
for result in data["title_list"]:
year = result["production_year"]
if not year:
year = result["since_year"]
yield SearchResult(
id_=result["title_code"],
title=result["title_name"],
description=result["catchphrase"],
label=year,
url=f"https://video.unext.jp/title/{result['title_code']}",
)
def get_titles(self) -> Titles_T:
stage_data = self._get_stage_data(self.title_id)
content_type = stage_data["media_type_code"]
self.log.debug(f" + Content Type: {content_type.upper()}")
if content_type in ["MOVIE", "VIDEO", "OV"]:
year = stage_data["production_year"]
if not year:
year = stage_data["since_year"]
movie_data = self._get_movie_data(self.title_id)
return Movies(
[
Movie(
id_=movie_data["episode"]["episode_code"],
service=self.__class__,
name=stage_data["title_name"],
description=stage_data["story"],
year=year,
language=Language.get("en"),
data=movie_data["episode"],
)
]
)
elif content_type == "TV":
return Series(self._get_series(stage_data))
else:
self.log.error(f" - Unsupported content type: {content_type}", exc_info=False)
sys.exit(1)
def _get_series(self, stage_data: dict) -> Series:
title_relation = self._get_title_relation(self.title_id)
all_seasons = {}
special_keywords = ["メイキング", "making", "special", "特典", "映像"]
current_year = stage_data["production_year"]
if not current_year:
current_year = stage_data["since_year"]
title_name_main = stage_data["title_name"]
is_special_main = any(k in title_name_main.lower() for k in special_keywords)
raw_order_main = int(stage_data["series_in_order"])
if is_special_main:
order_main = 0
else:
order_main = raw_order_main if raw_order_main > 0 else 1
series_name = stage_data["series_name"]
if not series_name:
series_name = title_name_main
all_seasons[self.title_id] = {
"order": order_main,
"name": series_name,
"year": current_year,
}
if "relation" in title_relation:
for rel_group in title_relation["relation"]:
if rel_group["groupcode"] != stage_data["series_code"]:
continue
for item in rel_group["grouplist"]:
if item["media_type_code"] != "TV":
continue
item_year = item["production_year"]
if not item_year:
item_year = item["since_year"]
title_name_sub = item["title_name"]
is_special_sub = any(k in title_name_sub.lower() for k in special_keywords)
raw_order_sub = int(item["series_in_order"])
if is_special_sub:
order_sub = 0
else:
order_sub = raw_order_sub if raw_order_sub > 0 else 1
all_seasons[item["title_code"]] = {
"order": order_sub,
"name": item["series_name"],
"year": item_year,
}
sorted_season_ids = sorted(all_seasons.keys(), key=lambda x: all_seasons[x]["order"])
episodes: List[Episode] = []
regular_season_idx = 1
special_episode_idx = 1
for season_id in sorted_season_ids:
season_info = all_seasons[season_id]
if season_info["order"] == 0:
current_season_number = 0
else:
current_season_number = regular_season_idx
regular_season_idx += 1
episode_list_data = self._get_episode_list(season_id)
for idx, episode in enumerate(episode_list_data["items"], start=1):
if current_season_number == 0:
episode_number = special_episode_idx
special_episode_idx += 1
else:
episode_number = idx
episodes.append(
Episode(
id_=episode["episode_code"],
service=self.__class__,
title=season_info["name"],
season=current_season_number,
number=episode_number,
name=episode["episode_name"],
description=episode["introduction"],
year=season_info["year"],
language=Language.get("en"),
data=episode,
)
)
return episodes
def get_tracks(self, title: Title_T) -> Tracks:
self._refresh() # Safe Access
play_mode = "caption"
if title.data["has_dub"] and self.dub_type == "dub":
play_mode = "dub"
title.language = Language.get("ja")
fetched_ranges = set()
def _fetch_variant(title: Title_T, codec: Optional[Video.Codec], range_: Video.Range) -> Tracks:
if codec == Video.Codec.AVC and range_ in (Video.Range.DV, Video.Range.HDR10, Video.Range.HDR10P):
return Tracks()
nonlocal fetched_ranges
video_ranges = ["SDR"]
if range_ == Video.Range.DV:
video_ranges = ["VISION"]
elif range_ in (Video.Range.HDR10, Video.Range.HDR10P):
video_ranges = ["HDR10"]
range_key = video_ranges[0]
if range_key in fetched_ranges:
return Tracks()
fetched_ranges.add(range_key)
self.log.debug(f"Fetching {range_.name} manifest...")
return self._fetch_manifest_tracks(title, video_ranges, play_mode)
tracks = self._get_tracks_for_variants(title, _fetch_variant)
if thumb := title.data.get("thumbnail"):
url = f"https://{thumb['standard']}"
description = Path(url).stem
tracks.add(Attachment.from_url(url=url, name="thumbnail", description=description, session=self.session))
for track in tracks:
if isinstance(track, (Audio, Subtitle)):
track.name = track.language.display_name()
track.name += " [Original]" if track.is_original_lang else ""
for audio in tracks.audio:
if audio.codec == Audio.Codec.EC3 and audio.bitrate > 448_000:
audio.joc = 16 # U-NEXT Bug...
return tracks
def _fetch_manifest_tracks(self, title: Title_T, video_ranges: List[str], play_mode: str) -> Tracks:
self.playback_data[title.id] = self._get_playlist(title.id, video_ranges, play_mode)
if (res_code := self.playback_data[title.id]["result_status"]) != 200:
self.log.debug(f" - Get stream error({res_code})")
return Tracks()
movie_profile = self.playback_data[title.id]["url_info"][0]["movie_profile"]
target_drm_key = "playready" if is_playready_cdm(self.cdm) else "widevine"
priority_protocols = ["dash", "smooth"]
selected_protocol = next(
(proto for proto in priority_protocols if proto in movie_profile and target_drm_key in movie_profile[proto].get("license_url_list", {})),
None,
)
if not selected_protocol:
self.log.debug(f" - Unable to handle with invalid DRM type. {target_drm_key} info not found or license_url is empty in response.")
return Tracks()
self.playback_data[title.id]["protocol"] = selected_protocol
base_url = movie_profile[selected_protocol].get("playlist_url")
u = urlparse(base_url)
query = parse_qs(u.query)
query["play_token"] = self.playback_data[title.id]["play_token"]
new_query = urlencode(query, doseq=True)
manifest_url = u._replace(query=new_query).geturl()
self.log.debug(f" + Manifest URL: {manifest_url}")
if ".mpd/" in manifest_url:
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(title.language)
elif ".ism/manifest" in manifest_url:
tracks = ISM.from_url(url=manifest_url, session=self.session).to_tracks(title.language)
else:
self.log.debug(" - Manifest type cannot be handled.")
return Tracks()
for video in tracks.videos:
if video.codec == Video.Codec.HEVC and "HDR10" in video_ranges:
video.range = Video.Range.HDR10 # U-NEXT Bug...
return tracks
def get_chapters(self, title: Titles_T) -> Chapters:
url_info = self.playback_data[title.id]["url_info"][0]
endroll_start_position = float(url_info["endroll_start_position"])
movie_parts_list = url_info["movie_parts_position_list"]
pre_chapter = []
has_intro = False
has_credits = False
if movie_parts_list:
for part in movie_parts_list:
part_type = part.get("movie_parts_type")
start = float(part.get("from", 0))
end = float(part.get("to", 0))
chapter_name = "Scene"
if part_type == "Opening":
if not has_intro:
chapter_name = "Intro"
has_intro = True
elif part_type == "Ending":
if not has_credits:
chapter_name = "Credits"
has_credits = True
pre_chapter.append((chapter_name, start))
if end > start:
pre_chapter.append(("Scene", end))
if endroll_start_position > 0:
is_duplicate = any(abs(t - endroll_start_position) < 1.0 for _, t in pre_chapter)
if not is_duplicate:
if has_credits:
pre_chapter.append(("Scene", endroll_start_position))
else:
pre_chapter.append(("Credits", endroll_start_position))
pre_chapter.sort(key=lambda x: x[1])
if not pre_chapter or pre_chapter[0][1] > 0:
pre_chapter.insert(0, ("Scene", 0.0))
if len(pre_chapter) == 1 and pre_chapter[0] == ("Scene", 0):
return []
chapters: List[Chapter] = []
for i, (chapter_title, time_sec) in enumerate(pre_chapter):
chapters.append(
Chapter(
timestamp=float(time_sec),
name=chapter_title if chapter_title != "Scene" else None,
)
)
return chapters
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
protocol = self.playback_data[title.id]["protocol"]
license_url = self.playback_data[title.id]["url_info"][0]["movie_profile"][protocol]["license_url_list"]["widevine"]
params = {"play_token": self.playback_data[title.id]["play_token"]}
headers = {"Content-Type": "application/octet-stream"}
res = self.session.post(license_url, params=params, headers=headers, data=challenge)
res.raise_for_status()
return res.content
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
protocol = self.playback_data[title.id]["protocol"]
license_url = self.playback_data[title.id]["url_info"][0]["movie_profile"][protocol]["license_url_list"]["playready"]
params = {"play_token": self.playback_data[title.id]["play_token"]}
headers = {
"Accept": "application/xml",
"Content-Type": "text/xml; charset=utf-8",
}
res = self.session.post(license_url, params=params, headers=headers, data=challenge)
res.raise_for_status()
return res.content
def _get_search(self, title: str) -> dict:
endpoint = self.config["endpoints"]["search"]
data_payload = {
"searchString": title,
"numParPage": 32,
"pageNo": 1,
"order": "recommend",
"film_rating_code": "R15",
"need_fulldata": 1,
}
payload = self._build_payload(
data_payload=data_payload,
user_token=self.account_tokens["userToken"],
)
data = self._request("POST", endpoint, payload=payload)
return data["data"]
def _get_stage_data(self, title_id: str) -> dict:
endpoint = self.config["endpoints"]["stage"]
data_payload = {"title_code": title_id}
payload = self._build_payload(
data_payload=data_payload,
user_token=self.account_tokens["userToken"],
)
data = self._request("POST", endpoint, payload=payload)
return data["data"]
def _get_movie_data(self, title_id: str) -> dict:
endpoint = self.config["endpoints"]["movie"]
data_payload = {"title_code": title_id}
payload = self._build_payload(
data_payload=data_payload,
user_token=self.account_tokens["userToken"],
)
data = self._request("POST", endpoint, payload=payload)
return data["data"]
def _get_title_relation(self, title_id: str) -> dict:
endpoint = self.config["endpoints"]["relation"]
data_payload = {"title_code": title_id}
payload = self._build_payload(
data_payload=data_payload,
user_token=self.account_tokens["userToken"],
)
data = self._request("POST", endpoint, payload=payload)
return data["data"]
def _get_episode_list(self, season_id: str) -> dict:
endpoint = self.config["endpoints"]["episode"]
data_payload = {
"title_code": season_id,
"page_size": 1000,
"page_number": 1,
}
payload = self._build_payload(
data_payload=data_payload,
user_token=self.account_tokens["userToken"],
)
data = self._request("POST", endpoint, payload=payload)
return data["data"]
def _get_playlist(self, title_id: str, video_ranges: List[str], play_mode: str) -> dict:
data_payload = {
"code": title_id,
"bitrate_low": 192,
"play_type": 2,
"play_mode": play_mode,
"keyonly_flg": 0,
"validation_flg": 0,
"codec": ["H264", "H265"],
"dynamic_range_list": video_ranges,
"audio_type_list": ["ac-3", "ec-3", "mp4a"],
}
endpoint = self.config["endpoints"]["playlist"]
payload = self._build_payload(
data_payload=data_payload,
user_token=self.account_tokens["userToken"],
)
data = self._request("POST", endpoint, payload=payload)
return data["data"]
def _login_with_password(self, username: str, password: str) -> dict:
endpoint = self.config["endpoints"]["auth"]
data_payload = {
"loginId": username,
"password": password,
}
payload = self._build_payload(data_payload=data_payload)
data = self._request("POST", endpoint, payload=payload)
return data["common"]["userInfo"]
def _get_account_info(self) -> dict:
endpoint = self.config["endpoints"]["account"]
payload = self._build_payload(
data_payload={},
user_token=self.account_tokens["userToken"],
security_token=self.account_tokens["securityToken"],
)
data = self._request("POST", endpoint, payload=payload)
return data["common"]["userInfo"]
def _refresh_token(self) -> dict:
endpoint = self.config["endpoints"]["auth"]
data_payload = {"securityToken": self.account_tokens["securityToken"]}
payload = self._build_payload(
data_payload=data_payload,
user_token=self.account_tokens["userToken"],
)
data = self._request("POST", endpoint, payload=payload)
return data["common"]["userInfo"]
def _build_payload(self, data_payload: dict, user_token: str = "", security_token: str = None) -> dict:
user_info = {
"userToken": user_token,
"service_name": "unext",
}
if security_token:
user_info["securityToken"] = security_token
device_info = {
"deviceType": "980",
"appVersion": "1",
"deviceUuid": self.device_id,
}
payload = {
"common": {
"userInfo": user_info,
"deviceInfo": device_info,
},
"data": data_payload,
}
return payload
def _request(self, method: str, endpoint: str, params: dict = None, headers: dict = None, payload: dict = None) -> Any[dict | str]:
_headers = self.session.headers.copy()
if headers:
_headers.update(headers)
req = Request(method, endpoint, headers=_headers, params=params, json=payload)
prepped = self.session.prepare_request(req)
try:
res = self.session.send(prepped)
res.raise_for_status()
data = res.json()
if error_code := data["common"]["result"]["errorCode"]:
raise ConnectionError(f"{error_code}: {data['common']['result']['errorMessage']}")
return data
except Exception as e:
self.log.error(f"API Request failed: {e}", exc_info=False)
sys.exit(1)
@@ -0,0 +1,12 @@
endpoints:
auth: "https://napi.unext.jp/1/auth/login"
account: "https://napi.unext.jp/2/user/account/get"
search: "https://napi.unext.jp/1/cmscommon/freeword/video"
stage: "https://napi.unext.jp/1/cmsuser/stage"
movie: "https://napi.unext.jp/1/cmsuser/episode/current"
relation: "https://napi.unext.jp/1/cmsuser/titledetail/relation"
episode: "https://napi.unext.jp/1/cmsuser/episode"
playlist: "https://napi.unext.jp/3/cmsuser/playlisturl/file"
device:
user_agent: "U-NEXT TV App Android16 5.61.0 AOSP TV"
@@ -0,0 +1,22 @@
service: BBC
loader_class: BbcLoader
description: A UK Public Sevice broadcaster
hlg_status: True
options: -q 720
media_dict:
Film: 'https://www.bbc.co.uk/iplayer/categories/films/featured'
Documentary: 'https://www.bbc.co.uk/iplayer/categories/documentaries/featured'
Comedy: 'https://www.bbc.co.uk/iplayer/categories/comedy/featured'
Drama: 'https://www.bbc.co.uk/iplayer/categories/drama-and-soaps/featured'
Entertainment: 'https://www.bbc.co.uk/iplayer/categories/entertainment/featured'
Lifestyle: 'https://www.channel4.com/categories/lifestyle'
News: 'https://www.bbc.co.uk/iplayer/categories/news/featured'
Sport: 'https://www.bbc.co.uk/iplayer/categories/sport/featured'
Lifestyle: 'https://www.bbc.co.uk/iplayer/categories/lifestyle/featured'
History: 'https://www.bbc.co.uk/iplayer/categories/history/featured'
Science/Nature: 'https://www.bbc.co.uk/iplayer/categories/science-and-nature/featured'