yaml addition

This commit is contained in:
VineFeeder
2026-04-16 10:45:17 +01:00
parent 02da12f6f8
commit 68f1bbae1a
4 changed files with 502 additions and 604 deletions
+140 -239
View File
@@ -1,64 +1,90 @@
# Herin a full example for envied.yaml # Group or Username to postfix to the end of all download fienames following a dash
#tag: ''
# 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
# Set terminal background color (custom option not in CONFIG.md) # Set terminal background color (custom option not in CONFIG.md)
set_terminal_bg: false 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 configuration
muxing: muxing:
set_title: false 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: <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
# Widevine pssh display; fold, crop or ellipsis
pssh_display: ellipsis
# Login credentials for each Service # Login credentials for each Service
credentials: credentials:
# Direct credentials (no profile support) ALL4: email:password
EXAMPLE: email@example.com:password ROKU: email:password
TVNZ: email:password
CBC: email:password
CRAVE: email:password
# Per-profile credentials with default fallback # Override default directories used across unshackle
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
directories: directories:
cache: Cache cache: Cache
cookies: Cookies cookies: Cookies
@@ -70,138 +96,66 @@ directories:
prds: PRDs prds: PRDs
# Additional directories that can be configured: # Additional directories that can be configured:
# commands: Commands # commands: Commands
services: #services:
- /path/to/services # - ./envied/services/
- /other/path/to/services vaults: vaults/
# vaults: Vaults
# fonts: Fonts # fonts: Fonts
# Pre-define which Widevine or PlayReady device to use for each Service # Pre-define which Widevine or PlayReady device to use for each Service
cdm: cdm:
# Global default CDM device (fallback for all services/profiles) default: device
default: WVD_1 # 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: remote_cdm:
- name: "chrome" - name: "DRMLABL3"
device_name: chrome "Device Type": ANDROID
device_type: CHROME "Device Name": "l3"
system_id: 27175 "System ID": 8159
security_level: 3 "Security Level": 3
host: https://domain.com/api "Host": "http://api2.drmlab.io"
secret: secret_key "Secret": "drmlab"
- 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: "decrypt_labs_chrome" remote_cdm:
type: "decrypt_labs" # Required to identify as DecryptLabs CDM - name: "DRMLAB_SL3K"
device_name: "ChromeCDM" # Scheme identifier - must match exactly "Device Type": PLAYREADY
device_type: CHROME "Device Name": "sl3k"
system_id: 4464 # Doesn't matter "System ID": 0
security_level: 3 "Security Level": 3000
host: "https://keyxtractor.decryptlabs.com" "Host": "http://api2.drmlab.io/playready"
secret: "your_decrypt_labs_api_key_here" # Replace with your API key "Secret": "drmlab"
- 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) # 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: key_vaults:
- type: SQLite - type: SQLite
name: Local name: Local vault
path: key_store.db path: key_store.db
# Additional vault types:
# - type: API #- type: HTTPAPI
# name: "Remote Vault" # name: drmlab
# uri: "https://key-vault.example.com" # host: http://api.drmlab.io/vault/
# token: "secret_token" # password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
# no_push: true # This vault will only provide keys, not receive them - type: HTTP
# - type: MySQL name: DRMLab
# name: "MySQL Vault" host: http://api.drmlab.io/vault/
# host: "127.0.0.1" password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
# port: 3306 api_mode: json
# database: vault no_push: false
# username: user
# password: pass
# no_push: false # Default behavior - vault both provides and receives keys
# Choose what software to use to download data # Choose what software to use to download data
downloader: aria2c #downloader: n_m3u8dl_re
# Options: requests | aria2c | curl_impersonate | n_m3u8dl_re # Options: requests | aria2c | curl_impersonate | n_m3u8dl_re
# Can also be a mapping: # Can also be a mapping:
# downloader: downloader:
# NF: requests # NF: requests
# AMZN: n_m3u8dl_re # AMZN: n_m3u8dl_re
# DSNP: n_m3u8dl_re ALL4: n_m3u8dl_re
# default: requests BBC: requests
default: n_m3u8dl_re
# aria2c downloader configuration # aria2c downloader configuration
aria2c: aria2c:
@@ -222,14 +176,12 @@ curl_impersonate:
# Pre-define default options and switches of the dl command # Pre-define default options and switches of the dl command
dl: dl:
best: true
sub_format: srt sub_format: srt
downloads: 4 downloads: 4
workers: 16 workers: 16
lang:
- en
- fr
EXAMPLE:
bitrate: CBR
# Chapter Name to use when exporting a Chapter without a Name # Chapter Name to use when exporting a Chapter without a Name
chapter_fallback_name: "Chapter {j:02}" chapter_fallback_name: "Chapter {j:02}"
@@ -237,9 +189,9 @@ chapter_fallback_name: "Chapter {j:02}"
# Case-Insensitive dictionary of headers for all Services # Case-Insensitive dictionary of headers for all Services
headers: headers:
Accept-Language: "en-US,en;q=0.8" 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: filenames:
log: "envied_{name}_{time}.log" log: "envied_{name}_{time}.log"
config: "config.yaml" config: "config.yaml"
@@ -248,7 +200,11 @@ filenames:
subtitle: "Subtitle_{id}_{language}.srt" subtitle: "Subtitle_{id}_{language}.srt"
# API key for The Movie Database (TMDB) # API key for The Movie Database (TMDB)
tmdb_api_key: "" # tmdb_api_key: key
# tag_imdb_tmdb (bool)
tag_imdb_tmdb: true
# conversion_method: # conversion_method:
# - auto (default): Smart routing - subby for WebVTT/SAMI, standard for others # - auto (default): Smart routing - subby for WebVTT/SAMI, standard for others
@@ -259,74 +215,19 @@ subtitle:
conversion_method: auto conversion_method: auto
sdh_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 # Configuration data for each Service
services: services:
# Service-specific configuration goes here # 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 # Legacy NordVPN configuration (use proxy_providers instead)
EXAMPLE: #proxy_providers:
# Global service config # nordvpn:
api_key: "service_api_key" # username: gobbldegook
# password: twaddle
# Service certificate for Widevine L1/L2 (base64 encoded) # server_map:
# This certificate is automatically used when L1/L2 schemes are selected # us: 6918
# Services obtain this from their DRM provider or license server # uk: 2613
certificate: | # nz: 100
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"
+316 -325
View File
@@ -1,16 +1,16 @@
from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import json import json
import re
import time import time
from datetime import datetime from datetime import datetime
from http.cookiejar import CookieJar from http.cookiejar import CookieJar
from typing import Optional from typing import Optional
from urllib.parse import urlparse
import click import click
from langcodes import Language from langcodes import Language
from pyplayready.cdm import Cdm as PlayReadyCdm
from envied.core.constants import AnyTrack from envied.core.constants import AnyTrack
from envied.core.credential import Credential from envied.core.credential import Credential
from envied.core.manifests import DASH from envied.core.manifests import DASH
@@ -21,26 +21,22 @@ from envied.core.tracks import Chapters, Tracks, Video
class PCOK(Service): class PCOK(Service):
""" """
Service code for NBC's Peacock streaming service (https://peacocktv.com). Service code for Peacock TV (https://peacocktv.com)
Version: 1.0.0
Authorization: Cookies Author: n0stal6ic
Security: UHD@-- FHD@SL* Authorization: Cookies, Credentials
Geofence: US
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.
""" """
ALIASES = ("PCOK", "peacock") ALIASES = ("PCOK", "peacock", "peacocktv")
GEOFENCE = ("US",) GEOFENCE = ("US",)
TITLE_RE = [ TITLE_RE = [
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>movies/[a-z0-9/./-]+/[a-f0-9-]+)", r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/movies/[a-z0-9_./-]+/[a-f0-9-]{36})",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>tv/[a-z0-9/./-]+/[a-f0-9-]+)", r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/tv/[a-z0-9_./-]+/[a-f0-9-]{36})",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>tv/[a-z0-9-/.]+/\d+)", r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/tv/[a-z0-9_./-]+/\d+)",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>news/[a-z0-9/./-]+/[a-f0-9-]+)", r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/news/[a-z0-9_./-]+/[a-f0-9-]{36})",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>news/[a-z0-9-/.]+/\d+)", r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/-/[a-z0-9_./-]+/\d+)",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>-/[a-z0-9-/.]+/\d+)", r"(?:https?://(?:www\.)?peacocktv\.com/stream-tv/)?(?P<id>[a-z0-9-]+)$",
r"(?:https?://(?:www\.)?peacocktv\.com/stream-tv/)?(?P<id>[a-z0-9-/.]+)",
] ]
@staticmethod @staticmethod
@@ -51,15 +47,11 @@ class PCOK(Service):
def cli(ctx, **kwargs): def cli(ctx, **kwargs):
return PCOK(ctx, **kwargs) return PCOK(ctx, **kwargs)
def __init__(self, ctx, title, movie): def __init__(self, ctx, title: str, movie: bool):
super().__init__(ctx) super().__init__(ctx)
self.title = title self.title = title
self.movie = movie 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_") range_param = ctx.parent.params.get("range_")
self.range = range_param[0].name if range_param else "SDR" 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") vcodec_param = ctx.parent.params.get("vcodec")
self.vcodec = vcodec_param if vcodec_param else "H264" self.vcodec = vcodec_param if vcodec_param else "H264"
if self.config is None: self.profile_name = ctx.parent.params.get("profile") or "default"
raise Exception("Config is missing!")
profile_name = ctx.parent.params.get("profile") prof_key = self.config["client"].get("profile", "tv")
if profile_name is None: profiles = self.config.get("profiles", {})
profile_name = "default" if prof_key not in profiles:
self.profile = profile_name 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 try:
self.tokens = None from pyplayready.cdm import Cdm as PlayReadyCdm
self.license_api = None self.use_playready: bool = isinstance(ctx.obj.cdm, PlayReadyCdm)
self.license_bt = None 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: def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential) super().authenticate(cookies, credential)
if not cookies:
raise EnvironmentError("Service requires Cookies for Authentication.")
self.session.headers.update({"Origin": "https://www.peacocktv.com"}) self.session.headers.update({"Origin": "https://www.peacocktv.com"})
self.log.info("Getting Peacock Client configuration")
if self.config["client"]["platform"] != "PC": if credential and credential.username and credential.password:
self.service_config = self.session.get( self.log.info("Authenticating with email/password credentials.")
url=self.config["endpoints"]["config"].format( self._login(credential.username, credential.password)
territory=self.config["client"]["territory"], elif not cookies:
provider=self.config["client"]["provider"], raise EnvironmentError("Peacock requires cookies or credential.")
proposition=self.config["client"]["proposition"],
device=self.config["client"]["platform"], self.log.info("Fetching authorization tokens.")
version=self.config["client"]["config_version"], 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"],
},
) )
).json() if r.status_code not in (200, 201):
try:
code = (
r.json()
.get("properties", {})
.get("errors", {})
.get("categoryErrors", [{}])[0]
.get("code", "unknown")
)
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") def _sky_headers(self, extra: Optional[dict] = None) -> dict:
self.log.info("Getting Authorization Tokens") h = {
self.tokens = self.get_tokens() "X-SkyOTT-Device": self.prof["device"],
self.log.info("Verifying Authorization Tokens") "X-SkyOTT-Platform": self.prof["platform"],
if not self.verify_tokens(): "X-SkyOTT-Proposition": self.config["client"]["proposition"],
raise EnvironmentError("Failed! Cookies might be outdated.") "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: def get_titles(self) -> Titles_T:
# Parse title from various URL formats
import re
title_id = self.title title_id = self.title
for pattern in self.TITLE_RE: for pattern in self.TITLE_RE:
match = re.search(pattern, self.title) m = re.search(pattern, self.title)
if match: if m:
title_id = match.group("id") title_id = m.group("id")
break break
# Handle stream-tv redirects
if "/" not in title_id: if "/" not in title_id:
r = self.session.get(self.config["endpoints"]["stream_tv"].format(title_id=title_id)) r = self.session.get(f"https://www.peacocktv.com/stream-tv/{title_id}")
match = re.search(r"/watch/asset(/[^']+)", r.text) m = re.search(r"/watch/asset(/[^'\"?#\s]+)", r.text)
if match: if m:
title_id = match.group(1) title_id = m.group(1)
else: else:
raise ValueError("Title ID not found or invalid") raise ValueError(f"Could not resolve slug: {title_id!r}")
if not title_id.startswith("/"): if not title_id.startswith("/"):
title_id = f"/{title_id}" title_id = f"/{title_id}"
@@ -132,22 +281,16 @@ class PCOK(Service):
if title_id.startswith("/movies/"): if title_id.startswith("/movies/"):
self.movie = True self.movie = True
sky_h = self._sky_headers()
res = self.session.get( res = self.session.get(
url=self.config["endpoints"]["node"], url=self.config["endpoints"]["node"],
params={ params={"slug": title_id, "represent": "(items(items))"},
"slug": title_id,
"represent": "(items(items))"
},
headers={ headers={
**sky_h,
"Accept": "*", "Accept": "*",
"Referer": f"https://www.peacocktv.com/watch/asset{title_id}", "Referer": f"https://www.peacocktv.com/watch/asset{title_id}",
"X-SkyOTT-Device": self.config["client"]["device"], "X-SkyOTT-Language": "en",
"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"
}
).json() ).json()
if self.movie: if self.movie:
@@ -156,299 +299,147 @@ class PCOK(Service):
id_=title_id, id_=title_id,
service=self.__class__, service=self.__class__,
name=res["attributes"]["title"], name=res["attributes"]["title"],
year=res["attributes"]["year"], year=res["attributes"].get("year"),
data=res, data=res,
) )
]) ])
else:
episodes = []
for season in res["relationships"]["items"]["data"]:
for episode in season["relationships"]["items"]["data"]:
episodes.append(episode)
episode_titles = [] episodes = [
for x in episodes: ep
episode_titles.append( for season in res.get("relationships", {}).get("items", {}).get("data", [])
for ep in season.get("relationships", {}).get("items", {}).get("data", [])
]
return Series([
Episode( Episode(
id_=title_id, id_=title_id,
service=self.__class__, service=self.__class__,
title=res["attributes"]["title"], title=res["attributes"]["title"],
season=x["attributes"].get("seasonNumber"), season=ep["attributes"].get("seasonNumber"),
number=x["attributes"].get("episodeNumber"), number=ep["attributes"].get("episodeNumber"),
name=x["attributes"].get("title"), name=ep["attributes"].get("title"),
year=x["attributes"].get("year"), year=ep["attributes"].get("year"),
data=x data=ep,
) )
) for ep in episodes
return Series(episode_titles) ])
def get_tracks(self, title: Title_T) -> Tracks: 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": if self.range == "HDR10":
self.log.info("Switched dynamic range to HDR10") colour_spaces = ["HDR10"]
supported_colour_spaces = ["HDR10"]
elif self.range == "DV": elif self.range == "DV":
self.log.info("Switched dynamic range to DV") colour_spaces = ["DolbyVision"]
supported_colour_spaces = ["DolbyVision"] else:
colour_spaces = ["SDR"]
content_id = title.data["attributes"]["formats"]["HD"]["contentId"] primary_drm = "PLAYREADY" if self.use_playready else "WIDEVINE"
variant_id = title.data["attributes"]["providerVariantId"]
sky_headers = { capabilities = [
"X-SkyOTT-Agent": ".".join([ {
self.config["client"]["proposition"].lower(), "protection": primary_drm,
self.config["client"]["device"].lower(), "container": "ISOBMFF",
self.config["client"]["platform"].lower() "transport": "DASH",
]), "acodec": "AAC",
"X-SkyOTT-PinOverride": "false", "vcodec": self.vcodec,
"X-SkyOTT-Provider": self.config["client"]["provider"],
"X-SkyOTT-Territory": self.config["client"]["territory"],
"X-SkyOTT-UserToken": self.tokens["userToken"]
} }
]
body = json.dumps({ sky_h = self._sky_headers({"X-SkyOTT-UserToken": self.tokens["userToken"]})
body = json.dumps(
{
"device": { "device": {
"capabilities": [ "capabilities": capabilities,
{ "maxVideoFormat": "UHD" if want_uhd else "HD",
"protection": "PLAYREADY", "supportedColourSpaces": colour_spaces,
"container": "ISOBMFF", "model": self.prof["platform"],
"transport": "DASH", "hdcpEnabled": "true",
"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"
},
"client": {
"thirdParties": ["FREEWHEEL", "YOSPACE"]
}, },
"client": {"thirdParties": ["FREEWHEEL", "YOSPACE"]},
"contentId": content_id, "contentId": content_id,
"providerVariantId": variant_id, "providerVariantId": variant_id,
"parentalControlPin": "null" "parentalControlPin": "null",
}, separators=(",", ":")) "personaParentalControlRating": 9,
},
separators=(",", ":"),
)
manifest = self.session.post( r = self.session.post(
url=self.config["endpoints"]["vod"], url=self.config["endpoints"]["vod"],
data=body, data=body,
headers=dict(**sky_headers, **{ headers={
**sky_h,
"Accept": "application/vnd.playvod.v1+json", "Accept": "application/vnd.playvod.v1+json",
"Content-Type": "application/vnd.playvod.v1+json", "Content-Type": "application/vnd.playvod.v1+json",
"X-Sky-Signature": self.create_signature_header( "X-Sky-Signature": self._sign("POST", "/video/playouts/vod", sky_h, body),
method="POST", },
path="/video/playouts/vod",
sky_headers=sky_headers,
body=body,
timestamp=int(time.time())
) )
}) manifest = r.json()
).json()
if "errorCode" in manifest: 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_url = manifest["protection"]["licenceAcquisitionUrl"]
self.license_bt = manifest["protection"]["licenceToken"]
tracks = DASH.from_url( endpoints = manifest["asset"]["endpoints"]
url=manifest["asset"]["endpoints"][0]["url"], dash_url = next(
session=self.session (e["url"] for e in endpoints if e.get("cdn", "").upper() == "FASTLY"),
).to_tracks(language=Language.get("en")) 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: for video in tracks.videos:
if supported_colour_spaces == ["HDR10"]: if colour_spaces == ["HDR10"]:
video.range = Video.Range.HDR10 video.range = Video.Range.HDR10
elif supported_colour_spaces == ["DolbyVision"]: elif colour_spaces == ["DolbyVision"]:
video.range = Video.Range.DV video.range = Video.Range.DV
else: else:
video.range = Video.Range.SDR video.range = Video.Range.SDR
# Fix audio description language for audio in tracks.audio:
for track in tracks.audio: if audio.language.territory == "AD":
if track.language.territory == "AD": audio.language.territory = None
track.language.territory = None
return tracks return tracks
def get_chapters(self, title: Title_T) -> Chapters: 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]: 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_url:
if not self.license_api:
return None return None
return self._license_request(challenge)
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
+30 -24
View File
@@ -1,27 +1,33 @@
endpoints: profiles:
stream_tv: 'https://www.peacocktv.com/stream-tv/{title_id}' tv:
config: 'https://config.clients.peacocktv.com/{territory}/{provider}/{proposition}/{device}/PROD/{version}/config.json' platform: ANDROIDTV
login: 'https://rango.id.peacocktv.com/signin/service/international' device: TV
personas: 'https://persona.id.peacocktv.com/persona-store/personas' client_sdk: NBCU-ANDROID-v3
tokens: 'https://ovp.peacocktv.com/auth/tokens' hmac_key: JuLQgyFz9n89D9pxcN6ZWZXKWfgj2PNBUb32zybj
me: 'https://ovp.peacocktv.com/auth/users/me' android:
node: 'https://atom.peacocktv.com/adapter-calypso/v3/query/node' platform: ANDROID
vod: 'https://ovp.peacocktv.com/video/playouts/vod' device: TABLET
client_sdk: NBCU-ANDROID-v3
hmac_key: JuLQgyFz9n89D9pxcN6ZWZXKWfgj2PNBUb32zybj
web:
platform: PC
device: COMPUTER
client_sdk: NBCU-WEB-v4
hmac_key: FvT9VtwvhtSZvqnExMsvDDTEvBqR3HdsMcBFtWYV
client: client:
config_version: '1.0.8' territory: US
territory: 'US' provider: NBCU
provider: 'NBCU' proposition: NBCUOTT
proposition: 'NBCUOTT' auth_scheme: MESSO
platform: 'ANDROID' # PC, ANDROID profile: tv
device: 'TABLET' # COMPUTER, TABLET device_id: PC
id: 'Jcvf1y0whKOI29vRXcJy' drm_device_id: UNKNOWN
drm_device_id: 'UNKNOWN'
client_sdk: 'NBCU-WEB-v4' # NBCU-ANDROID-v3 NBCU-ANDRTV-v4
auth_scheme: 'MESSO'
auth_issuer: 'NOWTV'
security: endpoints:
signature_hmac_key_v4: 'FvT9VtwvhtSZvqnExMsvDDTEvBqR3HdsMcBFtWYV' login: https://rango.id.peacocktv.com/signin/service/international
signature_hmac_key_v6: 'izU6EJqqu6DOhOWSk5X4p9dod3fNqH7vzKtYDK8d' personas: https://persona.id.peacocktv.com/persona-store/personas
signature_format: 'SkyOTT client="{client}",signature="{signature}",timestamp="{timestamp}",version="1.0"' 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
@@ -428,7 +428,7 @@ class RKTN(Service):
self.log.info(f"Searching for additional H.264 video tracks (pattern: {codec_prefix})...") 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") 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 a probar (en orden de preferencia)
codecs_to_try = ["ec-3", "ac-3", "dts", "mp4a"] 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") temp_file = os.path.join(str(config.directories.temp), "audio_test.mp4")