From 68f1bbae1a71cd5a64881f0d3b7e6e0c17afc5e4 Mon Sep 17 00:00:00 2001 From: VineFeeder Date: Thu, 16 Apr 2026 10:45:17 +0100 Subject: [PATCH] yaml addition --- .../envied/src/envied/envied-example.yaml | 385 ++++------ .../src/envied/services/PCOK/__init__.py | 663 +++++++++--------- .../src/envied/services/PCOK/config.yaml | 54 +- .../src/envied/services/RKTN/__init__.py | 4 +- 4 files changed, 502 insertions(+), 604 deletions(-) mode change 100755 => 100644 packages/envied/src/envied/services/PCOK/__init__.py mode change 100755 => 100644 packages/envied/src/envied/services/PCOK/config.yaml diff --git a/packages/envied/src/envied/envied-example.yaml b/packages/envied/src/envied/envied-example.yaml index 5d5b34a..31684d6 100644 --- a/packages/envied/src/envied/envied-example.yaml +++ b/packages/envied/src/envied/envied-example.yaml @@ -1,64 +1,90 @@ -# 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 - -# Enable/disable tagging with group name (default: true) -tag_group_name: true - -# Enable/disable tagging with IMDB/TMDB/TVDB details (default: true) -tag_imdb_tmdb: true +# Group or Username to postfix to the end of all download fienames following a dash +#tag: '' # Set terminal background color (custom option not in CONFIG.md) set_terminal_bg: false -# Set file naming convention -# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264 -# false for style - Prime Suspect S07E01 The Final Act - Part One -scene_naming: true - -# Whether to include the year in series names for episodes and folders (default: true) -# true for style - Show Name (2023) S01E01 Episode Name -# false for style - Show Name S01E01 Episode Name -series_year: true - -# Check for updates from GitHub repository on startup (default: true) -update_checks: true - -# How often to check for updates, in hours (default: 24) -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) - # Muxing configuration muxing: set_title: false +# shakapackager or mp4decrypt +decryption: shakapackager + +# Custom output templates for filenames +# Configure output_template in your unshackle.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}) {quality}' + 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: - any audio track matches this language +# subs_contain: - 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 + + +# Widevine pssh display; fold, crop or ellipsis +pssh_display: ellipsis + # Login credentials for each Service credentials: - # Direct credentials (no profile support) - EXAMPLE: email@example.com:password - - # Per-profile credentials with default fallback - SERVICE_NAME: - default: default@email.com:password # Used when no -p/--profile is specified - profile1: user1@email.com:password1 - profile2: user2@email.com:password2 - - # Per-profile credentials without default (requires -p/--profile) - SERVICE_NAME2: - john: john@example.com:johnspassword - jane: jane@example.com:janespassword - - # You can also use list format for passwords with special characters - SERVICE_NAME3: - default: ["user@email.com", ":PasswordWith:Colons"] - -# Override default directories used across envied + ALL4: email:password + ROKU: email:password + TVNZ: email:password + CBC: email:password + CRAVE: email:password + +# Override default directories used across unshackle directories: cache: Cache cookies: Cookies @@ -70,139 +96,67 @@ directories: prds: PRDs # Additional directories that can be configured: # commands: Commands - services: - - /path/to/services - - /other/path/to/services - # vaults: Vaults - # fonts: Fonts + #services: + # - ./envied/services/ + vaults: vaults/ + +# fonts: Fonts # Pre-define which Widevine or PlayReady device to use for each Service cdm: - # Global default CDM device (fallback for all services/profiles) - default: WVD_1 + default: device +# itv: DRMLABL3 - # Direct service-specific CDM - DIFFERENT_EXAMPLE: PRD_1 +# ========================================== +# REMOTE CDM - not working +# ========================================== - # Per-profile CDM configuration - EXAMPLE: - john_sd: chromecdm_903_l3 # Profile 'john_sd' uses Chrome CDM L3 - 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" - device_name: chrome - device_type: CHROME - system_id: 27175 - security_level: 3 - host: https://domain.com/api - secret: secret_key - - name: "chrome-2" - device_name: chrome - device_type: CHROME - system_id: 26830 - security_level: 3 - host: https://domain-2.com/api - secret: secret_key + - name: "DRMLABL3" + "Device Type": ANDROID + "Device Name": "l3" + "System ID": 8159 + "Security Level": 3 + "Host": "http://api2.drmlab.io" + "Secret": "drmlab" - - 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" +remote_cdm: + - name: "DRMLAB_SL3K" + "Device Type": PLAYREADY + "Device Name": "sl3k" + "System ID": 0 + "Security Level": 3000 + "Host": "http://api2.drmlab.io/playready" + "Secret": "drmlab" # 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 key_vaults: - - type: SQLite - name: Local - path: key_store.db - # Additional vault types: - # - type: API - # name: "Remote Vault" - # uri: "https://key-vault.example.com" - # token: "secret_token" - # no_push: true # This vault will only provide keys, not receive them - # - type: MySQL - # name: "MySQL Vault" - # host: "127.0.0.1" - # port: 3306 - # database: vault - # username: user - # password: pass - # no_push: false # Default behavior - vault both provides and receives keys +- type: SQLite + name: Local vault + path: key_store.db + +#- type: HTTPAPI +# name: drmlab +# host: http://api.drmlab.io/vault/ +# password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT +- 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: aria2c +#downloader: n_m3u8dl_re # Options: requests | aria2c | curl_impersonate | n_m3u8dl_re # Can also be a mapping: -# downloader: +downloader: # NF: requests # AMZN: n_m3u8dl_re -# DSNP: n_m3u8dl_re -# default: requests - + ALL4: n_m3u8dl_re + BBC: requests + default: n_m3u8dl_re + # aria2c downloader configuration aria2c: max_concurrent_downloads: 4 @@ -222,14 +176,12 @@ curl_impersonate: # Pre-define default options and switches of the dl command dl: + best: true sub_format: srt downloads: 4 workers: 16 - lang: - - en - - fr - EXAMPLE: - bitrate: CBR + + # Chapter Name to use when exporting a Chapter without a Name chapter_fallback_name: "Chapter {j:02}" @@ -237,9 +189,9 @@ 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/138.0.0.0 Safari/537.36" + 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" -# Override default filenames used across envied +# Override default filenames used across unshackle filenames: log: "envied_{name}_{time}.log" config: "config.yaml" @@ -248,7 +200,11 @@ filenames: subtitle: "Subtitle_{id}_{language}.srt" # API key for The Movie Database (TMDB) -tmdb_api_key: "" +# tmdb_api_key: key + +# tag_imdb_tmdb (bool) +tag_imdb_tmdb: true + # conversion_method: # - auto (default): Smart routing - subby for WebVTT/SAMI, standard for others @@ -259,74 +215,19 @@ subtitle: conversion_method: auto sdh_method: auto -# Configuration for pywidevine's serve functionality -serve: - users: - secret_key_for_user: - devices: - - generic_nexus_4464_l3 - username: user - # devices: - # - '/path/to/device.wvd' # Configuration data for each Service services: # Service-specific configuration goes here - # Profile-specific configurations can be nested under service names + # EXAMPLE: + # api_key: "service_specific_key" - # Example: with profile-specific device configs - EXAMPLE: - # 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: - device: - app_name: "AIV" - device_model: "SHIELD Android TV" - jane_uhd: - device: - app_name: "AIV" - device_model: "Fire TV Stick 4K" - - # Example: Service with different regions per profile - SERVICE_NAME: - profiles: - us_account: - region: "US" - api_endpoint: "https://api.us.service.com" - uk_account: - region: "GB" - api_endpoint: "https://api.uk.service.com" - -# External proxy provider services -proxy_providers: - nordvpn: - username: username_from_service_credentials - password: password_from_service_credentials - server_map: - 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 - basic: - GB: - - "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham) - - "socks5://username:password@gla.socks.ipvanish.com:1080" # 2 (Glasgow) - AU: - - "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" \ No newline at end of file +# Legacy NordVPN configuration (use proxy_providers instead) +#proxy_providers: +# nordvpn: +# username: gobbldegook +# password: twaddle +# server_map: +# us: 6918 +# uk: 2613 +# nz: 100 diff --git a/packages/envied/src/envied/services/PCOK/__init__.py b/packages/envied/src/envied/services/PCOK/__init__.py old mode 100755 new mode 100644 index d7fe024..1d835c2 --- a/packages/envied/src/envied/services/PCOK/__init__.py +++ b/packages/envied/src/envied/services/PCOK/__init__.py @@ -1,16 +1,16 @@ +from __future__ import annotations import base64 import hashlib import hmac import json +import re import time from datetime import datetime from http.cookiejar import CookieJar from typing import Optional - +from urllib.parse import urlparse import click from langcodes import Language -from pyplayready.cdm import Cdm as PlayReadyCdm - from envied.core.constants import AnyTrack from envied.core.credential import Credential from envied.core.manifests import DASH @@ -21,26 +21,22 @@ from envied.core.tracks import Chapters, Tracks, Video class PCOK(Service): """ - Service code for NBC's Peacock streaming service (https://peacocktv.com). - Version: 1.0.0 - - Authorization: Cookies - Security: UHD@-- FHD@SL* - - Tips: - The library of contents can be viewed without logging in at https://www.peacocktv.com/stream/tv - See the footer for links to movies, news, etc. A US IP is required to view. + Service code for Peacock TV (https://peacocktv.com) + + Author: n0stal6ic + Authorization: Cookies, Credentials + Geofence: US """ - ALIASES = ("PCOK", "peacock") + ALIASES = ("PCOK", "peacock", "peacocktv") GEOFENCE = ("US",) TITLE_RE = [ - r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?Pmovies/[a-z0-9/./-]+/[a-f0-9-]+)", - r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?Ptv/[a-z0-9/./-]+/[a-f0-9-]+)", - r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?Ptv/[a-z0-9-/.]+/\d+)", - r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?Pnews/[a-z0-9/./-]+/[a-f0-9-]+)", - r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?Pnews/[a-z0-9-/.]+/\d+)", - r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P-/[a-z0-9-/.]+/\d+)", - r"(?:https?://(?:www\.)?peacocktv\.com/stream-tv/)?(?P[a-z0-9-/.]+)", + r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P/movies/[a-z0-9_./-]+/[a-f0-9-]{36})", + r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P/tv/[a-z0-9_./-]+/[a-f0-9-]{36})", + r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P/tv/[a-z0-9_./-]+/\d+)", + r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P/news/[a-z0-9_./-]+/[a-f0-9-]{36})", + r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P/-/[a-z0-9_./-]+/\d+)", + r"(?:https?://(?:www\.)?peacocktv\.com/stream-tv/)?(?P[a-z0-9-]+)$", ] @staticmethod @@ -51,15 +47,11 @@ class PCOK(Service): def cli(ctx, **kwargs): return PCOK(ctx, **kwargs) - def __init__(self, ctx, title, movie): + def __init__(self, ctx, title: str, movie: bool): super().__init__(ctx) self.title = title self.movie = movie - self.cdm = ctx.obj.cdm - if not isinstance(self.cdm, PlayReadyCdm): - self.log.warning("PlayReady CDM not provided, exiting") - raise SystemExit(1) range_param = ctx.parent.params.get("range_") self.range = range_param[0].name if range_param else "SDR" @@ -67,64 +59,221 @@ class PCOK(Service): vcodec_param = ctx.parent.params.get("vcodec") self.vcodec = vcodec_param if vcodec_param else "H264" - if self.config is None: - raise Exception("Config is missing!") + self.profile_name = ctx.parent.params.get("profile") or "default" - profile_name = ctx.parent.params.get("profile") - if profile_name is None: - profile_name = "default" - self.profile = profile_name + prof_key = self.config["client"].get("profile", "tv") + profiles = self.config.get("profiles", {}) + if prof_key not in profiles: + raise ValueError(f"Unknown device profile {prof_key!r}. Valid: {list(profiles)}") + self.prof = profiles[prof_key] + self.hmac_key: bytes = self.prof["hmac_key"].encode() - self.hmac_key = None - self.tokens = None - self.license_api = None - self.license_bt = None + try: + from pyplayready.cdm import Cdm as PlayReadyCdm + self.use_playready: bool = isinstance(ctx.obj.cdm, PlayReadyCdm) + except ImportError: + self.use_playready = False + + self.tokens: Optional[dict] = None + self.license_url: Optional[str] = None def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: super().authenticate(cookies, credential) - if not cookies: - raise EnvironmentError("Service requires Cookies for Authentication.") - self.session.headers.update({"Origin": "https://www.peacocktv.com"}) - self.log.info("Getting Peacock Client configuration") - if self.config["client"]["platform"] != "PC": - self.service_config = self.session.get( - url=self.config["endpoints"]["config"].format( - territory=self.config["client"]["territory"], - provider=self.config["client"]["provider"], - proposition=self.config["client"]["proposition"], - device=self.config["client"]["platform"], - version=self.config["client"]["config_version"], + if credential and credential.username and credential.password: + self.log.info("Authenticating with email/password credentials.") + self._login(credential.username, credential.password) + elif not cookies: + raise EnvironmentError("Peacock requires cookies or credential.") + + self.log.info("Fetching authorization tokens.") + self.tokens = self._get_tokens() + self.log.info("Verifying tokens.") + if not self._verify_tokens(): + raise EnvironmentError("Token verification failed.") + + def _login(self, email: str, password: str) -> None: + r = self.session.post( + url=self.config["endpoints"]["login"], + data={"userIdentifier": email, "password": password}, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "X-SkyOTT-Proposition": self.config["client"]["proposition"], + "X-SkyOTT-Provider": self.config["client"]["provider"], + "X-SkyOTT-Territory": self.config["client"]["territory"], + }, + ) + if r.status_code not in (200, 201): + try: + code = ( + r.json() + .get("properties", {}) + .get("errors", {}) + .get("categoryErrors", [{}])[0] + .get("code", "unknown") ) - ).json() + raise EnvironmentError(f"Login failed: {code}") + except (ValueError, KeyError, IndexError): + raise EnvironmentError(f"Login failed with HTTP {r.status_code}.") - self.hmac_key = bytes(self.config["security"]["signature_hmac_key_v4"], "utf-8") - self.log.info("Getting Authorization Tokens") - self.tokens = self.get_tokens() - self.log.info("Verifying Authorization Tokens") - if not self.verify_tokens(): - raise EnvironmentError("Failed! Cookies might be outdated.") + def _sky_headers(self, extra: Optional[dict] = None) -> dict: + h = { + "X-SkyOTT-Device": self.prof["device"], + "X-SkyOTT-Platform": self.prof["platform"], + "X-SkyOTT-Proposition": self.config["client"]["proposition"], + "X-SkyOTT-Provider": self.config["client"]["provider"], + "X-SkyOTT-Territory": self.config["client"]["territory"], + } + if extra: + h.update(extra) + return h + + def _md5_headers(self, headers: dict) -> str: + lines = sorted( + f"{k.lower()}: {v}" + for k, v in headers.items() + if k.lower().startswith("x-skyott-") + ) + text = "\n".join(lines) + ("\n" if lines else "") + return hashlib.md5(text.encode()).hexdigest() + + @staticmethod + def _md5_body(body: str | bytes) -> str: + if isinstance(body, str): + body = body.encode() + return hashlib.md5(body).hexdigest() + + def _sign(self, method: str, path: str, headers: dict, body: str | bytes = b"") -> str: + ts = str(int(time.time())) + sdk = self.prof["client_sdk"] + msg = ( + "\n".join([ + method.upper(), + path, + "", + sdk, + "1.0", + self._md5_headers(headers), + ts, + self._md5_body(body), + ]) + + "\n" + ) + digest = hmac.new(self.hmac_key, msg.encode(), hashlib.sha1).digest() + sig = base64.b64encode(digest).decode() + return f'SkyOTT client="{sdk}",signature="{sig}",timestamp="{ts}",version="1.0"' + + def _get_tokens(self) -> dict: + prof_key = self.config["client"].get("profile", "tv") + cache_key = f"tokens_{self.profile_name}_{prof_key}" + cache = self.cache.get(cache_key) + + if cache and cache.data: + expiry = cache.data.get("tokenExpiryTime") + if expiry: + try: + if datetime.strptime(expiry, "%Y-%m-%dT%H:%M:%S.%fZ") > datetime.utcnow(): + self.log.debug("Using cached tokens.") + return cache.data + except ValueError: + pass + + sky_h = self._sky_headers() + + persona_id: Optional[str] = None + try: + r = self.session.get( + url=self.config["endpoints"]["personas"], + headers={ + **sky_h, + "Accept": "application/vnd.persona.v1+json", + "X-SkyOTT-TokenType": self.config["client"]["auth_scheme"], + }, + ) + if r.ok: + personas = r.json().get("personas", []) + if personas: + persona_id = personas[0]["personaId"] + except Exception as e: + self.log.debug(f"Persona fetch skipped: {e}") + + auth_block: dict = { + "authScheme": self.config["client"]["auth_scheme"], + "provider": self.config["client"]["provider"], + "providerTerritory": self.config["client"]["territory"], + "proposition": self.config["client"]["proposition"], + } + if persona_id: + auth_block["personaId"] = persona_id + + body = json.dumps( + { + "auth": auth_block, + "device": { + "type": self.prof["device"], + "platform": self.prof["platform"], + "id": self.config["client"].get("device_id", "PC"), + "drmDeviceId": self.config["client"].get("drm_device_id", "UNKNOWN"), + }, + }, + separators=(",", ":"), + ) + + r = self.session.post( + url=self.config["endpoints"]["tokens"], + data=body, + headers={ + **sky_h, + "Accept": "application/vnd.tokens.v1+json", + "Content-Type": "application/vnd.tokens.v1+json", + "X-Sky-Signature": self._sign("POST", "/auth/tokens", sky_h, body), + }, + ) + r.raise_for_status() + tokens = r.json() + + if "description" in tokens and "userToken" not in tokens: + raise EnvironmentError(f"Token fetch failed: {tokens['description']}") + + if cache: + cache.set(data=tokens) + + tokens["_fresh"] = True + return tokens + + def _verify_tokens(self) -> bool: + if self.tokens.pop("_fresh", False): + return True + sky_h = self._sky_headers({"X-SkyOTT-UserToken": self.tokens["userToken"]}) + try: + r = self.session.get( + url=self.config["endpoints"]["me"], + headers={ + **sky_h, + "Accept": "application/vnd.userinfo.v2+json", + "X-Sky-Signature": self._sign("GET", "/auth/users/me", sky_h), + }, + ) + return r.ok + except Exception: + return False def get_titles(self) -> Titles_T: - # Parse title from various URL formats - import re - title_id = self.title for pattern in self.TITLE_RE: - match = re.search(pattern, self.title) - if match: - title_id = match.group("id") + m = re.search(pattern, self.title) + if m: + title_id = m.group("id") break - # Handle stream-tv redirects if "/" not in title_id: - r = self.session.get(self.config["endpoints"]["stream_tv"].format(title_id=title_id)) - match = re.search(r"/watch/asset(/[^']+)", r.text) - if match: - title_id = match.group(1) + r = self.session.get(f"https://www.peacocktv.com/stream-tv/{title_id}") + m = re.search(r"/watch/asset(/[^'\"?#\s]+)", r.text) + if m: + title_id = m.group(1) else: - raise ValueError("Title ID not found or invalid") + raise ValueError(f"Could not resolve slug: {title_id!r}") if not title_id.startswith("/"): title_id = f"/{title_id}" @@ -132,22 +281,16 @@ class PCOK(Service): if title_id.startswith("/movies/"): self.movie = True + sky_h = self._sky_headers() res = self.session.get( url=self.config["endpoints"]["node"], - params={ - "slug": title_id, - "represent": "(items(items))" - }, + params={"slug": title_id, "represent": "(items(items))"}, headers={ + **sky_h, "Accept": "*", "Referer": f"https://www.peacocktv.com/watch/asset{title_id}", - "X-SkyOTT-Device": self.config["client"]["device"], - "X-SkyOTT-Platform": self.config["client"]["platform"], - "X-SkyOTT-Proposition": self.config["client"]["proposition"], - "X-SkyOTT-Provider": self.config["client"]["provider"], - "X-SkyOTT-Territory": self.config["client"]["territory"], - "X-SkyOTT-Language": "en" - } + "X-SkyOTT-Language": "en", + }, ).json() if self.movie: @@ -156,299 +299,147 @@ class PCOK(Service): id_=title_id, service=self.__class__, name=res["attributes"]["title"], - year=res["attributes"]["year"], + year=res["attributes"].get("year"), data=res, ) ]) - else: - episodes = [] - for season in res["relationships"]["items"]["data"]: - for episode in season["relationships"]["items"]["data"]: - episodes.append(episode) - episode_titles = [] - for x in episodes: - episode_titles.append( - Episode( - id_=title_id, - service=self.__class__, - title=res["attributes"]["title"], - season=x["attributes"].get("seasonNumber"), - number=x["attributes"].get("episodeNumber"), - name=x["attributes"].get("title"), - year=x["attributes"].get("year"), - data=x - ) - ) - return Series(episode_titles) + episodes = [ + ep + for season in res.get("relationships", {}).get("items", {}).get("data", []) + for ep in season.get("relationships", {}).get("items", {}).get("data", []) + ] + + return Series([ + Episode( + id_=title_id, + service=self.__class__, + title=res["attributes"]["title"], + season=ep["attributes"].get("seasonNumber"), + number=ep["attributes"].get("episodeNumber"), + name=ep["attributes"].get("title"), + year=ep["attributes"].get("year"), + data=ep, + ) + for ep in episodes + ]) def get_tracks(self, title: Title_T) -> Tracks: - supported_colour_spaces = ["SDR"] + attrs = title.data["attributes"] + formats = attrs.get("formats", {}) + + want_uhd = self.vcodec == "H265" + if want_uhd and "UHD" in formats: + content_id = formats["UHD"]["contentId"] + elif "HD" in formats: + content_id = formats["HD"]["contentId"] + else: + content_id = next(iter(formats.values()), {}).get("contentId", "") + + variant_id = attrs.get("providerVariantId", "") if self.range == "HDR10": - self.log.info("Switched dynamic range to HDR10") - supported_colour_spaces = ["HDR10"] + colour_spaces = ["HDR10"] elif self.range == "DV": - self.log.info("Switched dynamic range to DV") - supported_colour_spaces = ["DolbyVision"] + colour_spaces = ["DolbyVision"] + else: + colour_spaces = ["SDR"] - content_id = title.data["attributes"]["formats"]["HD"]["contentId"] - variant_id = title.data["attributes"]["providerVariantId"] + primary_drm = "PLAYREADY" if self.use_playready else "WIDEVINE" - sky_headers = { - "X-SkyOTT-Agent": ".".join([ - self.config["client"]["proposition"].lower(), - self.config["client"]["device"].lower(), - self.config["client"]["platform"].lower() - ]), - "X-SkyOTT-PinOverride": "false", - "X-SkyOTT-Provider": self.config["client"]["provider"], - "X-SkyOTT-Territory": self.config["client"]["territory"], - "X-SkyOTT-UserToken": self.tokens["userToken"] - } + capabilities = [ + { + "protection": primary_drm, + "container": "ISOBMFF", + "transport": "DASH", + "acodec": "AAC", + "vcodec": self.vcodec, + } + ] - body = json.dumps({ - "device": { - "capabilities": [ - { - "protection": "PLAYREADY", - "container": "ISOBMFF", - "transport": "DASH", - "acodec": "AAC", - "vcodec": "H265" if self.vcodec == "H265" else "H264", - }, - { - "protection": "PLAYREADY", - "container": "ISOBMFF", - "transport": "DASH", - "acodec": "AAC", - "vcodec": "H265" if self.vcodec == "H265" else "H264", - } - ], - "maxVideoFormat": "UHD" if self.vcodec == "H265" else "HD", - "supportedColourSpaces": supported_colour_spaces, - "model": self.config["client"]["platform"], - "hdcpEnabled": "true" + sky_h = self._sky_headers({"X-SkyOTT-UserToken": self.tokens["userToken"]}) + body = json.dumps( + { + "device": { + "capabilities": capabilities, + "maxVideoFormat": "UHD" if want_uhd else "HD", + "supportedColourSpaces": colour_spaces, + "model": self.prof["platform"], + "hdcpEnabled": "true", + }, + "client": {"thirdParties": ["FREEWHEEL", "YOSPACE"]}, + "contentId": content_id, + "providerVariantId": variant_id, + "parentalControlPin": "null", + "personaParentalControlRating": 9, }, - "client": { - "thirdParties": ["FREEWHEEL", "YOSPACE"] - }, - "contentId": content_id, - "providerVariantId": variant_id, - "parentalControlPin": "null" - }, separators=(",", ":")) + separators=(",", ":"), + ) - manifest = self.session.post( + r = self.session.post( url=self.config["endpoints"]["vod"], data=body, - headers=dict(**sky_headers, **{ + headers={ + **sky_h, "Accept": "application/vnd.playvod.v1+json", "Content-Type": "application/vnd.playvod.v1+json", - "X-Sky-Signature": self.create_signature_header( - method="POST", - path="/video/playouts/vod", - sky_headers=sky_headers, - body=body, - timestamp=int(time.time()) - ) - }) - ).json() + "X-Sky-Signature": self._sign("POST", "/video/playouts/vod", sky_h, body), + }, + ) + manifest = r.json() if "errorCode" in manifest: - raise ValueError(f"An error occurred: {manifest['description']} [{manifest['errorCode']}]") + raise ValueError( + f"Playout error: {manifest.get('description', 'unknown')} [{manifest['errorCode']}]" + ) - self.license_api = manifest["protection"]["licenceAcquisitionUrl"] - self.license_bt = manifest["protection"]["licenceToken"] + self.license_url = manifest["protection"]["licenceAcquisitionUrl"] - tracks = DASH.from_url( - url=manifest["asset"]["endpoints"][0]["url"], - session=self.session - ).to_tracks(language=Language.get("en")) + endpoints = manifest["asset"]["endpoints"] + dash_url = next( + (e["url"] for e in endpoints if e.get("cdn", "").upper() == "FASTLY"), + endpoints[0]["url"] if endpoints else None, + ) + if not dash_url: + raise ValueError("No DASH endpoint in playout response.") + + tracks = DASH.from_url(url=dash_url, session=self.session).to_tracks(language=Language.get("en")) - # Set HDR attributes for video in tracks.videos: - if supported_colour_spaces == ["HDR10"]: + if colour_spaces == ["HDR10"]: video.range = Video.Range.HDR10 - elif supported_colour_spaces == ["DolbyVision"]: + elif colour_spaces == ["DolbyVision"]: video.range = Video.Range.DV else: video.range = Video.Range.SDR - # Fix audio description language - for track in tracks.audio: - if track.language.territory == "AD": - track.language.territory = None + for audio in tracks.audio: + if audio.language.territory == "AD": + audio.language.territory = None return tracks def get_chapters(self, title: Title_T) -> Chapters: - """Get chapters for the title. Peacock doesn't typically provide chapter data.""" - return Chapters([]) + return Chapters() + + def _license_request(self, challenge: bytes) -> bytes: + path = urlparse(self.license_url).path + r = self.session.post( + url=self.license_url, + data=challenge, + headers={ + "X-Sky-Signature": self._sign("POST", path, {}, challenge), + }, + ) + r.raise_for_status() + return r.content + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]: + if not self.license_url: + return None + return self._license_request(challenge) def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]: - """Retrieve a PlayReady license for a given track.""" - if not self.license_api: + if not self.license_url: return None - - response = self.session.post( - url=self.license_api, - headers={ - "Accept": "*", - "X-Sky-Signature": self.create_signature_header( - method="POST", - path="/" + self.license_api.split("://", 2)[1].split("/", 1)[1], - sky_headers={}, - body="", - timestamp=int(time.time()) - ) - }, - data=challenge - ) - response.raise_for_status() - return response.content - - @staticmethod - def calculate_sky_header_md5(headers): - if len(headers.items()) > 0: - headers_str = "\n".join(f"{x[0].lower()}: {x[1]}" for x in headers.items()) + "\n" - else: - headers_str = "{}" - return str(hashlib.md5(headers_str.encode()).hexdigest()) - - @staticmethod - def calculate_body_md5(body): - return str(hashlib.md5(body.encode()).hexdigest()) - - def calculate_signature(self, msg): - digest = hmac.new(self.hmac_key, bytes(msg, "utf-8"), hashlib.sha1).digest() - return str(base64.b64encode(digest), "utf-8") - - def create_signature_header(self, method, path, sky_headers, body, timestamp): - data = "\n".join([ - method.upper(), - path, - "", - self.config["client"]["client_sdk"], - "1.0", - self.calculate_sky_header_md5(sky_headers), - str(timestamp), - self.calculate_body_md5(body) - ]) + "\n" - - signature_hmac = self.calculate_signature(data) - - return self.config["security"]["signature_format"].format( - client=self.config["client"]["client_sdk"], - signature=signature_hmac, - timestamp=timestamp - ) - - def get_tokens(self): - # Try to get cached tokens - cache = self.cache.get(f"tokens_{self.profile}_{self.config['client']['id']}") - - if cache and cache.data.get("tokenExpiryTime"): - tokens_expiration = cache.data.get("tokenExpiryTime") - if datetime.strptime(tokens_expiration, "%Y-%m-%dT%H:%M:%S.%fZ") > datetime.now(): - return cache.data - - # Get all SkyOTT headers - sky_headers = { - "X-SkyOTT-Agent": ".".join([ - self.config["client"]["proposition"], - self.config["client"]["device"], - self.config["client"]["platform"] - ]).lower(), - "X-SkyOTT-Device": self.config["client"]["device"], - "X-SkyOTT-Platform": self.config["client"]["platform"], - "X-SkyOTT-Proposition": self.config["client"]["proposition"], - "X-SkyOTT-Provider": self.config["client"]["provider"], - "X-SkyOTT-Territory": self.config["client"]["territory"] - } - - try: - # Call personas endpoint to get the accounts personaId - personas = self.session.get( - url=self.config["endpoints"]["personas"], - headers=dict(**sky_headers, **{ - "Accept": "application/vnd.persona.v1+json", - "Content-Type": "application/vnd.persona.v1+json", - "X-SkyOTT-TokenType": self.config["client"]["auth_scheme"] - }) - ).json() - except Exception as e: - raise EnvironmentError(f"Unable to get persona ID: {e}") - - persona = personas["personas"][0]["personaId"] - - # Craft the body data - body = json.dumps({ - "auth": { - "authScheme": self.config["client"]["auth_scheme"], - "authIssuer": self.config["client"]["auth_issuer"], - "provider": self.config["client"]["provider"], - "providerTerritory": self.config["client"]["territory"], - "proposition": self.config["client"]["proposition"], - "personaId": persona - }, - "device": { - "type": self.config["client"]["device"], - "platform": self.config["client"]["platform"], - "id": self.config["client"]["id"], - "drmDeviceId": self.config["client"]["drm_device_id"] - } - }, separators=(",", ":")) - - # Get the tokens - tokens = self.session.post( - url=self.config["endpoints"]["tokens"], - headers=dict(**sky_headers, **{ - "Accept": "application/vnd.tokens.v1+json", - "Content-Type": "application/vnd.tokens.v1+json", - "X-Sky-Signature": self.create_signature_header( - method="POST", - path="/auth/tokens", - sky_headers=sky_headers, - body=body, - timestamp=int(time.time()) - ) - }), - data=body - ).json() - - # Cache the tokens - if not cache: - cache = self.cache.get(f"tokens_{self.profile}_{self.config['client']['id']}") - cache.set(data=tokens) - - return tokens - - def verify_tokens(self): - """Verify the tokens by calling the /auth/users/me endpoint""" - sky_headers = { - "X-SkyOTT-Device": self.config["client"]["device"], - "X-SkyOTT-Platform": self.config["client"]["platform"], - "X-SkyOTT-Proposition": self.config["client"]["proposition"], - "X-SkyOTT-Provider": self.config["client"]["provider"], - "X-SkyOTT-Territory": self.config["client"]["territory"], - "X-SkyOTT-UserToken": self.tokens["userToken"] - } - - try: - self.session.get( - url=self.config["endpoints"]["me"], - headers=dict(**sky_headers, **{ - "Accept": "application/vnd.userinfo.v2+json", - "Content-Type": "application/vnd.userinfo.v2+json", - "X-Sky-Signature": self.create_signature_header( - method="GET", - path="/auth/users/me", - sky_headers=sky_headers, - body="", - timestamp=int(time.time()) - ) - }) - ) - return True - except Exception: - return False + return self._license_request(challenge) \ No newline at end of file diff --git a/packages/envied/src/envied/services/PCOK/config.yaml b/packages/envied/src/envied/services/PCOK/config.yaml old mode 100755 new mode 100644 index 0b9a880..8556571 --- a/packages/envied/src/envied/services/PCOK/config.yaml +++ b/packages/envied/src/envied/services/PCOK/config.yaml @@ -1,27 +1,33 @@ -endpoints: - stream_tv: 'https://www.peacocktv.com/stream-tv/{title_id}' - config: 'https://config.clients.peacocktv.com/{territory}/{provider}/{proposition}/{device}/PROD/{version}/config.json' - login: 'https://rango.id.peacocktv.com/signin/service/international' - personas: 'https://persona.id.peacocktv.com/persona-store/personas' - tokens: 'https://ovp.peacocktv.com/auth/tokens' - me: 'https://ovp.peacocktv.com/auth/users/me' - node: 'https://atom.peacocktv.com/adapter-calypso/v3/query/node' - vod: 'https://ovp.peacocktv.com/video/playouts/vod' +profiles: + tv: + platform: ANDROIDTV + device: TV + client_sdk: NBCU-ANDROID-v3 + hmac_key: JuLQgyFz9n89D9pxcN6ZWZXKWfgj2PNBUb32zybj + android: + platform: ANDROID + device: TABLET + client_sdk: NBCU-ANDROID-v3 + hmac_key: JuLQgyFz9n89D9pxcN6ZWZXKWfgj2PNBUb32zybj + web: + platform: PC + device: COMPUTER + client_sdk: NBCU-WEB-v4 + hmac_key: FvT9VtwvhtSZvqnExMsvDDTEvBqR3HdsMcBFtWYV client: - config_version: '1.0.8' - territory: 'US' - provider: 'NBCU' - proposition: 'NBCUOTT' - platform: 'ANDROID' # PC, ANDROID - device: 'TABLET' # COMPUTER, TABLET - id: 'Jcvf1y0whKOI29vRXcJy' - drm_device_id: 'UNKNOWN' - client_sdk: 'NBCU-WEB-v4' # NBCU-ANDROID-v3 NBCU-ANDRTV-v4 - auth_scheme: 'MESSO' - auth_issuer: 'NOWTV' + territory: US + provider: NBCU + proposition: NBCUOTT + auth_scheme: MESSO + profile: tv + device_id: PC + drm_device_id: UNKNOWN -security: - signature_hmac_key_v4: 'FvT9VtwvhtSZvqnExMsvDDTEvBqR3HdsMcBFtWYV' - signature_hmac_key_v6: 'izU6EJqqu6DOhOWSk5X4p9dod3fNqH7vzKtYDK8d' - signature_format: 'SkyOTT client="{client}",signature="{signature}",timestamp="{timestamp}",version="1.0"' +endpoints: + login: https://rango.id.peacocktv.com/signin/service/international + personas: https://persona.id.peacocktv.com/persona-store/personas + tokens: https://ovp.peacocktv.com/auth/tokens + me: https://ovp.peacocktv.com/auth/users/me + node: https://atom.peacocktv.com/adapter-calypso/v3/query/node + vod: https://ovp.peacocktv.com/video/playouts/vod \ No newline at end of file diff --git a/packages/envied/src/envied/services/RKTN/__init__.py b/packages/envied/src/envied/services/RKTN/__init__.py index de025a7..24f84c3 100644 --- a/packages/envied/src/envied/services/RKTN/__init__.py +++ b/packages/envied/src/envied/services/RKTN/__init__.py @@ -428,7 +428,7 @@ class RKTN(Service): self.log.info(f"Searching for additional H.264 video tracks (pattern: {codec_prefix})...") - # Usar el directorio temp de Unshackle + # Usar el directorio temp de.envied temp_file = os.path.join(str(config.directories.temp), "video_test.mp4") @@ -514,7 +514,7 @@ class RKTN(Service): # Codecs a probar (en orden de preferencia) codecs_to_try = ["ec-3", "ac-3", "dts", "mp4a"] - # Usar el directorio temp de Unshackle + # Usar el directorio temp de.envied temp_file = os.path.join(str(config.directories.temp), "audio_test.mp4")