mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
yaml addition
This commit is contained in:
@@ -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"
|
|
||||||
|
|||||||
Executable → Regular
+326
-335
@@ -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"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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")
|
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", [])
|
||||||
Episode(
|
for ep in season.get("relationships", {}).get("items", {}).get("data", [])
|
||||||
id_=title_id,
|
]
|
||||||
service=self.__class__,
|
|
||||||
title=res["attributes"]["title"],
|
return Series([
|
||||||
season=x["attributes"].get("seasonNumber"),
|
Episode(
|
||||||
number=x["attributes"].get("episodeNumber"),
|
id_=title_id,
|
||||||
name=x["attributes"].get("title"),
|
service=self.__class__,
|
||||||
year=x["attributes"].get("year"),
|
title=res["attributes"]["title"],
|
||||||
data=x
|
season=ep["attributes"].get("seasonNumber"),
|
||||||
)
|
number=ep["attributes"].get("episodeNumber"),
|
||||||
)
|
name=ep["attributes"].get("title"),
|
||||||
return Series(episode_titles)
|
year=ep["attributes"].get("year"),
|
||||||
|
data=ep,
|
||||||
|
)
|
||||||
|
for ep in episodes
|
||||||
|
])
|
||||||
|
|
||||||
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"]})
|
||||||
"device": {
|
body = json.dumps(
|
||||||
"capabilities": [
|
{
|
||||||
{
|
"device": {
|
||||||
"protection": "PLAYREADY",
|
"capabilities": capabilities,
|
||||||
"container": "ISOBMFF",
|
"maxVideoFormat": "UHD" if want_uhd else "HD",
|
||||||
"transport": "DASH",
|
"supportedColourSpaces": colour_spaces,
|
||||||
"acodec": "AAC",
|
"model": self.prof["platform"],
|
||||||
"vcodec": "H265" if self.vcodec == "H265" else "H264",
|
"hdcpEnabled": "true",
|
||||||
},
|
},
|
||||||
{
|
"client": {"thirdParties": ["FREEWHEEL", "YOSPACE"]},
|
||||||
"protection": "PLAYREADY",
|
"contentId": content_id,
|
||||||
"container": "ISOBMFF",
|
"providerVariantId": variant_id,
|
||||||
"transport": "DASH",
|
"parentalControlPin": "null",
|
||||||
"acodec": "AAC",
|
"personaParentalControlRating": 9,
|
||||||
"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": {
|
separators=(",", ":"),
|
||||||
"thirdParties": ["FREEWHEEL", "YOSPACE"]
|
)
|
||||||
},
|
|
||||||
"contentId": content_id,
|
|
||||||
"providerVariantId": variant_id,
|
|
||||||
"parentalControlPin": "null"
|
|
||||||
}, 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,
|
manifest = r.json()
|
||||||
body=body,
|
|
||||||
timestamp=int(time.time())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
).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
|
|
||||||
Executable → Regular
+30
-24
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user