mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
upstream updates
This commit is contained in:
@@ -4,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 you’ve 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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
__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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("&", "&")
|
||||
|
||||
# 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'
|
||||
|
||||
Reference in New Issue
Block a user