diff --git a/packages/envied/README.md b/packages/envied/README.md index 256ace6..5354e9e 100644 --- a/packages/envied/README.md +++ b/packages/envied/README.md @@ -8,17 +8,19 @@ ## What is envied? Envied is a fork of [Devine](https://github.com/devine-dl/devine/). The name 'envied' is an anagram of Devine, and as such, pays homage to the original author. -Is is based on v 1.4.3 of envied. It is a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content. +Is is based on v 1.4.7 of unshackle. It is a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content. No commands have been changed 'uv run envied' still works as usual. The major difference is that envied comes complete and needs little configuration. -CDM and services are taken care of. +A basic CDM and services are taken care of. The prime reason for the existence of envied is a --select-titles function. If you already use envied you'll probably just want to replace envied/envied/envied.yaml with your own. But the exisiting yaml is close to working - just needs a few directory locations. +Envied's existence helps keep download tools free. Unshackle is flirting with pay-for access, having a free, closley matched, aternative puts a break on any pay-me aspirations unshackle might have now or in the future. + ## Divergence from Envied's Parent - **select-titles option** avoid the uncertainty of -w S26E03 gobbledegook to get your video - **Singleton Design Pattern** Good coding practice: where possible, a single instance of a Class is created and re-used. Saving time and resources. diff --git a/packages/envied/src/envied/commands/dl.py b/packages/envied/src/envied/commands/dl.py index 1a234ab..40b48d9 100644 --- a/packages/envied/src/envied/commands/dl.py +++ b/packages/envied/src/envied/commands/dl.py @@ -1476,6 +1476,8 @@ class dl: @staticmethod def save_cookies(path: Path, cookies: CookieJar): + if hasattr(cookies, 'jar'): + cookies = cookies.jar cookie_jar = MozillaCookieJar(path) cookie_jar.load() for cookie in cookies: diff --git a/packages/envied/src/envied/core/__init__.py b/packages/envied/src/envied/core/__init__.py index 56dadec..ac329c9 100644 --- a/packages/envied/src/envied/core/__init__.py +++ b/packages/envied/src/envied/core/__init__.py @@ -1 +1 @@ -__version__ = "1.4.5" +__version__ = "1.4.7" diff --git a/packages/envied/src/envied/core/config.py b/packages/envied/src/envied/core/config.py index 2967479..15bc21d 100644 --- a/packages/envied/src/envied/core/config.py +++ b/packages/envied/src/envied/core/config.py @@ -10,7 +10,7 @@ from appdirs import AppDirs class Config: class _Directories: # default directories, do not modify here, set via config - app_dirs = AppDirs("envied", False) + app_dirs = AppDirs("unshackle", False) core_dir = Path(__file__).resolve().parent namespace_dir = core_dir.parent commands = namespace_dir / "commands" @@ -88,11 +88,12 @@ class Config: self.tag_group_name: bool = kwargs.get("tag_group_name", True) self.tag_imdb_tmdb: bool = kwargs.get("tag_imdb_tmdb", True) self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or "" + self.decrypt_labs_api_key: str = "" 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.series_year: bool = kwargs.get("series_year", True) - self.pssh_display: str = kwargs.get("pssh_display") or "fold" + self.pssh_display: str = kwargs.get("pssh_display") or "fold" self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default self.title_cache_enabled: bool = kwargs.get("title_cache_enabled", True) @@ -108,11 +109,11 @@ class Config: # noinspection PyProtectedMember POSSIBLE_CONFIG_PATHS = ( - # The envied Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/envied) + # The unshackle Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/unshackle) Config._Directories.namespace_dir / Config._Filenames.root_config, - # The Parent Folder to the envied Namespace Folder (e.g., %appdata%/Python/Python311/site-packages) + # The Parent Folder to the unshackle Namespace Folder (e.g., %appdata%/Python/Python311/site-packages) Config._Directories.namespace_dir.parent / Config._Filenames.root_config, - # The AppDirs User Config Folder (e.g., %localappdata%/envied) + # The AppDirs User Config Folder (e.g., %localappdata%/unshackle) Config._Directories.user_configs / Config._Filenames.root_config, ) @@ -135,4 +136,4 @@ if config_path: else: config = Config() -__all__ = ("config",) +__all__ = ("config",) \ No newline at end of file diff --git a/packages/envied/src/envied/core/drm/clearkey.py b/packages/envied/src/envied/core/drm/clearkey.py index 5652501..6c6d522 100644 --- a/packages/envied/src/envied/core/drm/clearkey.py +++ b/packages/envied/src/envied/core/drm/clearkey.py @@ -5,9 +5,9 @@ import shutil from pathlib import Path from typing import Optional, Union from urllib.parse import urljoin - from Cryptodome.Cipher import AES from Cryptodome.Util.Padding import unpad +from curl_cffi.requests import Session as CurlSession from m3u8.model import Key from requests import Session @@ -69,8 +69,8 @@ class ClearKey: """ if not isinstance(m3u_key, Key): raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}") - if not isinstance(session, (Session, type(None))): - raise TypeError(f"Expected session to be a {Session}, not a {type(session)}") + if not isinstance(session, (Session, CurlSession, type(None))): + raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not a {type(session)}") if not m3u_key.method.startswith("AES"): raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}") diff --git a/packages/envied/src/envied/core/manifests/dash.py b/packages/envied/src/envied/core/manifests/dash.py index 427eb12..6aaab56 100644 --- a/packages/envied/src/envied/core/manifests/dash.py +++ b/packages/envied/src/envied/core/manifests/dash.py @@ -15,6 +15,7 @@ from uuid import UUID from zlib import crc32 import requests +from curl_cffi.requests import Session as CurlSession from langcodes import Language, tag_is_valid from lxml.etree import Element, ElementTree from pyplayready.system.pssh import PSSH as PR_PSSH @@ -47,7 +48,7 @@ class DASH: self.url = url @classmethod - def from_url(cls, url: str, session: Optional[Session] = None, **args: Any) -> DASH: + def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> DASH: if not url: raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.") if not isinstance(url, str): @@ -55,8 +56,8 @@ class DASH: if not session: session = Session() - elif not isinstance(session, Session): - raise TypeError(f"Expected session to be a {Session}, not {session!r}") + elif not isinstance(session, (Session, CurlSession)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") res = session.get(url, **args) if res.url != url: @@ -103,6 +104,10 @@ class DASH: continue if next(iter(period.xpath("SegmentType/@value")), "content") != "content": continue + if "urn:amazon:primevideo:cachingBreadth" in [ + x.get("schemeIdUri") for x in period.findall("SupplementalProperty") + ]: + continue for adaptation_set in period.findall("AdaptationSet"): if self.is_trick_mode(adaptation_set): @@ -797,4 +802,4 @@ class DASH: return url -__all__ = ("DASH",) +__all__ = ("DASH",) \ No newline at end of file diff --git a/packages/envied/src/envied/core/manifests/hls.py b/packages/envied/src/envied/core/manifests/hls.py index 974c344..e35e53f 100644 --- a/packages/envied/src/envied/core/manifests/hls.py +++ b/packages/envied/src/envied/core/manifests/hls.py @@ -14,9 +14,10 @@ from typing import Any, Callable, Optional, Union from urllib.parse import urljoin from zlib import crc32 -import httpx import m3u8 import requests +from curl_cffi.requests import Response as CurlResponse +from curl_cffi.requests import Session as CurlSession from langcodes import Language, tag_is_valid from m3u8 import M3U8 from pyplayready.cdm import Cdm as PlayReadyCdm @@ -35,7 +36,7 @@ from envied.core.utilities import get_extension, is_close_match, try_ensure_utf8 class HLS: - def __init__(self, manifest: M3U8, session: Optional[Union[Session, httpx.Client]] = None): + def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None): if not manifest: raise ValueError("HLS manifest must be provided.") if not isinstance(manifest, M3U8): @@ -47,7 +48,7 @@ class HLS: self.session = session or Session() @classmethod - def from_url(cls, url: str, session: Optional[Union[Session, httpx.Client]] = None, **args: Any) -> HLS: + def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> HLS: if not url: raise requests.URLRequired("HLS manifest URL must be provided.") if not isinstance(url, str): @@ -55,22 +56,22 @@ class HLS: if not session: session = Session() - elif not isinstance(session, (Session, httpx.Client)): - raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {session!r}") + elif not isinstance(session, (Session, CurlSession)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") res = session.get(url, **args) - # Handle both requests and httpx response objects + # Handle requests and curl_cffi response objects if isinstance(res, requests.Response): if not res.ok: raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res) content = res.text - elif isinstance(res, httpx.Response): - if res.status_code >= 400: + elif isinstance(res, CurlResponse): + if not res.ok: raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res) content = res.text else: - raise TypeError(f"Expected response to be a requests.Response or httpx.Response, not {type(res)}") + raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(res)}") master = m3u8.loads(content, uri=url) @@ -229,7 +230,7 @@ class HLS: save_path: Path, save_dir: Path, progress: partial, - session: Optional[Union[Session, httpx.Client]] = None, + session: Optional[Union[Session, CurlSession]] = None, proxy: Optional[str] = None, max_workers: Optional[int] = None, license_widevine: Optional[Callable] = None, @@ -238,15 +239,13 @@ class HLS: ) -> None: if not session: session = Session() - elif not isinstance(session, (Session, httpx.Client)): - raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {session!r}") + elif not isinstance(session, (Session, CurlSession)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") if proxy: # Handle proxies differently based on session type if isinstance(session, Session): session.proxies.update({"all": proxy}) - elif isinstance(session, httpx.Client): - session.proxies = {"http://": proxy, "https://": proxy} log = logging.getLogger("HLS") @@ -257,13 +256,8 @@ class HLS: log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}") sys.exit(1) playlist_text = response.text - elif isinstance(response, httpx.Response): - if response.status_code >= 400: - log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}") - sys.exit(1) - playlist_text = response.text else: - raise TypeError(f"Expected response to be a requests.Response or httpx.Response, not {type(response)}") + raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(response)}") master = m3u8.loads(playlist_text, uri=track.url) @@ -533,13 +527,9 @@ class HLS: if isinstance(res, requests.Response): res.raise_for_status() init_content = res.content - elif isinstance(res, httpx.Response): - if res.status_code >= 400: - raise requests.HTTPError(f"HTTP Error: {res.status_code}", response=res) - init_content = res.content else: raise TypeError( - f"Expected response to be requests.Response or httpx.Response, not {type(res)}" + f"Expected response to be requests.Response or curl_cffi.Response, not {type(res)}" ) map_data = (segment.init_section, init_content) @@ -707,7 +697,7 @@ class HLS: @staticmethod def parse_session_data_keys( - manifest: M3U8, session: Optional[Union[Session, httpx.Client]] = None + manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None ) -> list[m3u8.model.Key]: """Parse `com.apple.hls.keys` session data and return Key objects.""" keys: list[m3u8.model.Key] = [] @@ -798,7 +788,8 @@ class HLS: @staticmethod def get_drm( - key: Union[m3u8.model.SessionKey, m3u8.model.Key], session: Optional[Union[Session, httpx.Client]] = None + key: Union[m3u8.model.SessionKey, m3u8.model.Key], + session: Optional[Union[Session, CurlSession]] = None, ) -> DRM_T: """ Convert HLS EXT-X-KEY data to an initialized DRM object. @@ -810,8 +801,8 @@ class HLS: Raises a NotImplementedError if the key system is not supported. """ - if not isinstance(session, (Session, httpx.Client, type(None))): - raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {type(session)}") + if not isinstance(session, (Session, CurlSession, type(None))): + raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}") if not session: session = Session() @@ -885,4 +876,4 @@ class HLS: return f"{offset}-{offset + length - 1}" -__all__ = ("HLS",) +__all__ = ("HLS",) \ No newline at end of file diff --git a/packages/envied/src/envied/core/manifests/ism.py b/packages/envied/src/envied/core/manifests/ism.py index cc42571..d5d7b9d 100644 --- a/packages/envied/src/envied/core/manifests/ism.py +++ b/packages/envied/src/envied/core/manifests/ism.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any, Callable, Optional, Union import requests +from curl_cffi.requests import Session as CurlSession from langcodes import Language, tag_is_valid from lxml.etree import Element from pyplayready.system.pssh import PSSH as PR_PSSH @@ -34,11 +35,13 @@ class ISM: self.url = url @classmethod - def from_url(cls, url: str, session: Optional[Session] = None, **kwargs: Any) -> "ISM": + def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **kwargs: Any) -> "ISM": if not url: raise requests.URLRequired("ISM manifest URL must be provided") if not session: session = Session() + elif not isinstance(session, (Session, CurlSession)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") res = session.get(url, **kwargs) if res.url != url: url = res.url @@ -332,4 +335,4 @@ class ISM: progress(downloaded="Downloaded") -__all__ = ("ISM",) +__all__ = ("ISM",) \ No newline at end of file diff --git a/packages/envied/src/envied/core/manifests/m3u8.py b/packages/envied/src/envied/core/manifests/m3u8.py index 9e1ed31..97c3fe8 100644 --- a/packages/envied/src/envied/core/manifests/m3u8.py +++ b/packages/envied/src/envied/core/manifests/m3u8.py @@ -4,15 +4,10 @@ from __future__ import annotations from typing import Optional, Union -import httpx import m3u8 -from pyplayready.cdm import Cdm as PlayReadyCdm -from pyplayready.system.pssh import PSSH as PR_PSSH -from pywidevine.cdm import Cdm as WidevineCdm -from pywidevine.pssh import PSSH as WV_PSSH +from curl_cffi.requests import Session as CurlSession from requests import Session -from envied.core.drm import PlayReady, Widevine from envied.core.manifests.hls import HLS from envied.core.tracks import Tracks @@ -21,56 +16,19 @@ def parse( master: m3u8.M3U8, language: str, *, - session: Optional[Union[Session, httpx.Client]] = None, + session: Optional[Union[Session, CurlSession]] = None, ) -> Tracks: - """Parse a variant playlist to ``Tracks`` with DRM information.""" + """Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading.""" tracks = HLS(master, session=session).to_tracks(language) - need_wv = not any(isinstance(d, Widevine) for t in tracks for d in (t.drm or [])) - need_pr = not any(isinstance(d, PlayReady) for t in tracks for d in (t.drm or [])) + bool(master.session_keys or HLS.parse_session_data_keys(master, session or Session())) - if (need_wv or need_pr) and tracks.videos: - if not session: - session = Session() - - session_keys = list(master.session_keys or []) - session_keys.extend(HLS.parse_session_data_keys(master, session)) - - for drm_obj in HLS.get_all_drm(session_keys): - if need_wv and isinstance(drm_obj, Widevine): - for t in tracks.videos + tracks.audio: - t.drm = [d for d in (t.drm or []) if not isinstance(d, Widevine)] + [drm_obj] - need_wv = False - elif need_pr and isinstance(drm_obj, PlayReady): - for t in tracks.videos + tracks.audio: - t.drm = [d for d in (t.drm or []) if not isinstance(d, PlayReady)] + [drm_obj] - need_pr = False - if not need_wv and not need_pr: - break - - if (need_wv or need_pr) and tracks.videos: - first_video = tracks.videos[0] - playlist = m3u8.load(first_video.url) - for key in playlist.keys or []: - if not key or not key.keyformat: - continue - fmt = key.keyformat.lower() - if need_wv and fmt == WidevineCdm.urn: - pssh_b64 = key.uri.split(",")[-1] - drm = Widevine(pssh=WV_PSSH(pssh_b64)) - for t in tracks.videos + tracks.audio: - t.drm = [d for d in (t.drm or []) if not isinstance(d, Widevine)] + [drm] - need_wv = False - elif need_pr and (fmt == PlayReadyCdm or "com.microsoft.playready" in fmt): - pssh_b64 = key.uri.split(",")[-1] - drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64) - for t in tracks.videos + tracks.audio: - t.drm = [d for d in (t.drm or []) if not isinstance(d, PlayReady)] + [drm] - need_pr = False - if not need_wv and not need_pr: - break + if True: + for t in tracks.videos + tracks.audio: + t.needs_drm_loading = True + t.session = session return tracks -__all__ = ["parse"] +__all__ = ["parse"] \ No newline at end of file diff --git a/packages/envied/src/envied/core/session.py b/packages/envied/src/envied/core/session.py new file mode 100644 index 0000000..4117af9 --- /dev/null +++ b/packages/envied/src/envied/core/session.py @@ -0,0 +1,79 @@ +"""Session utilities for creating HTTP sessions with different backends.""" + +from __future__ import annotations + +import warnings + +from curl_cffi.requests import Session as CurlSession + +from envied.core.config import config + +# Globally suppress curl_cffi HTTPS proxy warnings since some proxy providers +# (like NordVPN) require HTTPS URLs but curl_cffi expects HTTP format +warnings.filterwarnings( + "ignore", message="Make sure you are using https over https proxy.*", category=RuntimeWarning, module="curl_cffi.*" +) + + +class Session(CurlSession): + """curl_cffi Session with warning suppression.""" + + def request(self, method, url, **kwargs): + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message="Make sure you are using https over https proxy.*", category=RuntimeWarning + ) + return super().request(method, url, **kwargs) + + +def session(browser: str | None = None, **kwargs) -> Session: + """ + Create a curl_cffi session that impersonates a browser. + + This is a full replacement for requests.Session with browser impersonation + and anti-bot capabilities. The session uses curl-impersonate under the hood + to mimic real browser behavior. + + Args: + browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari"). + Uses the configured default from curl_impersonate.browser if not specified. + See https://github.com/lexiforest/curl_cffi#sessions for available options. + **kwargs: Additional arguments passed to CurlSession constructor: + - headers: Additional headers (dict) + - cookies: Cookie jar or dict + - auth: HTTP basic auth tuple (username, password) + - proxies: Proxy configuration dict + - verify: SSL certificate verification (bool, default True) + - timeout: Request timeout in seconds (float or tuple) + - allow_redirects: Follow redirects (bool, default True) + - max_redirects: Maximum redirect count (int) + - cert: Client certificate (str or tuple) + + Returns: + curl_cffi.requests.Session configured with browser impersonation, common headers, + and equivalent retry behavior to requests.Session. + + Example: + from envied.core.session import session + + class MyService(Service): + @staticmethod + def get_session(): + return session() # Uses config default browser + """ + if browser is None: + browser = config.curl_impersonate.get("browser", "chrome124") + + session_config = { + "impersonate": browser, + "timeout": 30.0, + "allow_redirects": True, + "max_redirects": 15, + "verify": True, + } + + session_config.update(kwargs) + session_obj = Session(**session_config) + session_obj.headers.update(config.headers) + + return session_obj diff --git a/packages/envied/src/envied/core/titles/episode.py b/packages/envied/src/envied/core/titles/episode.py index 058f23e..7986443 100644 --- a/packages/envied/src/envied/core/titles/episode.py +++ b/packages/envied/src/envied/core/titles/episode.py @@ -89,7 +89,17 @@ class Episode(Title): 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 = next(iter(media_info.audio_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}) # Title [Year] SXXEXX Name (or Title [Year] SXX if folder) @@ -227,4 +237,4 @@ class Series(SortedKeyList, ABC): return tree -__all__ = ("Episode", "Series") +__all__ = ("Episode", "Series") \ No newline at end of file diff --git a/packages/envied/src/envied/core/tracks/audio.py b/packages/envied/src/envied/core/tracks/audio.py index bc5aedd..c435d03 100644 --- a/packages/envied/src/envied/core/tracks/audio.py +++ b/packages/envied/src/envied/core/tracks/audio.py @@ -11,6 +11,7 @@ class Audio(Track): class Codec(str, Enum): AAC = "AAC" # https://wikipedia.org/wiki/Advanced_Audio_Coding AC3 = "DD" # https://wikipedia.org/wiki/Dolby_Digital + AC4 = "AC-4" # https://wikipedia.org/wiki/Dolby_AC-4 EC3 = "DD+" # https://wikipedia.org/wiki/Dolby_Digital_Plus OPUS = "OPUS" # https://wikipedia.org/wiki/Opus_(audio_format) OGG = "VORB" # https://wikipedia.org/wiki/Vorbis @@ -29,6 +30,8 @@ class Audio(Track): return Audio.Codec.AAC if mime == "ac-3": return Audio.Codec.AC3 + if mime == "ac-4": + return Audio.Codec.AC4 if mime == "ec-3": return Audio.Codec.EC3 if mime == "opus": @@ -60,6 +63,8 @@ class Audio(Track): return Audio.Codec.AC3 if profile.startswith("ddplus"): return Audio.Codec.EC3 + if profile.startswith("ac4"): + return Audio.Codec.AC4 if profile.startswith("playready-oggvorbis"): return Audio.Codec.OGG raise ValueError(f"The Content Profile '{profile}' is not a supported Audio Codec") diff --git a/packages/envied/src/envied/envied-example.yaml b/packages/envied/src/envied/envied-example.yaml index 71a2dc0..5d5b34a 100644 --- a/packages/envied/src/envied/envied-example.yaml +++ b/packages/envied/src/envied/envied-example.yaml @@ -1,3 +1,5 @@ +# Herin a full example for envied.yaml + # Group or Username to postfix to the end of all download filenames following a dash tag: user_tag @@ -28,9 +30,9 @@ update_check_interval: 24 # Title caching configuration # Cache title metadata to reduce redundant API calls -title_cache_enabled: true # Enable/disable title caching globally (default: true) -title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes) -title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours) +title_cache_enabled: true # Enable/disable title caching globally (default: true) +title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes) +title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours) # Muxing configuration muxing: @@ -88,6 +90,26 @@ cdm: jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1 default: generic_android_l3 # Default CDM for this service + # NEW: Quality-based CDM selection + # Use different CDMs based on video resolution + # Supports operators: >=, >, <=, <, or exact match + EXAMPLE_QUALITY: + "<=1080": generic_android_l3 # Use L3 for 1080p and below + ">1080": nexus_5_l1 # Use L1 for above 1080p (1440p, 2160p) + default: generic_android_l3 # Optional: fallback if no quality match + + # You can mix profiles and quality thresholds in the same service + NETFLIX: + # Profile-based selection (existing functionality) + john: netflix_l3_profile + jane: netflix_l1_profile + # Quality-based selection (new functionality) + "<=720": netflix_mobile_l3 + "1080": netflix_standard_l3 + ">=1440": netflix_premium_l1 + # Fallback + default: netflix_standard_l3 + # Use pywidevine Serve-compliant Remote CDMs remote_cdm: - name: "chrome" @@ -105,6 +127,50 @@ remote_cdm: host: https://domain-2.com/api secret: secret_key + - name: "decrypt_labs_chrome" + type: "decrypt_labs" # Required to identify as DecryptLabs CDM + device_name: "ChromeCDM" # Scheme identifier - must match exactly + device_type: CHROME + system_id: 4464 # Doesn't matter + security_level: 3 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" # Replace with your API key + - name: "decrypt_labs_l1" + type: "decrypt_labs" + device_name: "L1" # Scheme identifier - must match exactly + device_type: ANDROID + system_id: 4464 + security_level: 1 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" + + - name: "decrypt_labs_l2" + type: "decrypt_labs" + device_name: "L2" # Scheme identifier - must match exactly + device_type: ANDROID + system_id: 4464 + security_level: 2 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" + + - name: "decrypt_labs_playready_sl2" + type: "decrypt_labs" + device_name: "SL2" # Scheme identifier - must match exactly + device_type: PLAYREADY + system_id: 0 + security_level: 2000 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" + + - name: "decrypt_labs_playready_sl3" + type: "decrypt_labs" + device_name: "SL3" # Scheme identifier - must match exactly + device_type: PLAYREADY + system_id: 0 + security_level: 3000 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" + # Key Vaults store your obtained Content Encryption Keys (CEKs) # Use 'no_push: true' to prevent a vault from receiving pushed keys # while still allowing it to provide keys when requested @@ -156,7 +222,6 @@ curl_impersonate: # Pre-define default options and switches of the dl command dl: - best: true sub_format: srt downloads: 4 workers: 16 @@ -172,7 +237,7 @@ chapter_fallback_name: "Chapter {j:02}" # Case-Insensitive dictionary of headers for all Services headers: Accept-Language: "en-US,en;q=0.8" - User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36" + User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" # Override default filenames used across envied filenames: @@ -214,6 +279,13 @@ services: # Global service config api_key: "service_api_key" + # Service certificate for Widevine L1/L2 (base64 encoded) + # This certificate is automatically used when L1/L2 schemes are selected + # Services obtain this from their DRM provider or license server + certificate: | + CAUSwwUKvQIIAxIQ5US6QAvBDzfTtjb4tU/7QxiH8c+TBSKOAjCCAQoCggEBAObzvlu2hZRsapAPx4Aa4GUZj4/GjxgXUtBH4THSkM40x63wQeyVxlEEo + # ... (full base64 certificate here) + # Profile-specific device configurations profiles: john_sd: @@ -241,14 +313,14 @@ proxy_providers: username: username_from_service_credentials password: password_from_service_credentials server_map: - - us: 12 # force US server #12 for US proxies + us: 12 # force US server #12 for US proxies surfsharkvpn: username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn password: your_surfshark_service_password # Service credentials (not your login password) server_map: - - us: 3844 # force US server #3844 for US proxies - - gb: 2697 # force GB server #2697 for GB proxies - - au: 4621 # force AU server #4621 for AU proxies + us: 3844 # force US server #3844 for US proxies + gb: 2697 # force GB server #2697 for GB proxies + au: 4621 # force AU server #4621 for AU proxies basic: GB: - "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham) @@ -257,4 +329,4 @@ proxy_providers: - "socks5://username:password@syd.socks.ipvanish.com:1080" # 1 (Sydney) - "https://username:password@au-syd.prod.surfshark.com" # 2 (Sydney) - "https://username:password@au-bne.prod.surfshark.com" # 3 (Brisbane) - BG: "https://username:password@bg-sof.prod.surfshark.com" + BG: "https://username:password@bg-sof.prod.surfshark.com" \ No newline at end of file