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 filenames following a dash
|
||||
tag: user_tag
|
||||
|
||||
# Enable/disable tagging with group name (default: true)
|
||||
tag_group_name: true
|
||||
|
||||
# Enable/disable tagging with IMDB/TMDB/TVDB details (default: true)
|
||||
tag_imdb_tmdb: true
|
||||
# Group or Username to postfix to the end of all download fienames following a dash
|
||||
#tag: ''
|
||||
|
||||
# Set terminal background color (custom option not in CONFIG.md)
|
||||
set_terminal_bg: false
|
||||
|
||||
# Set file naming convention
|
||||
# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264
|
||||
# false for style - Prime Suspect S07E01 The Final Act - Part One
|
||||
scene_naming: true
|
||||
|
||||
# Whether to include the year in series names for episodes and folders (default: true)
|
||||
# true for style - Show Name (2023) S01E01 Episode Name
|
||||
# false for style - Show Name S01E01 Episode Name
|
||||
series_year: true
|
||||
|
||||
# Check for updates from GitHub repository on startup (default: true)
|
||||
update_checks: true
|
||||
|
||||
# How often to check for updates, in hours (default: 24)
|
||||
update_check_interval: 24
|
||||
|
||||
# Title caching configuration
|
||||
# Cache title metadata to reduce redundant API calls
|
||||
title_cache_enabled: true # Enable/disable title caching globally (default: true)
|
||||
title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes)
|
||||
title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours)
|
||||
|
||||
# Muxing configuration
|
||||
muxing:
|
||||
set_title: false
|
||||
|
||||
# shakapackager or mp4decrypt
|
||||
decryption: shakapackager
|
||||
|
||||
# Custom output templates for filenames
|
||||
# Configure output_template in your unshackle.yaml to control filename format.
|
||||
# If not configured, default scene-style templates are used and a warning is shown.
|
||||
# Available variables: {title}, {year}, {season}, {episode}, {season_episode}, {episode_name},
|
||||
# {quality}, {resolution}, {source}, {audio}, {audio_channels}, {audio_full},
|
||||
# {video}, {hdr}, {hfr}, {atmos}, {dual}, {multi}, {tag}, {edition}, {repack},
|
||||
# {lang_tag}
|
||||
# Conditional variables (included only if present): Add ? suffix like {year?}, {episode_name?}, {hdr?}
|
||||
# Customize the templates below:
|
||||
#
|
||||
# Example outputs:
|
||||
# Scene movies: 'The.Matrix.1999.1080p.SERVICE.WEB-DL.DDP5.1.H.264-EXAMPLE'
|
||||
# Scene movies (HDR): 'Dune.2021.2160p.SERVICE.WEB-DL.DDP5.1.HDR10.H.265-EXAMPLE'
|
||||
# Scene movies (REPACK): 'Dune.2021.REPACK.2160p.SERVICE.WEB-DL.DDP5.1.H.265-EXAMPLE'
|
||||
# Scene series: 'Breaking.Bad.2008.S01E01.Pilot.1080p.SERVICE.WEB-DL.DDP5.1.H.264-EXAMPLE'
|
||||
# Plex movies: 'The Matrix (1999)'
|
||||
# Plex series: 'Breaking Bad S01E01 Pilot'
|
||||
output_template:
|
||||
# Scene-style naming (dot-separated)
|
||||
# movies: '{title}.{year}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
|
||||
# series: '{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
|
||||
# songs: '{track_number}.{title}.{repack?}.{edition?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}'
|
||||
#
|
||||
# Plex-friendly naming (space-separated, clean format)
|
||||
movies: '{title} ({year}) {quality}'
|
||||
series: '{title} {season_episode} {episode_name?}'
|
||||
songs: '{track_number}. {title}'
|
||||
#
|
||||
## Minimal naming (basic info only)
|
||||
# movies: '{title}.{year}.{quality}'
|
||||
# series: '{title}.{season_episode}.{episode_name?}'
|
||||
|
||||
### Custom scene-style with specific elements
|
||||
# movies: '{title}.{year}.{quality}.{hdr?}.{source}.WEB-DL.{audio_full}.{video}-{tag}'
|
||||
# series: '{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}-{tag}'
|
||||
|
||||
# Language-based tagging for output filenames
|
||||
# Automatically adds language identifiers (e.g., DANiSH, NORDiC, DKsubs) based on
|
||||
# audio and subtitle track languages. Rules are evaluated in order; first match wins.
|
||||
# Use {lang_tag?} in your output_template to place the tag in the filename.
|
||||
#
|
||||
# Conditions (all conditions in a rule must match):
|
||||
# audio: <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
|
||||
credentials:
|
||||
# Direct credentials (no profile support)
|
||||
EXAMPLE: email@example.com:password
|
||||
ALL4: email:password
|
||||
ROKU: email:password
|
||||
TVNZ: email:password
|
||||
CBC: email:password
|
||||
CRAVE: email:password
|
||||
|
||||
# Per-profile credentials with default fallback
|
||||
SERVICE_NAME:
|
||||
default: default@email.com:password # Used when no -p/--profile is specified
|
||||
profile1: user1@email.com:password1
|
||||
profile2: user2@email.com:password2
|
||||
|
||||
# Per-profile credentials without default (requires -p/--profile)
|
||||
SERVICE_NAME2:
|
||||
john: john@example.com:johnspassword
|
||||
jane: jane@example.com:janespassword
|
||||
|
||||
# You can also use list format for passwords with special characters
|
||||
SERVICE_NAME3:
|
||||
default: ["user@email.com", ":PasswordWith:Colons"]
|
||||
|
||||
# Override default directories used across envied
|
||||
# Override default directories used across unshackle
|
||||
directories:
|
||||
cache: Cache
|
||||
cookies: Cookies
|
||||
@@ -70,138 +96,66 @@ directories:
|
||||
prds: PRDs
|
||||
# Additional directories that can be configured:
|
||||
# commands: Commands
|
||||
services:
|
||||
- /path/to/services
|
||||
- /other/path/to/services
|
||||
# vaults: Vaults
|
||||
# fonts: Fonts
|
||||
#services:
|
||||
# - ./envied/services/
|
||||
vaults: vaults/
|
||||
|
||||
# fonts: Fonts
|
||||
|
||||
# Pre-define which Widevine or PlayReady device to use for each Service
|
||||
cdm:
|
||||
# Global default CDM device (fallback for all services/profiles)
|
||||
default: WVD_1
|
||||
default: device
|
||||
# itv: DRMLABL3
|
||||
|
||||
# Direct service-specific CDM
|
||||
DIFFERENT_EXAMPLE: PRD_1
|
||||
# ==========================================
|
||||
# REMOTE CDM - not working
|
||||
# ==========================================
|
||||
|
||||
# Per-profile CDM configuration
|
||||
EXAMPLE:
|
||||
john_sd: chromecdm_903_l3 # Profile 'john_sd' uses Chrome CDM L3
|
||||
jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1
|
||||
default: generic_android_l3 # Default CDM for this service
|
||||
|
||||
# NEW: Quality-based CDM selection
|
||||
# Use different CDMs based on video resolution
|
||||
# Supports operators: >=, >, <=, <, or exact match
|
||||
EXAMPLE_QUALITY:
|
||||
"<=1080": generic_android_l3 # Use L3 for 1080p and below
|
||||
">1080": nexus_5_l1 # Use L1 for above 1080p (1440p, 2160p)
|
||||
default: generic_android_l3 # Optional: fallback if no quality match
|
||||
|
||||
# You can mix profiles and quality thresholds in the same service
|
||||
NETFLIX:
|
||||
# Profile-based selection (existing functionality)
|
||||
john: netflix_l3_profile
|
||||
jane: netflix_l1_profile
|
||||
# Quality-based selection (new functionality)
|
||||
"<=720": netflix_mobile_l3
|
||||
"1080": netflix_standard_l3
|
||||
">=1440": netflix_premium_l1
|
||||
# Fallback
|
||||
default: netflix_standard_l3
|
||||
|
||||
# Use pywidevine Serve-compliant Remote CDMs
|
||||
remote_cdm:
|
||||
- name: "chrome"
|
||||
device_name: chrome
|
||||
device_type: CHROME
|
||||
system_id: 27175
|
||||
security_level: 3
|
||||
host: https://domain.com/api
|
||||
secret: secret_key
|
||||
- name: "chrome-2"
|
||||
device_name: chrome
|
||||
device_type: CHROME
|
||||
system_id: 26830
|
||||
security_level: 3
|
||||
host: https://domain-2.com/api
|
||||
secret: secret_key
|
||||
- name: "DRMLABL3"
|
||||
"Device Type": ANDROID
|
||||
"Device Name": "l3"
|
||||
"System ID": 8159
|
||||
"Security Level": 3
|
||||
"Host": "http://api2.drmlab.io"
|
||||
"Secret": "drmlab"
|
||||
|
||||
- name: "decrypt_labs_chrome"
|
||||
type: "decrypt_labs" # Required to identify as DecryptLabs CDM
|
||||
device_name: "ChromeCDM" # Scheme identifier - must match exactly
|
||||
device_type: CHROME
|
||||
system_id: 4464 # Doesn't matter
|
||||
security_level: 3
|
||||
host: "https://keyxtractor.decryptlabs.com"
|
||||
secret: "your_decrypt_labs_api_key_here" # Replace with your API key
|
||||
- name: "decrypt_labs_l1"
|
||||
type: "decrypt_labs"
|
||||
device_name: "L1" # Scheme identifier - must match exactly
|
||||
device_type: ANDROID
|
||||
system_id: 4464
|
||||
security_level: 1
|
||||
host: "https://keyxtractor.decryptlabs.com"
|
||||
secret: "your_decrypt_labs_api_key_here"
|
||||
|
||||
- name: "decrypt_labs_l2"
|
||||
type: "decrypt_labs"
|
||||
device_name: "L2" # Scheme identifier - must match exactly
|
||||
device_type: ANDROID
|
||||
system_id: 4464
|
||||
security_level: 2
|
||||
host: "https://keyxtractor.decryptlabs.com"
|
||||
secret: "your_decrypt_labs_api_key_here"
|
||||
|
||||
- name: "decrypt_labs_playready_sl2"
|
||||
type: "decrypt_labs"
|
||||
device_name: "SL2" # Scheme identifier - must match exactly
|
||||
device_type: PLAYREADY
|
||||
system_id: 0
|
||||
security_level: 2000
|
||||
host: "https://keyxtractor.decryptlabs.com"
|
||||
secret: "your_decrypt_labs_api_key_here"
|
||||
|
||||
- name: "decrypt_labs_playready_sl3"
|
||||
type: "decrypt_labs"
|
||||
device_name: "SL3" # Scheme identifier - must match exactly
|
||||
device_type: PLAYREADY
|
||||
system_id: 0
|
||||
security_level: 3000
|
||||
host: "https://keyxtractor.decryptlabs.com"
|
||||
secret: "your_decrypt_labs_api_key_here"
|
||||
remote_cdm:
|
||||
- name: "DRMLAB_SL3K"
|
||||
"Device Type": PLAYREADY
|
||||
"Device Name": "sl3k"
|
||||
"System ID": 0
|
||||
"Security Level": 3000
|
||||
"Host": "http://api2.drmlab.io/playready"
|
||||
"Secret": "drmlab"
|
||||
|
||||
# Key Vaults store your obtained Content Encryption Keys (CEKs)
|
||||
# Use 'no_push: true' to prevent a vault from receiving pushed keys
|
||||
# while still allowing it to provide keys when requested
|
||||
key_vaults:
|
||||
- type: SQLite
|
||||
name: Local
|
||||
path: key_store.db
|
||||
# Additional vault types:
|
||||
# - type: API
|
||||
# name: "Remote Vault"
|
||||
# uri: "https://key-vault.example.com"
|
||||
# token: "secret_token"
|
||||
# no_push: true # This vault will only provide keys, not receive them
|
||||
# - type: MySQL
|
||||
# name: "MySQL Vault"
|
||||
# host: "127.0.0.1"
|
||||
# port: 3306
|
||||
# database: vault
|
||||
# username: user
|
||||
# password: pass
|
||||
# no_push: false # Default behavior - vault both provides and receives keys
|
||||
- type: SQLite
|
||||
name: Local vault
|
||||
path: key_store.db
|
||||
|
||||
#- type: HTTPAPI
|
||||
# name: drmlab
|
||||
# host: http://api.drmlab.io/vault/
|
||||
# password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
|
||||
- type: HTTP
|
||||
name: DRMLab
|
||||
host: http://api.drmlab.io/vault/
|
||||
password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
|
||||
api_mode: json
|
||||
no_push: false
|
||||
|
||||
# Choose what software to use to download data
|
||||
downloader: aria2c
|
||||
#downloader: n_m3u8dl_re
|
||||
# Options: requests | aria2c | curl_impersonate | n_m3u8dl_re
|
||||
# Can also be a mapping:
|
||||
# downloader:
|
||||
downloader:
|
||||
# NF: requests
|
||||
# AMZN: n_m3u8dl_re
|
||||
# DSNP: n_m3u8dl_re
|
||||
# default: requests
|
||||
ALL4: n_m3u8dl_re
|
||||
BBC: requests
|
||||
default: n_m3u8dl_re
|
||||
|
||||
# aria2c downloader configuration
|
||||
aria2c:
|
||||
@@ -222,14 +176,12 @@ curl_impersonate:
|
||||
|
||||
# Pre-define default options and switches of the dl command
|
||||
dl:
|
||||
best: true
|
||||
sub_format: srt
|
||||
downloads: 4
|
||||
workers: 16
|
||||
lang:
|
||||
- en
|
||||
- fr
|
||||
EXAMPLE:
|
||||
bitrate: CBR
|
||||
|
||||
|
||||
|
||||
# Chapter Name to use when exporting a Chapter without a Name
|
||||
chapter_fallback_name: "Chapter {j:02}"
|
||||
@@ -237,9 +189,9 @@ chapter_fallback_name: "Chapter {j:02}"
|
||||
# Case-Insensitive dictionary of headers for all Services
|
||||
headers:
|
||||
Accept-Language: "en-US,en;q=0.8"
|
||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
|
||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36"
|
||||
|
||||
# Override default filenames used across envied
|
||||
# Override default filenames used across unshackle
|
||||
filenames:
|
||||
log: "envied_{name}_{time}.log"
|
||||
config: "config.yaml"
|
||||
@@ -248,7 +200,11 @@ filenames:
|
||||
subtitle: "Subtitle_{id}_{language}.srt"
|
||||
|
||||
# API key for The Movie Database (TMDB)
|
||||
tmdb_api_key: ""
|
||||
# tmdb_api_key: key
|
||||
|
||||
# tag_imdb_tmdb (bool)
|
||||
tag_imdb_tmdb: true
|
||||
|
||||
|
||||
# conversion_method:
|
||||
# - auto (default): Smart routing - subby for WebVTT/SAMI, standard for others
|
||||
@@ -259,74 +215,19 @@ subtitle:
|
||||
conversion_method: auto
|
||||
sdh_method: auto
|
||||
|
||||
# Configuration for pywidevine's serve functionality
|
||||
serve:
|
||||
users:
|
||||
secret_key_for_user:
|
||||
devices:
|
||||
- generic_nexus_4464_l3
|
||||
username: user
|
||||
# devices:
|
||||
# - '/path/to/device.wvd'
|
||||
|
||||
# Configuration data for each Service
|
||||
services:
|
||||
# Service-specific configuration goes here
|
||||
# Profile-specific configurations can be nested under service names
|
||||
# EXAMPLE:
|
||||
# api_key: "service_specific_key"
|
||||
|
||||
# Example: with profile-specific device configs
|
||||
EXAMPLE:
|
||||
# Global service config
|
||||
api_key: "service_api_key"
|
||||
|
||||
# Service certificate for Widevine L1/L2 (base64 encoded)
|
||||
# This certificate is automatically used when L1/L2 schemes are selected
|
||||
# Services obtain this from their DRM provider or license server
|
||||
certificate: |
|
||||
CAUSwwUKvQIIAxIQ5US6QAvBDzfTtjb4tU/7QxiH8c+TBSKOAjCCAQoCggEBAObzvlu2hZRsapAPx4Aa4GUZj4/GjxgXUtBH4THSkM40x63wQeyVxlEEo
|
||||
# ... (full base64 certificate here)
|
||||
|
||||
# Profile-specific device configurations
|
||||
profiles:
|
||||
john_sd:
|
||||
device:
|
||||
app_name: "AIV"
|
||||
device_model: "SHIELD Android TV"
|
||||
jane_uhd:
|
||||
device:
|
||||
app_name: "AIV"
|
||||
device_model: "Fire TV Stick 4K"
|
||||
|
||||
# Example: Service with different regions per profile
|
||||
SERVICE_NAME:
|
||||
profiles:
|
||||
us_account:
|
||||
region: "US"
|
||||
api_endpoint: "https://api.us.service.com"
|
||||
uk_account:
|
||||
region: "GB"
|
||||
api_endpoint: "https://api.uk.service.com"
|
||||
|
||||
# External proxy provider services
|
||||
proxy_providers:
|
||||
nordvpn:
|
||||
username: username_from_service_credentials
|
||||
password: password_from_service_credentials
|
||||
server_map:
|
||||
us: 12 # force US server #12 for US proxies
|
||||
surfsharkvpn:
|
||||
username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
||||
password: your_surfshark_service_password # Service credentials (not your login password)
|
||||
server_map:
|
||||
us: 3844 # force US server #3844 for US proxies
|
||||
gb: 2697 # force GB server #2697 for GB proxies
|
||||
au: 4621 # force AU server #4621 for AU proxies
|
||||
basic:
|
||||
GB:
|
||||
- "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham)
|
||||
- "socks5://username:password@gla.socks.ipvanish.com:1080" # 2 (Glasgow)
|
||||
AU:
|
||||
- "socks5://username:password@syd.socks.ipvanish.com:1080" # 1 (Sydney)
|
||||
- "https://username:password@au-syd.prod.surfshark.com" # 2 (Sydney)
|
||||
- "https://username:password@au-bne.prod.surfshark.com" # 3 (Brisbane)
|
||||
BG: "https://username:password@bg-sof.prod.surfshark.com"
|
||||
# Legacy NordVPN configuration (use proxy_providers instead)
|
||||
#proxy_providers:
|
||||
# nordvpn:
|
||||
# username: gobbldegook
|
||||
# password: twaddle
|
||||
# server_map:
|
||||
# us: 6918
|
||||
# uk: 2613
|
||||
# nz: 100
|
||||
|
||||
Executable → Regular
+326
-335
@@ -1,16 +1,16 @@
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Optional
|
||||
|
||||
from urllib.parse import urlparse
|
||||
import click
|
||||
from langcodes import Language
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
|
||||
from envied.core.constants import AnyTrack
|
||||
from envied.core.credential import Credential
|
||||
from envied.core.manifests import DASH
|
||||
@@ -21,26 +21,22 @@ from envied.core.tracks import Chapters, Tracks, Video
|
||||
|
||||
class PCOK(Service):
|
||||
"""
|
||||
Service code for NBC's Peacock streaming service (https://peacocktv.com).
|
||||
Version: 1.0.0
|
||||
Service code for Peacock TV (https://peacocktv.com)
|
||||
|
||||
Authorization: Cookies
|
||||
Security: UHD@-- FHD@SL*
|
||||
|
||||
Tips: - The library of contents can be viewed without logging in at https://www.peacocktv.com/stream/tv
|
||||
See the footer for links to movies, news, etc. A US IP is required to view.
|
||||
Author: n0stal6ic
|
||||
Authorization: Cookies, Credentials
|
||||
Geofence: US
|
||||
"""
|
||||
|
||||
ALIASES = ("PCOK", "peacock")
|
||||
ALIASES = ("PCOK", "peacock", "peacocktv")
|
||||
GEOFENCE = ("US",)
|
||||
TITLE_RE = [
|
||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>movies/[a-z0-9/./-]+/[a-f0-9-]+)",
|
||||
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-/.]+/\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-/.]+/\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/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-]{36})",
|
||||
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-]{36})",
|
||||
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-]+)$",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -51,15 +47,11 @@ class PCOK(Service):
|
||||
def cli(ctx, **kwargs):
|
||||
return PCOK(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title, movie):
|
||||
def __init__(self, ctx, title: str, movie: bool):
|
||||
super().__init__(ctx)
|
||||
|
||||
self.title = title
|
||||
self.movie = movie
|
||||
self.cdm = ctx.obj.cdm
|
||||
if not isinstance(self.cdm, PlayReadyCdm):
|
||||
self.log.warning("PlayReady CDM not provided, exiting")
|
||||
raise SystemExit(1)
|
||||
|
||||
range_param = ctx.parent.params.get("range_")
|
||||
self.range = range_param[0].name if range_param else "SDR"
|
||||
@@ -67,64 +59,221 @@ class PCOK(Service):
|
||||
vcodec_param = ctx.parent.params.get("vcodec")
|
||||
self.vcodec = vcodec_param if vcodec_param else "H264"
|
||||
|
||||
if self.config is None:
|
||||
raise Exception("Config is missing!")
|
||||
self.profile_name = ctx.parent.params.get("profile") or "default"
|
||||
|
||||
profile_name = ctx.parent.params.get("profile")
|
||||
if profile_name is None:
|
||||
profile_name = "default"
|
||||
self.profile = profile_name
|
||||
prof_key = self.config["client"].get("profile", "tv")
|
||||
profiles = self.config.get("profiles", {})
|
||||
if prof_key not in profiles:
|
||||
raise ValueError(f"Unknown device profile {prof_key!r}. Valid: {list(profiles)}")
|
||||
self.prof = profiles[prof_key]
|
||||
self.hmac_key: bytes = self.prof["hmac_key"].encode()
|
||||
|
||||
self.hmac_key = None
|
||||
self.tokens = None
|
||||
self.license_api = None
|
||||
self.license_bt = None
|
||||
try:
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
self.use_playready: bool = isinstance(ctx.obj.cdm, PlayReadyCdm)
|
||||
except ImportError:
|
||||
self.use_playready = False
|
||||
|
||||
self.tokens: Optional[dict] = None
|
||||
self.license_url: Optional[str] = None
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
if not cookies:
|
||||
raise EnvironmentError("Service requires Cookies for Authentication.")
|
||||
|
||||
self.session.headers.update({"Origin": "https://www.peacocktv.com"})
|
||||
self.log.info("Getting Peacock Client configuration")
|
||||
|
||||
if self.config["client"]["platform"] != "PC":
|
||||
self.service_config = self.session.get(
|
||||
url=self.config["endpoints"]["config"].format(
|
||||
territory=self.config["client"]["territory"],
|
||||
provider=self.config["client"]["provider"],
|
||||
proposition=self.config["client"]["proposition"],
|
||||
device=self.config["client"]["platform"],
|
||||
version=self.config["client"]["config_version"],
|
||||
if credential and credential.username and credential.password:
|
||||
self.log.info("Authenticating with email/password credentials.")
|
||||
self._login(credential.username, credential.password)
|
||||
elif not cookies:
|
||||
raise EnvironmentError("Peacock requires cookies or credential.")
|
||||
|
||||
self.log.info("Fetching authorization tokens.")
|
||||
self.tokens = self._get_tokens()
|
||||
self.log.info("Verifying tokens.")
|
||||
if not self._verify_tokens():
|
||||
raise EnvironmentError("Token verification failed.")
|
||||
|
||||
def _login(self, email: str, password: str) -> None:
|
||||
r = self.session.post(
|
||||
url=self.config["endpoints"]["login"],
|
||||
data={"userIdentifier": email, "password": password},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-SkyOTT-Proposition": self.config["client"]["proposition"],
|
||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
||||
},
|
||||
)
|
||||
if r.status_code not in (200, 201):
|
||||
try:
|
||||
code = (
|
||||
r.json()
|
||||
.get("properties", {})
|
||||
.get("errors", {})
|
||||
.get("categoryErrors", [{}])[0]
|
||||
.get("code", "unknown")
|
||||
)
|
||||
).json()
|
||||
raise EnvironmentError(f"Login failed: {code}")
|
||||
except (ValueError, KeyError, IndexError):
|
||||
raise EnvironmentError(f"Login failed with HTTP {r.status_code}.")
|
||||
|
||||
self.hmac_key = bytes(self.config["security"]["signature_hmac_key_v4"], "utf-8")
|
||||
self.log.info("Getting Authorization Tokens")
|
||||
self.tokens = self.get_tokens()
|
||||
self.log.info("Verifying Authorization Tokens")
|
||||
if not self.verify_tokens():
|
||||
raise EnvironmentError("Failed! Cookies might be outdated.")
|
||||
def _sky_headers(self, extra: Optional[dict] = None) -> dict:
|
||||
h = {
|
||||
"X-SkyOTT-Device": self.prof["device"],
|
||||
"X-SkyOTT-Platform": self.prof["platform"],
|
||||
"X-SkyOTT-Proposition": self.config["client"]["proposition"],
|
||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
||||
}
|
||||
if extra:
|
||||
h.update(extra)
|
||||
return h
|
||||
|
||||
def _md5_headers(self, headers: dict) -> str:
|
||||
lines = sorted(
|
||||
f"{k.lower()}: {v}"
|
||||
for k, v in headers.items()
|
||||
if k.lower().startswith("x-skyott-")
|
||||
)
|
||||
text = "\n".join(lines) + ("\n" if lines else "")
|
||||
return hashlib.md5(text.encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _md5_body(body: str | bytes) -> str:
|
||||
if isinstance(body, str):
|
||||
body = body.encode()
|
||||
return hashlib.md5(body).hexdigest()
|
||||
|
||||
def _sign(self, method: str, path: str, headers: dict, body: str | bytes = b"") -> str:
|
||||
ts = str(int(time.time()))
|
||||
sdk = self.prof["client_sdk"]
|
||||
msg = (
|
||||
"\n".join([
|
||||
method.upper(),
|
||||
path,
|
||||
"",
|
||||
sdk,
|
||||
"1.0",
|
||||
self._md5_headers(headers),
|
||||
ts,
|
||||
self._md5_body(body),
|
||||
])
|
||||
+ "\n"
|
||||
)
|
||||
digest = hmac.new(self.hmac_key, msg.encode(), hashlib.sha1).digest()
|
||||
sig = base64.b64encode(digest).decode()
|
||||
return f'SkyOTT client="{sdk}",signature="{sig}",timestamp="{ts}",version="1.0"'
|
||||
|
||||
def _get_tokens(self) -> dict:
|
||||
prof_key = self.config["client"].get("profile", "tv")
|
||||
cache_key = f"tokens_{self.profile_name}_{prof_key}"
|
||||
cache = self.cache.get(cache_key)
|
||||
|
||||
if cache and cache.data:
|
||||
expiry = cache.data.get("tokenExpiryTime")
|
||||
if expiry:
|
||||
try:
|
||||
if datetime.strptime(expiry, "%Y-%m-%dT%H:%M:%S.%fZ") > datetime.utcnow():
|
||||
self.log.debug("Using cached tokens.")
|
||||
return cache.data
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
sky_h = self._sky_headers()
|
||||
|
||||
persona_id: Optional[str] = None
|
||||
try:
|
||||
r = self.session.get(
|
||||
url=self.config["endpoints"]["personas"],
|
||||
headers={
|
||||
**sky_h,
|
||||
"Accept": "application/vnd.persona.v1+json",
|
||||
"X-SkyOTT-TokenType": self.config["client"]["auth_scheme"],
|
||||
},
|
||||
)
|
||||
if r.ok:
|
||||
personas = r.json().get("personas", [])
|
||||
if personas:
|
||||
persona_id = personas[0]["personaId"]
|
||||
except Exception as e:
|
||||
self.log.debug(f"Persona fetch skipped: {e}")
|
||||
|
||||
auth_block: dict = {
|
||||
"authScheme": self.config["client"]["auth_scheme"],
|
||||
"provider": self.config["client"]["provider"],
|
||||
"providerTerritory": self.config["client"]["territory"],
|
||||
"proposition": self.config["client"]["proposition"],
|
||||
}
|
||||
if persona_id:
|
||||
auth_block["personaId"] = persona_id
|
||||
|
||||
body = json.dumps(
|
||||
{
|
||||
"auth": auth_block,
|
||||
"device": {
|
||||
"type": self.prof["device"],
|
||||
"platform": self.prof["platform"],
|
||||
"id": self.config["client"].get("device_id", "PC"),
|
||||
"drmDeviceId": self.config["client"].get("drm_device_id", "UNKNOWN"),
|
||||
},
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
r = self.session.post(
|
||||
url=self.config["endpoints"]["tokens"],
|
||||
data=body,
|
||||
headers={
|
||||
**sky_h,
|
||||
"Accept": "application/vnd.tokens.v1+json",
|
||||
"Content-Type": "application/vnd.tokens.v1+json",
|
||||
"X-Sky-Signature": self._sign("POST", "/auth/tokens", sky_h, body),
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
tokens = r.json()
|
||||
|
||||
if "description" in tokens and "userToken" not in tokens:
|
||||
raise EnvironmentError(f"Token fetch failed: {tokens['description']}")
|
||||
|
||||
if cache:
|
||||
cache.set(data=tokens)
|
||||
|
||||
tokens["_fresh"] = True
|
||||
return tokens
|
||||
|
||||
def _verify_tokens(self) -> bool:
|
||||
if self.tokens.pop("_fresh", False):
|
||||
return True
|
||||
sky_h = self._sky_headers({"X-SkyOTT-UserToken": self.tokens["userToken"]})
|
||||
try:
|
||||
r = self.session.get(
|
||||
url=self.config["endpoints"]["me"],
|
||||
headers={
|
||||
**sky_h,
|
||||
"Accept": "application/vnd.userinfo.v2+json",
|
||||
"X-Sky-Signature": self._sign("GET", "/auth/users/me", sky_h),
|
||||
},
|
||||
)
|
||||
return r.ok
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
# Parse title from various URL formats
|
||||
import re
|
||||
|
||||
title_id = self.title
|
||||
for pattern in self.TITLE_RE:
|
||||
match = re.search(pattern, self.title)
|
||||
if match:
|
||||
title_id = match.group("id")
|
||||
m = re.search(pattern, self.title)
|
||||
if m:
|
||||
title_id = m.group("id")
|
||||
break
|
||||
|
||||
# Handle stream-tv redirects
|
||||
if "/" not in title_id:
|
||||
r = self.session.get(self.config["endpoints"]["stream_tv"].format(title_id=title_id))
|
||||
match = re.search(r"/watch/asset(/[^']+)", r.text)
|
||||
if match:
|
||||
title_id = match.group(1)
|
||||
r = self.session.get(f"https://www.peacocktv.com/stream-tv/{title_id}")
|
||||
m = re.search(r"/watch/asset(/[^'\"?#\s]+)", r.text)
|
||||
if m:
|
||||
title_id = m.group(1)
|
||||
else:
|
||||
raise ValueError("Title ID not found or invalid")
|
||||
raise ValueError(f"Could not resolve slug: {title_id!r}")
|
||||
|
||||
if not title_id.startswith("/"):
|
||||
title_id = f"/{title_id}"
|
||||
@@ -132,22 +281,16 @@ class PCOK(Service):
|
||||
if title_id.startswith("/movies/"):
|
||||
self.movie = True
|
||||
|
||||
sky_h = self._sky_headers()
|
||||
res = self.session.get(
|
||||
url=self.config["endpoints"]["node"],
|
||||
params={
|
||||
"slug": title_id,
|
||||
"represent": "(items(items))"
|
||||
},
|
||||
params={"slug": title_id, "represent": "(items(items))"},
|
||||
headers={
|
||||
**sky_h,
|
||||
"Accept": "*",
|
||||
"Referer": f"https://www.peacocktv.com/watch/asset{title_id}",
|
||||
"X-SkyOTT-Device": self.config["client"]["device"],
|
||||
"X-SkyOTT-Platform": self.config["client"]["platform"],
|
||||
"X-SkyOTT-Proposition": self.config["client"]["proposition"],
|
||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
||||
"X-SkyOTT-Language": "en"
|
||||
}
|
||||
"X-SkyOTT-Language": "en",
|
||||
},
|
||||
).json()
|
||||
|
||||
if self.movie:
|
||||
@@ -156,299 +299,147 @@ class PCOK(Service):
|
||||
id_=title_id,
|
||||
service=self.__class__,
|
||||
name=res["attributes"]["title"],
|
||||
year=res["attributes"]["year"],
|
||||
year=res["attributes"].get("year"),
|
||||
data=res,
|
||||
)
|
||||
])
|
||||
else:
|
||||
episodes = []
|
||||
for season in res["relationships"]["items"]["data"]:
|
||||
for episode in season["relationships"]["items"]["data"]:
|
||||
episodes.append(episode)
|
||||
|
||||
episode_titles = []
|
||||
for x in episodes:
|
||||
episode_titles.append(
|
||||
Episode(
|
||||
id_=title_id,
|
||||
service=self.__class__,
|
||||
title=res["attributes"]["title"],
|
||||
season=x["attributes"].get("seasonNumber"),
|
||||
number=x["attributes"].get("episodeNumber"),
|
||||
name=x["attributes"].get("title"),
|
||||
year=x["attributes"].get("year"),
|
||||
data=x
|
||||
)
|
||||
)
|
||||
return Series(episode_titles)
|
||||
episodes = [
|
||||
ep
|
||||
for season in res.get("relationships", {}).get("items", {}).get("data", [])
|
||||
for ep in season.get("relationships", {}).get("items", {}).get("data", [])
|
||||
]
|
||||
|
||||
return Series([
|
||||
Episode(
|
||||
id_=title_id,
|
||||
service=self.__class__,
|
||||
title=res["attributes"]["title"],
|
||||
season=ep["attributes"].get("seasonNumber"),
|
||||
number=ep["attributes"].get("episodeNumber"),
|
||||
name=ep["attributes"].get("title"),
|
||||
year=ep["attributes"].get("year"),
|
||||
data=ep,
|
||||
)
|
||||
for ep in episodes
|
||||
])
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
supported_colour_spaces = ["SDR"]
|
||||
attrs = title.data["attributes"]
|
||||
formats = attrs.get("formats", {})
|
||||
|
||||
want_uhd = self.vcodec == "H265"
|
||||
if want_uhd and "UHD" in formats:
|
||||
content_id = formats["UHD"]["contentId"]
|
||||
elif "HD" in formats:
|
||||
content_id = formats["HD"]["contentId"]
|
||||
else:
|
||||
content_id = next(iter(formats.values()), {}).get("contentId", "")
|
||||
|
||||
variant_id = attrs.get("providerVariantId", "")
|
||||
|
||||
if self.range == "HDR10":
|
||||
self.log.info("Switched dynamic range to HDR10")
|
||||
supported_colour_spaces = ["HDR10"]
|
||||
colour_spaces = ["HDR10"]
|
||||
elif self.range == "DV":
|
||||
self.log.info("Switched dynamic range to DV")
|
||||
supported_colour_spaces = ["DolbyVision"]
|
||||
colour_spaces = ["DolbyVision"]
|
||||
else:
|
||||
colour_spaces = ["SDR"]
|
||||
|
||||
content_id = title.data["attributes"]["formats"]["HD"]["contentId"]
|
||||
variant_id = title.data["attributes"]["providerVariantId"]
|
||||
primary_drm = "PLAYREADY" if self.use_playready else "WIDEVINE"
|
||||
|
||||
sky_headers = {
|
||||
"X-SkyOTT-Agent": ".".join([
|
||||
self.config["client"]["proposition"].lower(),
|
||||
self.config["client"]["device"].lower(),
|
||||
self.config["client"]["platform"].lower()
|
||||
]),
|
||||
"X-SkyOTT-PinOverride": "false",
|
||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
||||
"X-SkyOTT-UserToken": self.tokens["userToken"]
|
||||
}
|
||||
capabilities = [
|
||||
{
|
||||
"protection": primary_drm,
|
||||
"container": "ISOBMFF",
|
||||
"transport": "DASH",
|
||||
"acodec": "AAC",
|
||||
"vcodec": self.vcodec,
|
||||
}
|
||||
]
|
||||
|
||||
body = json.dumps({
|
||||
"device": {
|
||||
"capabilities": [
|
||||
{
|
||||
"protection": "PLAYREADY",
|
||||
"container": "ISOBMFF",
|
||||
"transport": "DASH",
|
||||
"acodec": "AAC",
|
||||
"vcodec": "H265" if self.vcodec == "H265" else "H264",
|
||||
},
|
||||
{
|
||||
"protection": "PLAYREADY",
|
||||
"container": "ISOBMFF",
|
||||
"transport": "DASH",
|
||||
"acodec": "AAC",
|
||||
"vcodec": "H265" if self.vcodec == "H265" else "H264",
|
||||
}
|
||||
],
|
||||
"maxVideoFormat": "UHD" if self.vcodec == "H265" else "HD",
|
||||
"supportedColourSpaces": supported_colour_spaces,
|
||||
"model": self.config["client"]["platform"],
|
||||
"hdcpEnabled": "true"
|
||||
sky_h = self._sky_headers({"X-SkyOTT-UserToken": self.tokens["userToken"]})
|
||||
body = json.dumps(
|
||||
{
|
||||
"device": {
|
||||
"capabilities": capabilities,
|
||||
"maxVideoFormat": "UHD" if want_uhd else "HD",
|
||||
"supportedColourSpaces": colour_spaces,
|
||||
"model": self.prof["platform"],
|
||||
"hdcpEnabled": "true",
|
||||
},
|
||||
"client": {"thirdParties": ["FREEWHEEL", "YOSPACE"]},
|
||||
"contentId": content_id,
|
||||
"providerVariantId": variant_id,
|
||||
"parentalControlPin": "null",
|
||||
"personaParentalControlRating": 9,
|
||||
},
|
||||
"client": {
|
||||
"thirdParties": ["FREEWHEEL", "YOSPACE"]
|
||||
},
|
||||
"contentId": content_id,
|
||||
"providerVariantId": variant_id,
|
||||
"parentalControlPin": "null"
|
||||
}, separators=(",", ":"))
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
manifest = self.session.post(
|
||||
r = self.session.post(
|
||||
url=self.config["endpoints"]["vod"],
|
||||
data=body,
|
||||
headers=dict(**sky_headers, **{
|
||||
headers={
|
||||
**sky_h,
|
||||
"Accept": "application/vnd.playvod.v1+json",
|
||||
"Content-Type": "application/vnd.playvod.v1+json",
|
||||
"X-Sky-Signature": self.create_signature_header(
|
||||
method="POST",
|
||||
path="/video/playouts/vod",
|
||||
sky_headers=sky_headers,
|
||||
body=body,
|
||||
timestamp=int(time.time())
|
||||
)
|
||||
})
|
||||
).json()
|
||||
"X-Sky-Signature": self._sign("POST", "/video/playouts/vod", sky_h, body),
|
||||
},
|
||||
)
|
||||
manifest = r.json()
|
||||
|
||||
if "errorCode" in manifest:
|
||||
raise ValueError(f"An error occurred: {manifest['description']} [{manifest['errorCode']}]")
|
||||
raise ValueError(
|
||||
f"Playout error: {manifest.get('description', 'unknown')} [{manifest['errorCode']}]"
|
||||
)
|
||||
|
||||
self.license_api = manifest["protection"]["licenceAcquisitionUrl"]
|
||||
self.license_bt = manifest["protection"]["licenceToken"]
|
||||
self.license_url = manifest["protection"]["licenceAcquisitionUrl"]
|
||||
|
||||
tracks = DASH.from_url(
|
||||
url=manifest["asset"]["endpoints"][0]["url"],
|
||||
session=self.session
|
||||
).to_tracks(language=Language.get("en"))
|
||||
endpoints = manifest["asset"]["endpoints"]
|
||||
dash_url = next(
|
||||
(e["url"] for e in endpoints if e.get("cdn", "").upper() == "FASTLY"),
|
||||
endpoints[0]["url"] if endpoints else None,
|
||||
)
|
||||
if not dash_url:
|
||||
raise ValueError("No DASH endpoint in playout response.")
|
||||
|
||||
tracks = DASH.from_url(url=dash_url, session=self.session).to_tracks(language=Language.get("en"))
|
||||
|
||||
# Set HDR attributes
|
||||
for video in tracks.videos:
|
||||
if supported_colour_spaces == ["HDR10"]:
|
||||
if colour_spaces == ["HDR10"]:
|
||||
video.range = Video.Range.HDR10
|
||||
elif supported_colour_spaces == ["DolbyVision"]:
|
||||
elif colour_spaces == ["DolbyVision"]:
|
||||
video.range = Video.Range.DV
|
||||
else:
|
||||
video.range = Video.Range.SDR
|
||||
|
||||
# Fix audio description language
|
||||
for track in tracks.audio:
|
||||
if track.language.territory == "AD":
|
||||
track.language.territory = None
|
||||
for audio in tracks.audio:
|
||||
if audio.language.territory == "AD":
|
||||
audio.language.territory = None
|
||||
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
"""Get chapters for the title. Peacock doesn't typically provide chapter data."""
|
||||
return Chapters([])
|
||||
return Chapters()
|
||||
|
||||
def _license_request(self, challenge: bytes) -> bytes:
|
||||
path = urlparse(self.license_url).path
|
||||
r = self.session.post(
|
||||
url=self.license_url,
|
||||
data=challenge,
|
||||
headers={
|
||||
"X-Sky-Signature": self._sign("POST", path, {}, challenge),
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||
if not self.license_url:
|
||||
return None
|
||||
return self._license_request(challenge)
|
||||
|
||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||
"""Retrieve a PlayReady license for a given track."""
|
||||
if not self.license_api:
|
||||
if not self.license_url:
|
||||
return None
|
||||
|
||||
response = self.session.post(
|
||||
url=self.license_api,
|
||||
headers={
|
||||
"Accept": "*",
|
||||
"X-Sky-Signature": self.create_signature_header(
|
||||
method="POST",
|
||||
path="/" + self.license_api.split("://", 2)[1].split("/", 1)[1],
|
||||
sky_headers={},
|
||||
body="",
|
||||
timestamp=int(time.time())
|
||||
)
|
||||
},
|
||||
data=challenge
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
@staticmethod
|
||||
def calculate_sky_header_md5(headers):
|
||||
if len(headers.items()) > 0:
|
||||
headers_str = "\n".join(f"{x[0].lower()}: {x[1]}" for x in headers.items()) + "\n"
|
||||
else:
|
||||
headers_str = "{}"
|
||||
return str(hashlib.md5(headers_str.encode()).hexdigest())
|
||||
|
||||
@staticmethod
|
||||
def calculate_body_md5(body):
|
||||
return str(hashlib.md5(body.encode()).hexdigest())
|
||||
|
||||
def calculate_signature(self, msg):
|
||||
digest = hmac.new(self.hmac_key, bytes(msg, "utf-8"), hashlib.sha1).digest()
|
||||
return str(base64.b64encode(digest), "utf-8")
|
||||
|
||||
def create_signature_header(self, method, path, sky_headers, body, timestamp):
|
||||
data = "\n".join([
|
||||
method.upper(),
|
||||
path,
|
||||
"",
|
||||
self.config["client"]["client_sdk"],
|
||||
"1.0",
|
||||
self.calculate_sky_header_md5(sky_headers),
|
||||
str(timestamp),
|
||||
self.calculate_body_md5(body)
|
||||
]) + "\n"
|
||||
|
||||
signature_hmac = self.calculate_signature(data)
|
||||
|
||||
return self.config["security"]["signature_format"].format(
|
||||
client=self.config["client"]["client_sdk"],
|
||||
signature=signature_hmac,
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
def get_tokens(self):
|
||||
# Try to get cached tokens
|
||||
cache = self.cache.get(f"tokens_{self.profile}_{self.config['client']['id']}")
|
||||
|
||||
if cache and cache.data.get("tokenExpiryTime"):
|
||||
tokens_expiration = cache.data.get("tokenExpiryTime")
|
||||
if datetime.strptime(tokens_expiration, "%Y-%m-%dT%H:%M:%S.%fZ") > datetime.now():
|
||||
return cache.data
|
||||
|
||||
# Get all SkyOTT headers
|
||||
sky_headers = {
|
||||
"X-SkyOTT-Agent": ".".join([
|
||||
self.config["client"]["proposition"],
|
||||
self.config["client"]["device"],
|
||||
self.config["client"]["platform"]
|
||||
]).lower(),
|
||||
"X-SkyOTT-Device": self.config["client"]["device"],
|
||||
"X-SkyOTT-Platform": self.config["client"]["platform"],
|
||||
"X-SkyOTT-Proposition": self.config["client"]["proposition"],
|
||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
||||
"X-SkyOTT-Territory": self.config["client"]["territory"]
|
||||
}
|
||||
|
||||
try:
|
||||
# Call personas endpoint to get the accounts personaId
|
||||
personas = self.session.get(
|
||||
url=self.config["endpoints"]["personas"],
|
||||
headers=dict(**sky_headers, **{
|
||||
"Accept": "application/vnd.persona.v1+json",
|
||||
"Content-Type": "application/vnd.persona.v1+json",
|
||||
"X-SkyOTT-TokenType": self.config["client"]["auth_scheme"]
|
||||
})
|
||||
).json()
|
||||
except Exception as e:
|
||||
raise EnvironmentError(f"Unable to get persona ID: {e}")
|
||||
|
||||
persona = personas["personas"][0]["personaId"]
|
||||
|
||||
# Craft the body data
|
||||
body = json.dumps({
|
||||
"auth": {
|
||||
"authScheme": self.config["client"]["auth_scheme"],
|
||||
"authIssuer": self.config["client"]["auth_issuer"],
|
||||
"provider": self.config["client"]["provider"],
|
||||
"providerTerritory": self.config["client"]["territory"],
|
||||
"proposition": self.config["client"]["proposition"],
|
||||
"personaId": persona
|
||||
},
|
||||
"device": {
|
||||
"type": self.config["client"]["device"],
|
||||
"platform": self.config["client"]["platform"],
|
||||
"id": self.config["client"]["id"],
|
||||
"drmDeviceId": self.config["client"]["drm_device_id"]
|
||||
}
|
||||
}, separators=(",", ":"))
|
||||
|
||||
# Get the tokens
|
||||
tokens = self.session.post(
|
||||
url=self.config["endpoints"]["tokens"],
|
||||
headers=dict(**sky_headers, **{
|
||||
"Accept": "application/vnd.tokens.v1+json",
|
||||
"Content-Type": "application/vnd.tokens.v1+json",
|
||||
"X-Sky-Signature": self.create_signature_header(
|
||||
method="POST",
|
||||
path="/auth/tokens",
|
||||
sky_headers=sky_headers,
|
||||
body=body,
|
||||
timestamp=int(time.time())
|
||||
)
|
||||
}),
|
||||
data=body
|
||||
).json()
|
||||
|
||||
# Cache the tokens
|
||||
if not cache:
|
||||
cache = self.cache.get(f"tokens_{self.profile}_{self.config['client']['id']}")
|
||||
cache.set(data=tokens)
|
||||
|
||||
return tokens
|
||||
|
||||
def verify_tokens(self):
|
||||
"""Verify the tokens by calling the /auth/users/me endpoint"""
|
||||
sky_headers = {
|
||||
"X-SkyOTT-Device": self.config["client"]["device"],
|
||||
"X-SkyOTT-Platform": self.config["client"]["platform"],
|
||||
"X-SkyOTT-Proposition": self.config["client"]["proposition"],
|
||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
||||
"X-SkyOTT-UserToken": self.tokens["userToken"]
|
||||
}
|
||||
|
||||
try:
|
||||
self.session.get(
|
||||
url=self.config["endpoints"]["me"],
|
||||
headers=dict(**sky_headers, **{
|
||||
"Accept": "application/vnd.userinfo.v2+json",
|
||||
"Content-Type": "application/vnd.userinfo.v2+json",
|
||||
"X-Sky-Signature": self.create_signature_header(
|
||||
method="GET",
|
||||
path="/auth/users/me",
|
||||
sky_headers=sky_headers,
|
||||
body="",
|
||||
timestamp=int(time.time())
|
||||
)
|
||||
})
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return self._license_request(challenge)
|
||||
Executable → Regular
+30
-24
@@ -1,27 +1,33 @@
|
||||
endpoints:
|
||||
stream_tv: 'https://www.peacocktv.com/stream-tv/{title_id}'
|
||||
config: 'https://config.clients.peacocktv.com/{territory}/{provider}/{proposition}/{device}/PROD/{version}/config.json'
|
||||
login: 'https://rango.id.peacocktv.com/signin/service/international'
|
||||
personas: 'https://persona.id.peacocktv.com/persona-store/personas'
|
||||
tokens: 'https://ovp.peacocktv.com/auth/tokens'
|
||||
me: 'https://ovp.peacocktv.com/auth/users/me'
|
||||
node: 'https://atom.peacocktv.com/adapter-calypso/v3/query/node'
|
||||
vod: 'https://ovp.peacocktv.com/video/playouts/vod'
|
||||
profiles:
|
||||
tv:
|
||||
platform: ANDROIDTV
|
||||
device: TV
|
||||
client_sdk: NBCU-ANDROID-v3
|
||||
hmac_key: JuLQgyFz9n89D9pxcN6ZWZXKWfgj2PNBUb32zybj
|
||||
android:
|
||||
platform: ANDROID
|
||||
device: TABLET
|
||||
client_sdk: NBCU-ANDROID-v3
|
||||
hmac_key: JuLQgyFz9n89D9pxcN6ZWZXKWfgj2PNBUb32zybj
|
||||
web:
|
||||
platform: PC
|
||||
device: COMPUTER
|
||||
client_sdk: NBCU-WEB-v4
|
||||
hmac_key: FvT9VtwvhtSZvqnExMsvDDTEvBqR3HdsMcBFtWYV
|
||||
|
||||
client:
|
||||
config_version: '1.0.8'
|
||||
territory: 'US'
|
||||
provider: 'NBCU'
|
||||
proposition: 'NBCUOTT'
|
||||
platform: 'ANDROID' # PC, ANDROID
|
||||
device: 'TABLET' # COMPUTER, TABLET
|
||||
id: 'Jcvf1y0whKOI29vRXcJy'
|
||||
drm_device_id: 'UNKNOWN'
|
||||
client_sdk: 'NBCU-WEB-v4' # NBCU-ANDROID-v3 NBCU-ANDRTV-v4
|
||||
auth_scheme: 'MESSO'
|
||||
auth_issuer: 'NOWTV'
|
||||
territory: US
|
||||
provider: NBCU
|
||||
proposition: NBCUOTT
|
||||
auth_scheme: MESSO
|
||||
profile: tv
|
||||
device_id: PC
|
||||
drm_device_id: UNKNOWN
|
||||
|
||||
security:
|
||||
signature_hmac_key_v4: 'FvT9VtwvhtSZvqnExMsvDDTEvBqR3HdsMcBFtWYV'
|
||||
signature_hmac_key_v6: 'izU6EJqqu6DOhOWSk5X4p9dod3fNqH7vzKtYDK8d'
|
||||
signature_format: 'SkyOTT client="{client}",signature="{signature}",timestamp="{timestamp}",version="1.0"'
|
||||
endpoints:
|
||||
login: https://rango.id.peacocktv.com/signin/service/international
|
||||
personas: https://persona.id.peacocktv.com/persona-store/personas
|
||||
tokens: https://ovp.peacocktv.com/auth/tokens
|
||||
me: https://ovp.peacocktv.com/auth/users/me
|
||||
node: https://atom.peacocktv.com/adapter-calypso/v3/query/node
|
||||
vod: https://ovp.peacocktv.com/video/playouts/vod
|
||||
@@ -428,7 +428,7 @@ class RKTN(Service):
|
||||
self.log.info(f"Searching for additional H.264 video tracks (pattern: {codec_prefix})...")
|
||||
|
||||
|
||||
# Usar el directorio temp de Unshackle
|
||||
# Usar el directorio temp de.envied
|
||||
temp_file = os.path.join(str(config.directories.temp), "video_test.mp4")
|
||||
|
||||
|
||||
@@ -514,7 +514,7 @@ class RKTN(Service):
|
||||
# Codecs a probar (en orden de preferencia)
|
||||
codecs_to_try = ["ec-3", "ac-3", "dts", "mp4a"]
|
||||
|
||||
# Usar el directorio temp de Unshackle
|
||||
# Usar el directorio temp de.envied
|
||||
temp_file = os.path.join(str(config.directories.temp), "audio_test.mp4")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user