Added DSNP service
This commit is contained in:
parent
f90eaff39b
commit
debc33f62e
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,7 +4,7 @@
|
|||||||
/vinetrimmer/Cache/
|
/vinetrimmer/Cache/
|
||||||
/vinetrimmer/Cookies/
|
/vinetrimmer/Cookies/
|
||||||
/vinetrimmer/Logs/
|
/vinetrimmer/Logs/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|||||||
14
packaging/make.ps1
Normal file
14
packaging/make.ps1
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Tip: add argument `run` to directly run after build for fast testing
|
||||||
|
|
||||||
|
Write-Output 'Creating Python Wheel package via Poetry'
|
||||||
|
& 'poetry' build -f wheel
|
||||||
|
|
||||||
|
Write-Output 'Building to self-contained folder/app via PyInstaller'
|
||||||
|
& 'poetry' run python pyinstaller.py
|
||||||
|
|
||||||
|
if ($args[0] -eq 'run') {
|
||||||
|
& 'dist/vinetrimmer/vinetrimmer.exe' ($args | Select-Object -Skip 1)
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output 'Done! See /dist for output files.'
|
||||||
17
packaging/make.sh
Normal file
17
packaging/make.sh
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Tip: add argument `run` to directly run after build for fast testing
|
||||||
|
|
||||||
|
echo 'Creating Python Wheel package via Poetry'
|
||||||
|
poetry build -f wheel
|
||||||
|
|
||||||
|
echo 'Building to self-contained folder/app via PyInstaller'
|
||||||
|
poetry run python pyinstaller.py
|
||||||
|
|
||||||
|
if [ "$1" = 'run' ]; then
|
||||||
|
shift
|
||||||
|
./dist/vinetrimmer/vinetrimmer "$@"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo 'Done! See /dist for output files.'
|
||||||
95
packaging/pyinstaller.py
Normal file
95
packaging/pyinstaller.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import toml
|
||||||
|
from PyInstaller.__main__ import run
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
from PyInstaller.utils.win32.versioninfo import (FixedFileInfo, SetVersion, StringFileInfo, StringStruct,
|
||||||
|
StringTable, VarFileInfo, VarStruct, VSVersionInfo)
|
||||||
|
|
||||||
|
SCRIPT_PATH = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
|
||||||
|
"""Load pyproject.toml information."""
|
||||||
|
project = toml.load(os.path.join(SCRIPT_PATH, "pyproject.toml"))
|
||||||
|
poetry = project["tool"]["poetry"]
|
||||||
|
|
||||||
|
"""Configuration options that may be changed or referenced often."""
|
||||||
|
DEBUG = False # When False, removes un-needed data after build has finished
|
||||||
|
NAME = poetry["name"]
|
||||||
|
AUTHOR = "vinetrimmer contributors"
|
||||||
|
VERSION = poetry["version"]
|
||||||
|
ICON_FILE = "assets/icon.ico" # pass None to use default icon
|
||||||
|
ONE_FILE = False # Must be False if using setup.iss
|
||||||
|
CONSOLE = True # If build is intended for GUI, set to False
|
||||||
|
ADDITIONAL_DATA = [
|
||||||
|
# (local file path, destination in build output)
|
||||||
|
]
|
||||||
|
HIDDEN_IMPORTS = []
|
||||||
|
EXTRA_ARGS = [
|
||||||
|
"-y", "--win-private-assemblies", "--win-no-prefer-redirects"
|
||||||
|
]
|
||||||
|
|
||||||
|
"""Prepare environment to ensure output data is fresh."""
|
||||||
|
shutil.rmtree("build", ignore_errors=True)
|
||||||
|
shutil.rmtree("dist/vinetrimmer", ignore_errors=True)
|
||||||
|
# we don't want to use any spec, only the configuration set in this file
|
||||||
|
try:
|
||||||
|
os.unlink(f"{NAME}.spec")
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
"""Run PyInstaller with the provided configuration."""
|
||||||
|
run([
|
||||||
|
"vinetrimmer/vinetrimmer.py",
|
||||||
|
"-n", NAME,
|
||||||
|
"-i", ["NONE", ICON_FILE][bool(ICON_FILE)],
|
||||||
|
["-D", "-F"][ONE_FILE],
|
||||||
|
["-w", "-c"][CONSOLE],
|
||||||
|
*itertools.chain(*[["--add-data", os.pathsep.join(x)] for x in ADDITIONAL_DATA]),
|
||||||
|
*itertools.chain(*[["--hidden-import", x] for x in HIDDEN_IMPORTS]),
|
||||||
|
*EXTRA_ARGS
|
||||||
|
])
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
"""Set Version Info Structure."""
|
||||||
|
VERSION_4_TUP = tuple(map(int, f"{VERSION}.0".split(".")))
|
||||||
|
VERSION_4_STR = ".".join(map(str, VERSION_4_TUP))
|
||||||
|
SetVersion(
|
||||||
|
"dist/{0}/{0}.exe".format(NAME),
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers=VERSION_4_TUP,
|
||||||
|
prodvers=VERSION_4_TUP
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo([StringTable(
|
||||||
|
"040904B0", # ?
|
||||||
|
[
|
||||||
|
StringStruct("Comments", NAME),
|
||||||
|
StringStruct("CompanyName", AUTHOR),
|
||||||
|
StringStruct("FileDescription", "Widevine DRM downloader and decrypter"),
|
||||||
|
StringStruct("FileVersion", VERSION_4_STR),
|
||||||
|
StringStruct("InternalName", NAME),
|
||||||
|
StringStruct("LegalCopyright", f"Copyright (C) 2019-2021 {AUTHOR}"),
|
||||||
|
StringStruct("OriginalFilename", ""),
|
||||||
|
StringStruct("ProductName", NAME),
|
||||||
|
StringStruct("ProductVersion", VERSION_4_STR)
|
||||||
|
]
|
||||||
|
)]),
|
||||||
|
VarFileInfo([VarStruct("Translation", [0, 1200])]) # ?
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not DEBUG:
|
||||||
|
shutil.rmtree("build", ignore_errors=True)
|
||||||
|
# we don't want to keep the generated spec
|
||||||
|
try:
|
||||||
|
os.unlink(f"{NAME}.spec")
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
50
vinetrimmer/config/Services/disneyplus.yml
Normal file
50
vinetrimmer/config/Services/disneyplus.yml
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
certificate: |
|
||||||
|
CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml
|
||||||
|
ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa
|
||||||
|
xsU4v8tQDA8PcjzzV9ICodl6crcFZhAy4QMNXfbWOv/ZrGFx5blSXrzP1sMQ64IY8bjUYw4coZM34NDhu8aCA692g8k2mTz2494x7u3Is8v7RKC9ZNiETE
|
||||||
|
K5/4oeVclXPpelNQokR4uvggnCD1L2EULG/pp6wnk1yWNNLxcCAwEAAToHYmFtdGVjaBKAA2FqHlqkE7EUmdOLiCi0hy5jRgBDJrU1CWNHfH6r2i6s5T5k
|
||||||
|
6LK7ZfD65Tv6uyqq1k82PsDz4++kxbpfJDZaypFbae4XPc6lZxRCc5X0toX/x9TftOQQ4N82l5Hxoha569EPRkrnNy7rO7xrRILa3ZVj1alttEnEEjxEuw
|
||||||
|
SV8usdlUg8/LvLA2C59T/HA2I77k7yVbTrVdy0f81r2l+E2SslivCy1JD3xKlgoaKl4xBnRxItWt8+DCw1Xm2lemYl2LGoh1Wk9gvlXQvr2Jv2+dFX3RNs
|
||||||
|
i5sd00KS9sePszfjoTkQ6fmpRd7ZgFCGFWYB9JZ92aGUFQRE14OTST2uwSf32YCfsoATDNs4V6dB8YDoTGKFGrcoc4gtHPKySGNt7z/fOW4/01ZGzKqoVY
|
||||||
|
Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/3GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw==
|
||||||
|
|
||||||
|
# taken from web application, firefox, linux
|
||||||
|
##
|
||||||
|
# firefox, linux: ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
|
||||||
|
# android phones, tv, and fire tv stick: ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA
|
||||||
|
# <INFO BASE64>.<KEY?> e.g. for browser: disney&browser&1.0.0
|
||||||
|
##
|
||||||
|
device_api_key: 'ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA'
|
||||||
|
|
||||||
|
bamsdk:
|
||||||
|
##
|
||||||
|
# Browser (Windows, Chrome), isUhdAllowed: false.
|
||||||
|
# https://bam-sdk-configs.bamgrid.com/bam-sdk/v3.0/disney-svod-3d9324fc/browser/v6.1/windows/chrome/prod.json
|
||||||
|
# Android Phone, isUhdAllowed: false.
|
||||||
|
# https://bam-sdk-configs.bamgrid.com/bam-sdk/v3.0/disney-svod-3d9324fc/android/v5.1.0/google/handset/prod.json
|
||||||
|
# Android TV, isUhdAllowed: TRUE!
|
||||||
|
# https://bam-sdk-configs.bamgrid.com/bam-sdk/v3.0/disney-svod-3d9324fc/android/v5.1.0/google/tv/prod.json
|
||||||
|
# Amazon Fire TV, isUhdAllowed: TRUE!
|
||||||
|
# https://bam-sdk-configs.bamgrid.com/bam-sdk/v3.0/disney-svod-3d9324fc/android/v5.1.0/amazon/tv/prod.json
|
||||||
|
##
|
||||||
|
season: https://disney.api.edge.bamgrid.com/explore/v1.0/season/{id}
|
||||||
|
page: https://disney.api.edge.bamgrid.com/explore/v1.2/page/{id}
|
||||||
|
config: 'https://bam-sdk-configs.bamgrid.com/bam-sdk/v3.0/disney-svod-3d9324fc/android/v6.0.0/google/tv/prod.json'
|
||||||
|
family: 'browser'
|
||||||
|
profile: 'tv'
|
||||||
|
##
|
||||||
|
# android phone: handset
|
||||||
|
# android tv: tv? android?
|
||||||
|
##
|
||||||
|
applicationRuntime: 'android'
|
||||||
|
##
|
||||||
|
# android phone: android-phone
|
||||||
|
# android tv: android-tv
|
||||||
|
##
|
||||||
|
platform: 'android/google/tv'
|
||||||
|
version: '9.7.1'
|
||||||
|
##
|
||||||
|
# samsung s8: BAMSDK/v4.18.2 (disney-svod-3d9324fc 1.7.2.0; v2.0/v4.18.0; android; phone) samsung SM-G950F (PPR1.180610.011.G950FXXS8DTC1; Linux; 9; API 28)
|
||||||
|
# android tv: BAMSDK/v4.18.2 (disney-svod-3d9324fc 1.7.2.0; v2.0/v4.18.0; android; tv) google Nexus Player (OPR2.170623.027; Linux; 8.0.0; API 26)
|
||||||
|
##
|
||||||
|
user_agent: 'BAMSDK/v9.7.1 (disney-svod-3d9324fc 2.26.3-rc2.0; v5.0/v9.7.0; android; tv) SDMC S905X4 OTT Box (RTM6.230109.097; Linux; 11; API 30)'
|
||||||
68
vinetrimmer/config/Services/hulu.yml
Normal file
68
vinetrimmer/config/Services/hulu.yml
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
user_agent: 'Mozilla/5.0 (Fire OS 6.2.7.6; Amazon AFTMM; armeabi-v7a) AppleWebKit/604.1.38 (KHTML, like Gecko) Neutron/1.3.37 Hulu/0 TV Safari/604.1.38'
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
movie: 'https://discover.hulu.com/content/v5/hubs/movie/{id}?limit=999&schema=9&referralHost=production'
|
||||||
|
series: 'https://discover.hulu.com/content/v5/hubs/series/{id}?limit=999&schema=9&referralHost=production'
|
||||||
|
season: 'https://discover.hulu.com/content/v5/hubs/series/{id}/season/{season}?limit=999&schema=9&referralHost=production'
|
||||||
|
|
||||||
|
device:
|
||||||
|
PC:
|
||||||
|
code: '159' # Referred to as just "PC", but what specifically is it
|
||||||
|
key: '6ebfc84f3c304217b20fd9a66cb5957f'
|
||||||
|
|
||||||
|
Chrome:
|
||||||
|
code: '190' # Same key as "PC", but chrome specific? code just changed?
|
||||||
|
key: '6ebfc84f3c304217b20fd9a66cb5957f'
|
||||||
|
|
||||||
|
FireTV:
|
||||||
|
code: '188'
|
||||||
|
key: 'ca8d311a734854871623d906b968a073'
|
||||||
|
|
||||||
|
FireTV4K:
|
||||||
|
code: '208'
|
||||||
|
key: 'fa49ca06261fe41b6e56fa2d24b4f295'
|
||||||
|
|
||||||
|
Shield:
|
||||||
|
code: '109'
|
||||||
|
key: 'd0f4adc1d8a774256acb00c0fff46f5f'
|
||||||
|
|
||||||
|
Shield2:
|
||||||
|
code: '142' # TODO: Might not be a shield device, was referred to as "shield2"
|
||||||
|
key: 'd6bdf1f49c73db36f465536162ccc830'
|
||||||
|
|
||||||
|
codecs:
|
||||||
|
video_selection: 'ONE'
|
||||||
|
audio_selection: 'ALL'
|
||||||
|
|
||||||
|
video:
|
||||||
|
- type: 'H265'
|
||||||
|
profile: 'MAIN_10'
|
||||||
|
width: 3840
|
||||||
|
height: 2160
|
||||||
|
framerate: 60
|
||||||
|
level: '5.1'
|
||||||
|
tier: 'MAIN'
|
||||||
|
|
||||||
|
- type: 'H264'
|
||||||
|
profile: 'HIGH'
|
||||||
|
width: 1920
|
||||||
|
height: 1080
|
||||||
|
framerate: 60
|
||||||
|
level: '5.2'
|
||||||
|
|
||||||
|
audio:
|
||||||
|
- type: 'AAC'
|
||||||
|
|
||||||
|
- type: 'EC3'
|
||||||
|
|
||||||
|
drm:
|
||||||
|
selection_mode: 'ONE'
|
||||||
|
hdcp: true
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
- type: 'WIDEVINE'
|
||||||
|
version: 'MODULAR'
|
||||||
|
security_level: 'L1'
|
||||||
|
- type: 'PLAYREADY'
|
||||||
|
version: 'V2'
|
||||||
|
security_level: 'SL3000'
|
||||||
49
vinetrimmer/config/Services/paramountplus.yml
Normal file
49
vinetrimmer/config/Services/paramountplus.yml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
US:
|
||||||
|
device_link: True
|
||||||
|
base_url: 'https://www.paramountplus.com'
|
||||||
|
at_token: 'ABC+2JjrOUYWbaaqKmzwPdppq0RDB2WdufcFmIsSnJDmDEQpVgyAjQpqpEDksKZNMKQ='
|
||||||
|
login: 'https://www.paramountplus.com/apps-api/v2.0/androidphone/auth/login.json'
|
||||||
|
status: 'https://www.paramountplus.com/apps-api/v2.0/androidphone/app/status.json'
|
||||||
|
movie: 'https://www.paramountplus.com/apps-api/v3.0/androidphone/movies/{title_id}.json'
|
||||||
|
shows: 'https://www.paramountplus.com/apps-api/v3.0/androidphone/shows/slug/{title}.json'
|
||||||
|
section: 'https://www.paramountplus.com/apps-api/v2.0/androidphone/shows/{showId}/videos/config/{config}.json'
|
||||||
|
seasons: 'https://www.paramountplus.com/apps-api/v2.0/androidphone/videos/section/{section}.json'
|
||||||
|
show: 'https://www.paramountplus.com/apps-api/v3.0/androidphone/shows/{}.json'
|
||||||
|
video_items: 'https://www.paramountplus.com/apps-api/v2.0/{device}/video/cid/{content_id}.json'
|
||||||
|
barrearUrl: 'https://www.paramountplus.com/apps-api/v3.1/androidphone/irdeto-control/anonymous-session-token.json'
|
||||||
|
|
||||||
|
FR:
|
||||||
|
device_link: True
|
||||||
|
base_url: 'https://www.paramountplus.com'
|
||||||
|
at_token: 'ABAS/G30Pp6tJuNOlZ1OEE6Rf5goS0KjICkGkBVIapVuxemiiASyWVfW4v7SUeAkogc='
|
||||||
|
login: 'https://www.paramountplus.com/apps-api/v2.0/androidphone/auth/login.json'
|
||||||
|
status: 'https://www.paramountplus.com/apps-api/v3.0/androidphone/login/status.json'
|
||||||
|
movie: 'https://www.paramountplus.com/apps-api/v3.0/androidphone/movies/{title_id}.json'
|
||||||
|
shows: 'https://www.paramountplus.com/apps-api/v3.0/androidphone/shows/slug/{title}.json'
|
||||||
|
section: 'https://www.paramountplus.com/apps-api/v2.0/androidphone/shows/{showId}/videos/config/{config}.json'
|
||||||
|
seasons: 'https://www.paramountplus.com/apps-api/v2.0/androidphone/videos/section/{section}.json'
|
||||||
|
show: 'https://www.paramountplus.com/apps-api/v3.0/androidphone/shows/{}.json'
|
||||||
|
video_items: 'https://www.paramountplus.com/apps-api/v2.0/{device}/video/cid/{content_id}.json'
|
||||||
|
barrearUrl: 'https://www.paramountplus.com/apps-api/v3.1/androidphone/irdeto-control/anonymous-session-token.json'
|
||||||
|
|
||||||
|
INTL:
|
||||||
|
device_link: False
|
||||||
|
rows: 50 # Int number between 1 and 50
|
||||||
|
base_url: 'https://www.intl.paramountplus.com'
|
||||||
|
at_token: 'ABAS/G30Pp6tJuNOlZ1OEE6Rf5goS0KjICkGkBVIapVuxemiiASyWVfW4v7SUeAkogc='
|
||||||
|
login: 'https://www.intl.paramountplus.com/apps-api/v2.0/androidphone/auth/login.json'
|
||||||
|
status: 'https://www.intl.paramountplus.com/apps-api/v3.0/androidphone/login/status.json'
|
||||||
|
movie: 'https://www.intl.paramountplus.com/apps-api/v3.0/androidphone/movies/{title_id}.json'
|
||||||
|
shows: 'https://www.intl.paramountplus.com/apps-api/v3.0/androidphone/shows/slug/{title}.json'
|
||||||
|
section: 'https://www.intl.paramountplus.com/apps-api/v2.0/androidphone/shows/{showId}/videos/config/{config}.json'
|
||||||
|
seasons: 'https://www.intl.paramountplus.com/apps-api/v2.0/androidphone/videos/section/{section}.json'
|
||||||
|
show: 'https://www.intl.paramountplus.com/apps-api/v3.0/androidphone/shows/{}.json'
|
||||||
|
video_items: 'https://www.intl.paramountplus.com/apps-api/v2.0/{device}/video/cid/{content_id}.json'
|
||||||
|
barrearUrl: 'https://www.intl.paramountplus.com/apps-api/v3.1/androidphone/irdeto-control/session-token.json'
|
||||||
|
|
||||||
|
Android:
|
||||||
|
UserAgent: 'Mozilla/5.0 (Linux; Android 13; SM-A536E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36'
|
||||||
|
|
||||||
|
LINK_PLATFORM_URL: 'http://link.theplatform.com/s/dJ5BDC/media/guid/2198311517/{video_id}'
|
||||||
|
license: 'https://cbsi.live.ott.irdeto.com/widevine/getlicense'
|
||||||
|
license_pr: 'https://cbsi.live.ott.irdeto.com/playready/rightsmanager.asmx'
|
||||||
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,9 @@ from vinetrimmer.services.hotstar import Hotstar
|
|||||||
from vinetrimmer.services.jio import Jio
|
from vinetrimmer.services.jio import Jio
|
||||||
from vinetrimmer.services.moviesanywhere import MoviesAnywhere
|
from vinetrimmer.services.moviesanywhere import MoviesAnywhere
|
||||||
from vinetrimmer.services.sonyliv import Sonyliv
|
from vinetrimmer.services.sonyliv import Sonyliv
|
||||||
|
from vinetrimmer.services.disneyplus import DisneyPlus
|
||||||
|
from vinetrimmer.services.hulu import Hulu
|
||||||
|
from vinetrimmer.services.paramountplus import ParamountPlus
|
||||||
|
|
||||||
# Above is necessary since dynamic imports like below fuck up nuitak
|
# Above is necessary since dynamic imports like below fuck up nuitak
|
||||||
|
|
||||||
|
|||||||
567
vinetrimmer/services/disneyplus.py
Normal file
567
vinetrimmer/services/disneyplus.py
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import click
|
||||||
|
import m3u8
|
||||||
|
|
||||||
|
from vinetrimmer.objects import MenuTrack, Title, Tracks
|
||||||
|
from vinetrimmer.services.BaseService import BaseService
|
||||||
|
from vinetrimmer.utils.BamSDK import BamSdk
|
||||||
|
from vinetrimmer.utils.collections import as_list
|
||||||
|
from vinetrimmer.utils.io import get_ip_info
|
||||||
|
from vinetrimmer.utils.widevine.device import LocalDevice
|
||||||
|
|
||||||
|
|
||||||
|
class DisneyPlus(BaseService):
|
||||||
|
"""
|
||||||
|
Service code for Disney's Disney+ streaming service (https://disneyplus.com).
|
||||||
|
|
||||||
|
\b
|
||||||
|
Authorization: Credentials
|
||||||
|
Security: UHD@L1 FHD@L1 HD@L3, HEAVILY monitors high-profit and newly released titles!!
|
||||||
|
|
||||||
|
\b
|
||||||
|
Tips: - Some titles offer a setting in its Details tab to prefer "Remastered" or Original format
|
||||||
|
- You can specify which profile is used for its preferences and such in the config file
|
||||||
|
"""
|
||||||
|
|
||||||
|
ALIASES = ["DSNP", "disneyplus", "disney+"]
|
||||||
|
TITLE_RE = [
|
||||||
|
r"^https?://(?:www\.)?disneyplus\.com(?:/[a-z0-9-]+)?(?:/[a-z0-9-]+)?/(?P<type>browse)/(?P<id>entity-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
AUDIO_CODEC_MAP = {
|
||||||
|
"AAC": ["aac"],
|
||||||
|
"EC3": ["eac", "atmos"]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="DisneyPlus", short_help="https://disneyplus.com")
|
||||||
|
@click.argument("title", type=str, required=False)
|
||||||
|
@click.option("-m", "--movie", is_flag=True, default=False, help="Title is a movie.")
|
||||||
|
@click.option("-s", "--scenario", default="tv-drm-ctr", type=str,
|
||||||
|
help="Capability profile that specifies compatible codecs, streams, bit-rates, resolutions and such.")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, **kwargs):
|
||||||
|
return DisneyPlus(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx, title, movie, scenario):
|
||||||
|
super().__init__(ctx)
|
||||||
|
m = self.parse_title(ctx, title)
|
||||||
|
self.movie = movie #or m.get("type") == "movies"
|
||||||
|
#self.type = m.get("type")
|
||||||
|
self.scenario = scenario
|
||||||
|
|
||||||
|
self.vcodec = ctx.parent.params["vcodec"]
|
||||||
|
self.acodec = ctx.parent.params["acodec"]
|
||||||
|
self.range = ctx.parent.params["range_"]
|
||||||
|
self.wanted = ctx.parent.params["wanted"]
|
||||||
|
|
||||||
|
self.playready = True if "group_certificate" in dir(ctx.obj.cdm.device) else False # ctx.obj.cdm.device.type == LocalDevice.Types.PLAYREADY
|
||||||
|
|
||||||
|
self.region = None
|
||||||
|
self.bamsdk = None
|
||||||
|
self.device_token = None
|
||||||
|
self.account_tokens = {}
|
||||||
|
|
||||||
|
self.configure()
|
||||||
|
|
||||||
|
def get_titles(self):
|
||||||
|
|
||||||
|
if self.movie:
|
||||||
|
#original_lang = self.get_hulu_series(self.title)['originalLanguage']
|
||||||
|
data = self.get_hulu_series(self.title)["data"]["page"]
|
||||||
|
movie = Title(
|
||||||
|
id_=self.title,
|
||||||
|
type_=Title.Types.MOVIE,
|
||||||
|
name=data['visuals']['title'],
|
||||||
|
year=data['visuals']['metastringParts']['releaseYearRange']['startYear'],
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
original_lang="en",
|
||||||
|
service_data=data["containers"][-1]["visuals"]
|
||||||
|
)
|
||||||
|
|
||||||
|
movie.service_data = data["actions"][0]["resourceId"]
|
||||||
|
|
||||||
|
return movie
|
||||||
|
|
||||||
|
else:
|
||||||
|
data = self.get_hulu_series(self.title)["data"].get("page")
|
||||||
|
if not data:
|
||||||
|
raise self.log.exit(" - No data returned")
|
||||||
|
|
||||||
|
season_len = len(data["containers"][0]["seasons"])
|
||||||
|
if data["containers"][0].get("type") == "episodes":
|
||||||
|
if season_len == 0:
|
||||||
|
raise self.log.exit(" - No seasons available")
|
||||||
|
|
||||||
|
seasons = list()
|
||||||
|
for x, season in enumerate(
|
||||||
|
reversed(data["containers"][0]["seasons"]), start=1
|
||||||
|
):
|
||||||
|
episodes = self.get_hulu_season(season["id"])["data"]["season"]["items"]
|
||||||
|
self.log.debug(episodes)
|
||||||
|
seasons += [
|
||||||
|
Title(
|
||||||
|
id_=t2["id"],
|
||||||
|
type_=Title.Types.TV,
|
||||||
|
name=t2["visuals"]["title"],
|
||||||
|
season=t2["visuals"].get("seasonNumber"),
|
||||||
|
episode=t2["visuals"].get("episodeNumber"),
|
||||||
|
episode_name=t2["visuals"]
|
||||||
|
.get("episodeTitle")
|
||||||
|
.replace("(Sub) ", ""),
|
||||||
|
original_lang="en",
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
service_data=t2,
|
||||||
|
)
|
||||||
|
for t2 in episodes
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get mediaId from decoded resourceId
|
||||||
|
for x in seasons:
|
||||||
|
x.service_data["mediaMetadata"] = {}
|
||||||
|
x.service_data["mediaMetadata"]["mediaId"] = x.service_data["actions"][0]["resourceId"]
|
||||||
|
|
||||||
|
return seasons
|
||||||
|
|
||||||
|
# get data for every episode in every season via looping due to the fact
|
||||||
|
# that the api doesn't provide ALL episodes in the initial bundle api call.
|
||||||
|
# TODO: The season info returned might also be paged/limited
|
||||||
|
|
||||||
|
|
||||||
|
def get_tracks(self, title):
|
||||||
|
# Refresh token in case it expired
|
||||||
|
self.account_tokens = self.get_account_token(
|
||||||
|
credential=self.credentials,
|
||||||
|
device_family=self.config["bamsdk"]["family"],
|
||||||
|
device_token=self.device_token,
|
||||||
|
)
|
||||||
|
if self.movie:
|
||||||
|
tracks = self.get_manifest_tracks(
|
||||||
|
self.get_manifest_url(
|
||||||
|
media_id=title.service_data,
|
||||||
|
scenario=self.scenario
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
tracks = self.get_manifest_tracks(
|
||||||
|
self.get_manifest_url(
|
||||||
|
media_id=title.service_data["mediaMetadata"]["mediaId"],
|
||||||
|
scenario=self.scenario
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (not any((x.codec or "").startswith("atmos") for x in tracks.audios)
|
||||||
|
and not self.scenario.endswith(("-atmos", "~unlimited"))):
|
||||||
|
self.log.info(" + Attempting to get Atmos audio from H265 manifest")
|
||||||
|
try:
|
||||||
|
atmos_scenario = self.get_manifest_tracks(
|
||||||
|
self.get_manifest_url(
|
||||||
|
media_id=title.service_data["mediaMetadata"]["mediaId"],
|
||||||
|
scenario="tv-drm-ctr-h265-atmos"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
atmos_scenario = self.get_manifest_tracks(
|
||||||
|
self.get_manifest_url(
|
||||||
|
media_id=title.service_data,
|
||||||
|
scenario="tv-drm-ctr-h265-atmos"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tracks.audios.extend(atmos_scenario.audios)
|
||||||
|
tracks.subtitles.extend(atmos_scenario.subtitles)
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def get_chapters(self, title):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def certificate(self, **_):
|
||||||
|
return None if self.playready else self.config["certificate"]
|
||||||
|
|
||||||
|
def license(self, challenge, **_):
|
||||||
|
# Refresh token in case it expired
|
||||||
|
self.account_tokens = self.get_account_token(
|
||||||
|
credential=self.credentials,
|
||||||
|
device_family=self.config["bamsdk"]["family"],
|
||||||
|
device_token=self.device_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.playready:
|
||||||
|
res = self.bamsdk.drm.playreadyLicense(
|
||||||
|
licence=challenge.decode(), # expects XML
|
||||||
|
access_token=self.account_tokens["access_token"]
|
||||||
|
)
|
||||||
|
res = base64.b64encode(res).decode()
|
||||||
|
else:
|
||||||
|
res = self.bamsdk.drm.widevineLicense(
|
||||||
|
licence=challenge, # expects bytes
|
||||||
|
access_token=self.account_tokens["access_token"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
# Service specific functions
|
||||||
|
|
||||||
|
def configure(self):
|
||||||
|
self.session.headers.update({
|
||||||
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
|
"User-Agent": self.config["bamsdk"]["user_agent"],
|
||||||
|
"Origin": "https://www.disneyplus.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
self.log.info("Preparing")
|
||||||
|
if self.range != "SDR" and self.vcodec != "H265":
|
||||||
|
# vcodec must be H265 for High Dynamic Range
|
||||||
|
self.vcodec = "H265"
|
||||||
|
self.log.info(f" + Switched video codec to H265 to be able to get {self.range} dynamic range")
|
||||||
|
self.scenario = self.prepare_scenario(self.scenario, self.vcodec, self.range)
|
||||||
|
self.log.info(f" + Scenario: {self.scenario}")
|
||||||
|
|
||||||
|
self.log.info("Getting BAMSDK Configuration")
|
||||||
|
|
||||||
|
ip_info = get_ip_info(self.session, fresh=True)
|
||||||
|
self.region = ip_info["country_code"].upper()
|
||||||
|
self.config["location_x"] = ip_info["latitude"]
|
||||||
|
self.config["location_y"] = ip_info["longitude"]
|
||||||
|
self.log.info(f" + IP Location: {self.config['location_x']},{self.config['location_y']}")
|
||||||
|
|
||||||
|
self.bamsdk = BamSdk(self.config["bamsdk"]["config"], self.session)
|
||||||
|
self.session.headers.update(dict(**{
|
||||||
|
k.lower(): v.replace(
|
||||||
|
"{SDKPlatform}", self.config["bamsdk"]["platform"]
|
||||||
|
).replace(
|
||||||
|
"{SDKVersion}", self.config["bamsdk"]["version"]
|
||||||
|
) for k, v in self.bamsdk.commonHeaders.items()
|
||||||
|
}, **{
|
||||||
|
"user-agent": self.config["bamsdk"]["user_agent"]
|
||||||
|
}))
|
||||||
|
|
||||||
|
self.log.debug(" + Capabilities:")
|
||||||
|
for k, v in self.bamsdk.media.extras.items():
|
||||||
|
self.log.debug(f" {k}: {v}")
|
||||||
|
|
||||||
|
self.log.info("Logging into Disney+")
|
||||||
|
self.device_token, self.account_tokens = self.login(self.credentials)
|
||||||
|
|
||||||
|
session_info = self.bamsdk.session.getInfo(self.account_tokens["access_token"])
|
||||||
|
self.log.info(f" + Account ID: {session_info['account']['id']}")
|
||||||
|
self.log.info(f" + Profile ID: {session_info['profile']['id']}")
|
||||||
|
self.log.info(f" + Subscribed: {session_info['isSubscriber']}")
|
||||||
|
self.log.info(f" + Account Region: {session_info['home_location']['country_code']}")
|
||||||
|
self.log.info(f" + Detected Location: {session_info['location']['country_code']}")
|
||||||
|
self.log.info(f" + Supported Location: {session_info['inSupportedLocation']}")
|
||||||
|
self.log.info(f" + Device: {session_info['device']['platform']}")
|
||||||
|
|
||||||
|
if not session_info["isSubscriber"]:
|
||||||
|
raise self.log.exit(" - Cannot continue, account is not subscribed to Disney+.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def prepare_scenario(scenario, vcodec, range_):
|
||||||
|
"""Prepare Disney+'s scenario based on other arguments and settings."""
|
||||||
|
if scenario.endswith("~unlimited"):
|
||||||
|
# if unlimited scenario, nothing needs to be appended or changed.
|
||||||
|
# the scenario will return basically all streams it can.
|
||||||
|
return scenario
|
||||||
|
if vcodec == "H265":
|
||||||
|
scenario += "-h265"
|
||||||
|
if range_ == "HDR10":
|
||||||
|
scenario += "-hdr10"
|
||||||
|
elif range_ == "DV":
|
||||||
|
scenario += "-dovi"
|
||||||
|
return scenario
|
||||||
|
|
||||||
|
def login(self, credential):
|
||||||
|
"""Log into Disney+ and retrieve various authorisation keys."""
|
||||||
|
device_token = self.create_device_token(
|
||||||
|
family=self.config["bamsdk"]["family"],
|
||||||
|
profile=self.config["bamsdk"]["profile"],
|
||||||
|
application=self.config["bamsdk"]["applicationRuntime"],
|
||||||
|
api_key=self.config["device_api_key"]
|
||||||
|
)
|
||||||
|
self.log.info(" + Obtained Device Token")
|
||||||
|
account_tokens = self.get_account_token(
|
||||||
|
credential=credential,
|
||||||
|
device_family=self.config["bamsdk"]["family"],
|
||||||
|
device_token=device_token,
|
||||||
|
)
|
||||||
|
self.log.info(" + Obtained Account Token")
|
||||||
|
return device_token, account_tokens
|
||||||
|
|
||||||
|
def create_device_token(self, family, profile, application, api_key):
|
||||||
|
"""
|
||||||
|
Create a Device Token for a specified device type.
|
||||||
|
This tells the API's what is possible for your device.
|
||||||
|
:param family: Device Family.
|
||||||
|
:param profile: Device Profile.
|
||||||
|
:param application: Device Runtime, the use case of the device.
|
||||||
|
:param api_key: Device API Key.
|
||||||
|
:returns: Device Exchange Token.
|
||||||
|
"""
|
||||||
|
# create an initial assertion grant used to identify the kind of device profile-level.
|
||||||
|
# TODO: cache this, it doesn't need to be obtained unless the values change
|
||||||
|
device_grant = self.bamsdk.device.createDeviceGrant(
|
||||||
|
json={
|
||||||
|
"deviceFamily": family,
|
||||||
|
"applicationRuntime": application,
|
||||||
|
"deviceProfile": profile,
|
||||||
|
"attributes": {}
|
||||||
|
},
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
if "errors" in device_grant:
|
||||||
|
raise self.log.exit(
|
||||||
|
" - Failed to obtain the device assertion grant: "
|
||||||
|
f"{device_grant['errors']}"
|
||||||
|
)
|
||||||
|
# exchange the assertion grant for a usable device token.
|
||||||
|
device_token = self.bamsdk.token.exchange(
|
||||||
|
data={
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||||
|
"platform": family,
|
||||||
|
"subject_token": device_grant["assertion"],
|
||||||
|
"subject_token_type": self.bamsdk.token.subject_tokens["device"]
|
||||||
|
},
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
if "error" in device_token:
|
||||||
|
raise self.log.exit(
|
||||||
|
" - Failed to exchange the assertion grant for a device token: "
|
||||||
|
f"{device_token['error_description']} [{device_token['error']}]"
|
||||||
|
)
|
||||||
|
return device_token["access_token"]
|
||||||
|
|
||||||
|
def get_account_token(self, credential, device_family, device_token):
|
||||||
|
"""
|
||||||
|
Get an Account Token using Account Credentials and a Device Token, using a Cache store.
|
||||||
|
It also refreshes the token if needed.
|
||||||
|
"""
|
||||||
|
if not credential:
|
||||||
|
raise self.log.exit(" - No credentials provided, unable to log in.")
|
||||||
|
tokens_cache_path = self.get_cache(f"tokens_{self.region}_{credential.sha1}.json")
|
||||||
|
if os.path.isfile(tokens_cache_path):
|
||||||
|
self.log.info(" + Using cached tokens...")
|
||||||
|
with open(tokens_cache_path, encoding="utf-8") as fd:
|
||||||
|
tokens = json.load(fd)
|
||||||
|
if os.stat(tokens_cache_path).st_ctime > (time.time() - tokens["expires_in"]):
|
||||||
|
return tokens
|
||||||
|
# expired
|
||||||
|
self.log.info(" + Refreshing...")
|
||||||
|
tokens = self.refresh_token(
|
||||||
|
device_family=device_family,
|
||||||
|
refresh_token=tokens["refresh_token"],
|
||||||
|
api_key=self.config["device_api_key"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# first time
|
||||||
|
self.log.info(" + Getting new tokens...")
|
||||||
|
tokens = self.create_account_token(
|
||||||
|
device_family=self.config["bamsdk"]["family"],
|
||||||
|
email=credential.username,
|
||||||
|
password=credential.password,
|
||||||
|
device_token=device_token,
|
||||||
|
api_key=self.config["device_api_key"]
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(tokens_cache_path), exist_ok=True)
|
||||||
|
with open(tokens_cache_path, "w", encoding="utf-8") as fd:
|
||||||
|
json.dump(tokens, fd)
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def create_account_token(self, device_family, email, password, device_token, api_key):
|
||||||
|
"""
|
||||||
|
Create an Account Token using Account Credentials and a Device Token.
|
||||||
|
:param device_family: Device Family.
|
||||||
|
:param email: Account Email.
|
||||||
|
:param password: Account Password.
|
||||||
|
:param device_token: Device Token.
|
||||||
|
:param api_key: Device API Key.
|
||||||
|
:returns: Account Exchange Tokens.
|
||||||
|
"""
|
||||||
|
# log in to the account via bamsdk using the device token
|
||||||
|
identity_token = self.bamsdk.bamIdentity.identityLogin(
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
access_token=device_token
|
||||||
|
)
|
||||||
|
if "errors" in identity_token:
|
||||||
|
raise self.log.exit(
|
||||||
|
" - Failed to obtain the identity token: "
|
||||||
|
f"{identity_token['errors']}"
|
||||||
|
)
|
||||||
|
# create an initial assertion grant used to identify the account
|
||||||
|
# this seems to tie the account to the device token
|
||||||
|
account_grant = self.bamsdk.account.createAccountGrant(
|
||||||
|
json={"id_token": identity_token["id_token"]},
|
||||||
|
access_token=device_token
|
||||||
|
)
|
||||||
|
# exchange the assertion grant for a usable account token.
|
||||||
|
account_tokens = self.bamsdk.token.exchange(
|
||||||
|
data={
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||||
|
"platform": device_family,
|
||||||
|
"subject_token": account_grant["assertion"],
|
||||||
|
"subject_token_type": self.bamsdk.token.subject_tokens["account"]
|
||||||
|
},
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
# change profile and re-exchange if provided
|
||||||
|
if self.config.get("profile"):
|
||||||
|
profile_grant = self.change_profile(self.config["profile"], account_tokens["access_token"])
|
||||||
|
account_tokens = self.bamsdk.token.exchange(
|
||||||
|
data={
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||||
|
"platform": device_family,
|
||||||
|
"subject_token": profile_grant["assertion"],
|
||||||
|
"subject_token_type": self.bamsdk.token.subject_tokens["account"]
|
||||||
|
},
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
return account_tokens
|
||||||
|
|
||||||
|
def refresh_token(self, device_family, refresh_token, api_key):
|
||||||
|
"""
|
||||||
|
Refresh a Token using its adjacent refresh token.
|
||||||
|
:param device_family: Device Family.
|
||||||
|
:param refresh_token: Refresh Token.
|
||||||
|
:param api_key: Device API Key.
|
||||||
|
:returns: Account Exchange Token.
|
||||||
|
"""
|
||||||
|
return self.bamsdk.token.exchange(
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"platform": device_family,
|
||||||
|
"refresh_token": refresh_token
|
||||||
|
},
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
def change_profile(self, profile, access_token):
|
||||||
|
"""
|
||||||
|
Change to a different account user profile.
|
||||||
|
:param profile: profile by name, number, or directly by profile ID.
|
||||||
|
:param access_token: account access token.
|
||||||
|
:returns: profile grant tokens.
|
||||||
|
"""
|
||||||
|
if not profile:
|
||||||
|
raise self.log.exit(" - Profile cannot be empty")
|
||||||
|
try:
|
||||||
|
profile_id = uuid.UUID(str(profile))
|
||||||
|
self.log.info(f" + Switching profile to {profile_id}")
|
||||||
|
# is UUID
|
||||||
|
except ValueError:
|
||||||
|
profiles = self.bamsdk.account.getUserProfiles(access_token)
|
||||||
|
if isinstance(profile, int):
|
||||||
|
if len(profiles) < profile:
|
||||||
|
raise self.log.exit(
|
||||||
|
" - There isn't a {}{} profile for this account".format(
|
||||||
|
profile, "tsnrhtdd"[(profile // 10 % 10 != 1) * (profile % 10 < 4) * profile % 10::4]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
profile_data = profiles[profile - 1]
|
||||||
|
else:
|
||||||
|
profile_data = [x for x in profiles if x["profileName"] == profile]
|
||||||
|
if not profile_data:
|
||||||
|
raise self.log.exit(f" - Profile {profile!r} does not exist in this account")
|
||||||
|
profile_data = profile_data[0]
|
||||||
|
profile_id = profile_data["profileId"]
|
||||||
|
self.log.info(f" + Switching profile to {profile_data['profileName']!r} ({profile_id})")
|
||||||
|
res = self.bamsdk.account.setActiveUserProfile(str(profile_id), access_token)
|
||||||
|
if "errors" in res:
|
||||||
|
raise self.log.exit(f" - Failed! {res['errors'][0]['description']}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_manifest_url(self, media_id, scenario):
|
||||||
|
self.log.info(f"Retrieving manifest for {scenario}")
|
||||||
|
self.session.headers['x-dss-feature-filtering'] = 'true'
|
||||||
|
self.session.headers['x-application-version'] = '1.1.2'
|
||||||
|
self.session.headers['x-bamsdk-client-id'] = 'disney-svod'
|
||||||
|
self.session.headers['x-bamsdk-platform'] = 'javascript/windows/chrome'
|
||||||
|
self.session.headers['x-bamsdk-version'] = '28.0'
|
||||||
|
resolution = "1280x720" if str(self.scenario).lower() == "browser" else ""
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
'playback': {
|
||||||
|
'attributes': {
|
||||||
|
'resolution': {
|
||||||
|
'max': [
|
||||||
|
f'{resolution}',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'protocol': 'HTTPS',
|
||||||
|
'assetInsertionStrategy': 'SGAI',
|
||||||
|
'playbackInitiationContext': 'ONLINE',
|
||||||
|
'frameRates': [
|
||||||
|
60,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'playbackId': media_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest = self.session.post(
|
||||||
|
f'https://disney.playback.edge.bamgrid.com/v7/playback/{scenario}',
|
||||||
|
headers={"authorization": f"Bearer {self.account_tokens['access_token']}"},
|
||||||
|
json=json_data
|
||||||
|
).json()
|
||||||
|
|
||||||
|
self.chaps = {}
|
||||||
|
self.chaps["editorial"] = manifest["stream"].get("editorial", {})
|
||||||
|
|
||||||
|
return manifest["stream"]["sources"][0]['complete']['url']
|
||||||
|
|
||||||
|
def get_manifest_tracks(self, url):
|
||||||
|
self.session.get(url)
|
||||||
|
tracks = Tracks.from_m3u8(m3u8.load(url), source=self.ALIASES[0])
|
||||||
|
if self.acodec:
|
||||||
|
tracks.audios = [
|
||||||
|
x for x in tracks.audios if (x.codec or "").split("-")[0] in self.AUDIO_CODEC_MAP[self.acodec]
|
||||||
|
]
|
||||||
|
for video in tracks.videos:
|
||||||
|
# This is needed to remove weird glitchy NOP data at the end of stream
|
||||||
|
video.needs_repack = True
|
||||||
|
for audio in tracks.audios:
|
||||||
|
bitrate = re.search(r"(?<=r/composite_)\d+|\d+(?=_complete.m3u8)", as_list(audio.url)[0])
|
||||||
|
if not bitrate:
|
||||||
|
raise self.log.exit(" - Unable to get bitrate for an audio track")
|
||||||
|
audio.bitrate = int(bitrate.group()) * 1000
|
||||||
|
if audio.bitrate == 1000_000:
|
||||||
|
# DSNP lies about the Atmos bitrate
|
||||||
|
audio.bitrate = 768_000
|
||||||
|
for subtitle in tracks.subtitles:
|
||||||
|
subtitle.codec = "vtt"
|
||||||
|
subtitle.forced = subtitle.forced or subtitle.extra.name.endswith("--forced--")
|
||||||
|
# sdh might not actually occur, either way DSNP CC == SDH :)
|
||||||
|
subtitle.sdh = "[cc]" in subtitle.extra.name.lower() or "[sdh]" in subtitle.extra.name.lower()
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def get_hulu_series(self, content_id: str) -> dict:
|
||||||
|
r = self.session.get(
|
||||||
|
url=self.config["bamsdk"]["page"].format(id=content_id),
|
||||||
|
params={
|
||||||
|
"disableSmartFocus": True,
|
||||||
|
"enhancedContainersLimit": 12,
|
||||||
|
"limit": 999,
|
||||||
|
},
|
||||||
|
headers={"authorization": f"Bearer {self.account_tokens['access_token']}"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
def get_hulu_season(self, season_id: str) -> dict:
|
||||||
|
r = self.session.get(
|
||||||
|
url=self.config["bamsdk"]["season"].format(id=season_id),
|
||||||
|
params={"limit": 999},
|
||||||
|
headers={"authorization": f"Bearer {self.account_tokens['access_token']}"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
return r
|
||||||
@ -1,456 +0,0 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
import re
|
|
||||||
import requests
|
|
||||||
from datetime import datetime
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
from urllib.request import urlopen, Request
|
|
||||||
import http.cookiejar as cookiejar
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from vinetrimmer.objects import Title, Tracks
|
|
||||||
from vinetrimmer.services.BaseService import BaseService
|
|
||||||
from vinetrimmer.config import config, directories
|
|
||||||
|
|
||||||
class Hotstar(BaseService):
|
|
||||||
"""
|
|
||||||
Service code for Star India's Hotstar (aka Disney+ Hotstar) streaming service (https://hotstar.com).
|
|
||||||
|
|
||||||
\b
|
|
||||||
Authorization: Credentials
|
|
||||||
Security: UHD@L3, doesn't seem to care about releases.
|
|
||||||
|
|
||||||
\b
|
|
||||||
Tips: - The library of contents can be viewed without logging in at https://hotstar.com
|
|
||||||
- The homepage hosts domestic programming; Disney+ content is at https://hotstar.com/in/disneyplus
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES = ["HS", "hotstar"]
|
|
||||||
#GEOFENCE = ["in"]
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?hotstar\.com/[a-z0-9/-]+/)(?P<id>\d+)"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@click.command(name="Hotstar", short_help="https://hotstar.com")
|
|
||||||
@click.argument("title", type=str, required=False)
|
|
||||||
@click.option("-q", "--quality", default="hd",
|
|
||||||
type=click.Choice(["4k", "fhd", "hd", "sd"], case_sensitive=False),
|
|
||||||
help="Manifest quality to request.")
|
|
||||||
@click.option("-c", "--channels", default="5.1", type=click.Choice(["5.1", "2.0", "atmos"], case_sensitive=False),
|
|
||||||
help="Audio Codec")
|
|
||||||
@click.option("-rg", "--region", default="in", type=click.Choice(["in", "id", "th"], case_sensitive=False),
|
|
||||||
help="Account region")
|
|
||||||
@click.pass_context
|
|
||||||
def cli(ctx, **kwargs):
|
|
||||||
return Hotstar(ctx, **kwargs)
|
|
||||||
|
|
||||||
def __init__(self, ctx, title, quality, channels, region):
|
|
||||||
super().__init__(ctx)
|
|
||||||
self.parse_title(ctx, title)
|
|
||||||
self.quality = quality
|
|
||||||
self.channels = channels
|
|
||||||
self.region = region.lower()
|
|
||||||
|
|
||||||
assert ctx.parent is not None
|
|
||||||
|
|
||||||
self.vcodec = ctx.parent.params["vcodec"]
|
|
||||||
self.acodec = ctx.parent.params["acodec"] or "EC3"
|
|
||||||
self.range = ctx.parent.params["range_"]
|
|
||||||
|
|
||||||
|
|
||||||
self.profile = ctx.obj.profile
|
|
||||||
|
|
||||||
self.device_id = None
|
|
||||||
self.hotstar_auth = None
|
|
||||||
self.token = None
|
|
||||||
self.license_api = None
|
|
||||||
|
|
||||||
self.configure()
|
|
||||||
|
|
||||||
def get_titles(self):
|
|
||||||
headers = {
|
|
||||||
"Accept": "*/*",
|
|
||||||
"Accept-Language": "en-GB,en;q=0.5",
|
|
||||||
"hotstarauth": self.hotstar_auth,
|
|
||||||
"X-HS-UserToken": self.token,
|
|
||||||
"X-HS-Platform": self.config["device"]["platform"]["name"],
|
|
||||||
"X-HS-AppVersion": self.config["device"]["platform"]["version"],
|
|
||||||
"X-Country-Code": self.region,
|
|
||||||
"x-platform-code": "PCTV"
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
r = self.session.get(
|
|
||||||
url=self.config["endpoints"]["movie_title"],
|
|
||||||
headers=headers,
|
|
||||||
params={"contentId": self.title}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
res = r.json()["body"]["results"]["item"]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Failed to load title manifest: {res.text}")
|
|
||||||
except:
|
|
||||||
r = self.session.get(
|
|
||||||
url=self.config["endpoints"]["tv_title"],
|
|
||||||
headers=headers,
|
|
||||||
params={"contentId": self.title}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
res = r.json()["body"]["results"]["item"]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Failed to load title manifest: {res.text}")
|
|
||||||
|
|
||||||
if res["assetType"] == "MOVIE":
|
|
||||||
return Title(
|
|
||||||
id_=self.title,
|
|
||||||
type_=Title.Types.MOVIE,
|
|
||||||
name=res["title"],
|
|
||||||
year=res["year"],
|
|
||||||
original_lang=res["langObjs"][0]["iso3code"],
|
|
||||||
source=self.ALIASES[0],
|
|
||||||
service_data=res,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
r = self.session.get(
|
|
||||||
url=self.config["endpoints"]["tv_episodes"],
|
|
||||||
headers=headers,
|
|
||||||
params={
|
|
||||||
"eid": res["id"],
|
|
||||||
"etid": "2",
|
|
||||||
"tao": "0",
|
|
||||||
"tas": "1000"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
res = r.json()["body"]["results"]["assets"]["items"]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Failed to load episodes list: {r.text}")
|
|
||||||
return [Title(
|
|
||||||
id_=self.title,
|
|
||||||
type_=Title.Types.TV,
|
|
||||||
name=x.get("showShortTitle"),
|
|
||||||
year=x.get("year"),
|
|
||||||
season=x.get("seasonNo"),
|
|
||||||
episode=x.get("episodeNo"),
|
|
||||||
episode_name=x.get("title"),
|
|
||||||
original_lang=x["langObjs"][0]["iso3code"],
|
|
||||||
source=self.ALIASES[0],
|
|
||||||
service_data=x
|
|
||||||
) for x in res]
|
|
||||||
|
|
||||||
def get_tracks(self, title):
|
|
||||||
if title.service_data.get("parentalRating", 0) > 2:
|
|
||||||
body = json.dumps({
|
|
||||||
"devices": [{
|
|
||||||
"id": self.device_id,
|
|
||||||
"name": "Chrome Browser on Windows",
|
|
||||||
"consentProvided": True
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
self.session.post(
|
|
||||||
url="https://api.hotstar.com/play/v1/consent/content/{id}?".format(id=title.service_data["contentId"]),
|
|
||||||
headers={
|
|
||||||
"Accept": "*/*",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"hotstarauth": self.hotstar_auth,
|
|
||||||
"X-HS-UserToken": self.token,
|
|
||||||
"X-HS-Platform": self.config["device"]["platform"]["name"],
|
|
||||||
"X-HS-AppVersion": self.config["device"]["platform"]["version"],
|
|
||||||
"X-HS-Request-Id": str(uuid.uuid4()),
|
|
||||||
"X-Country-Code": self.region
|
|
||||||
},
|
|
||||||
data=body
|
|
||||||
).json()
|
|
||||||
akamai_cdn=True
|
|
||||||
count = 1
|
|
||||||
while akamai_cdn:
|
|
||||||
r = self.session.post(
|
|
||||||
url=self.config["endpoints"]["manifest"].format(id=title.service_data["contentId"]),
|
|
||||||
params={
|
|
||||||
# TODO: Perhaps set up desired-config to actual desired playback set values?
|
|
||||||
"desired-config": "|".join([
|
|
||||||
"audio_channel:stereo",
|
|
||||||
"container:fmp4",
|
|
||||||
"dynamic_range:sdr",
|
|
||||||
"encryption:widevine",
|
|
||||||
"ladder:tv",
|
|
||||||
"package:dash",
|
|
||||||
"resolution:fhd",
|
|
||||||
"video_codec:h264"
|
|
||||||
]),
|
|
||||||
"device-id": self.device_id,
|
|
||||||
"type": "paid",
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"Accept": "*/*",
|
|
||||||
"hotstarauth": self.hotstar_auth,
|
|
||||||
"x-hs-usertoken": self.token,
|
|
||||||
"x-hs-request-id": self.device_id,
|
|
||||||
"x-country-code": self.region
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
"os_name": "Windows",
|
|
||||||
"os_version": "10",
|
|
||||||
"app_name": "web",
|
|
||||||
"app_version": "7.34.1",
|
|
||||||
"platform": "Chrome",
|
|
||||||
"platform_version": "99.0.4844.82",
|
|
||||||
"client_capabilities": {
|
|
||||||
"ads": ["non_ssai"],
|
|
||||||
"audio_channel": ["stereo"],
|
|
||||||
"dvr": ["short"],
|
|
||||||
"package": ["dash", "hls"],
|
|
||||||
"dynamic_range": ["sdr"],
|
|
||||||
"video_codec": ["h264"],
|
|
||||||
"encryption": ["widevine"],
|
|
||||||
"ladder": ["tv"],
|
|
||||||
"container": ["fmp4", "ts"],
|
|
||||||
"resolution": ["hd"]
|
|
||||||
},
|
|
||||||
"drm_parameters": {
|
|
||||||
"widevine_security_level": ["SW_SECURE_DECODE", "SW_SECURE_CRYPTO"],
|
|
||||||
"hdcp_version": ["HDCP_V2_2", "HDCP_V2_1", "HDCP_V2", "HDCP_V1"]
|
|
||||||
},
|
|
||||||
"resolution": "auto",
|
|
||||||
"type": "paid",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
playback_sets = r.json()["data"]["playback_sets"]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"Manifest fetch failed: {r.text}")
|
|
||||||
|
|
||||||
# transform tagsCombination into `tags` key-value dictionary for easier usage
|
|
||||||
playback_sets = [dict(
|
|
||||||
**x,
|
|
||||||
tags=dict(y.split(":") for y in x["tags_combination"].lower().split(";"))
|
|
||||||
) for x in playback_sets]
|
|
||||||
#self.log.debug(playback_sets)
|
|
||||||
playback_set = next((
|
|
||||||
x for x in playback_sets
|
|
||||||
if x["tags"].get("encryption") == "widevine" or x["tags"].get("encryption") == "plain" # widevine, fairplay, playready
|
|
||||||
if x["tags"].get("package") == "dash" # dash, hls
|
|
||||||
if x["tags"].get("container") == "fmp4br" # fmp4, fmp4br, ts
|
|
||||||
if x["tags"].get("ladder") == "tv" # tv, phone
|
|
||||||
if x["tags"].get("video_codec").endswith(self.vcodec.lower()) # dvh265, h265, h264 - vp9?
|
|
||||||
# user defined, may not be available in the tags list:
|
|
||||||
if x["tags"].get("resolution") in [self.quality, None] # max is fine, -q can choose lower if wanted
|
|
||||||
if x["tags"].get("dynamic_range") in [self.range.lower(), None] # dv, hdr10, sdr - hdr10+?
|
|
||||||
if x["tags"].get("audio_codec") in [self.acodec.lower(), None] # ec3, aac - atmos?
|
|
||||||
if x["tags"].get("audio_channel") in [{"5.1": "dolby51", "2.0": "stereo", "atmos": "atmos"}[self.channels], None]
|
|
||||||
), None)
|
|
||||||
if not playback_set:
|
|
||||||
playback_set = next((
|
|
||||||
x for x in playback_sets
|
|
||||||
if x["tags"].get("encryption") == "widevine" or x["tags"].get("encryption") == "plain" # widevine, fairplay, playready
|
|
||||||
if x["tags"].get("package") == "dash" # dash, hls
|
|
||||||
if x["tags"].get("ladder") == "tv" # tv, phone
|
|
||||||
if x["tags"].get("resolution") in [self.quality, None]
|
|
||||||
), None)
|
|
||||||
if not playback_set:
|
|
||||||
raise ValueError("Wanted playback set is unavailable for this title...")
|
|
||||||
if "licence_url" in playback_set: self.license_api = playback_set["licence_url"]
|
|
||||||
if playback_set['token_algorithm'] == 'airtel-qwilt-vod' or playback_set['token_algorithm'] == 'AKAMAI-HMAC':
|
|
||||||
self.log.info(f'Gotcha!')
|
|
||||||
akamai_cdn = False
|
|
||||||
else:
|
|
||||||
self.log.info(f'Finding MPD... {count}')
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
r = Request(playback_set["playback_url"])
|
|
||||||
r.add_header("user-agent", "Hotstar;in.startv.hotstar/3.3.0 (Android/8.1.0)")
|
|
||||||
data = urlopen(r).read()
|
|
||||||
|
|
||||||
|
|
||||||
mpd_url = playback_set["playback_url"] # .replace(".hotstar.com", ".akamaized.net")
|
|
||||||
self.log.debug(mpd_url)
|
|
||||||
try:
|
|
||||||
self.session.headers.update({
|
|
||||||
"Cookie": self.hdntl,
|
|
||||||
})
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
tracks = Tracks.from_mpd(
|
|
||||||
url=mpd_url,
|
|
||||||
data=data,
|
|
||||||
session=self.session,
|
|
||||||
source=self.ALIASES[0]
|
|
||||||
)
|
|
||||||
for track in tracks:
|
|
||||||
track.needs_proxy = True
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_chapters(self, title):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def certificate(self, **_):
|
|
||||||
return None # will use common privacy cert
|
|
||||||
|
|
||||||
def license(self, challenge, **_):
|
|
||||||
return self.session.post(
|
|
||||||
url=self.license_api,
|
|
||||||
data=challenge # expects bytes
|
|
||||||
).content
|
|
||||||
|
|
||||||
# Service specific functions
|
|
||||||
|
|
||||||
def configure(self):
|
|
||||||
self.session.headers.update({
|
|
||||||
"Origin": "https://www.hotstar.com",
|
|
||||||
"Referer": f'"https://www.hotstar.com/{self.region}"'
|
|
||||||
})
|
|
||||||
self.log.info("Logging into Hotstar")
|
|
||||||
self.log.info(f'Setting region to "{self.region}"')
|
|
||||||
self.hotstar_auth = self.get_akamai()
|
|
||||||
self.log.info(f" + Calculated HotstarAuth: {self.hotstar_auth}")
|
|
||||||
try:
|
|
||||||
if self.cookies:
|
|
||||||
hdntl_cookies = [cookie for cookie in self.session.cookies if cookie.name == 'hdntl']
|
|
||||||
self.hdntl = f"hdntl={hdntl_cookies[-1].value}"
|
|
||||||
self.device_id = self.session.cookies.get("deviceId")
|
|
||||||
self.log.info(f" + Using Device ID: {self.device_id}")
|
|
||||||
except:
|
|
||||||
self.device_id = str(uuid.uuid4())
|
|
||||||
self.log.info(f" + Created Device ID: {self.device_id}")
|
|
||||||
self.session.headers.update({
|
|
||||||
"dnt": "1"
|
|
||||||
})
|
|
||||||
|
|
||||||
self.token = self.get_token()
|
|
||||||
self.log.info(" + Obtained tokens")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_akamai():
|
|
||||||
enc_key = b"\x05\xfc\x1a\x01\xca\xc9\x4b\xc4\x12\xfc\x53\x12\x07\x75\xf9\xee"
|
|
||||||
st = int(time.time())
|
|
||||||
exp = st + 6000
|
|
||||||
res = f"st={st}~exp={exp}~acl=/*"
|
|
||||||
res += "~hmac=" + hmac.new(enc_key, res.encode(), hashlib.sha256).hexdigest()
|
|
||||||
return res
|
|
||||||
|
|
||||||
def get_token(self):
|
|
||||||
token_cache_path = self.get_cache("token_{profile}.json".format(profile=self.profile))
|
|
||||||
if os.path.isfile(token_cache_path):
|
|
||||||
with open(token_cache_path, encoding="utf-8") as fd:
|
|
||||||
token = json.load(fd)
|
|
||||||
if token.get("exp", 0) > int(time.time()):
|
|
||||||
# not expired, lets use
|
|
||||||
self.log.info(" + Using cached auth tokens...")
|
|
||||||
return token["uid"]
|
|
||||||
else:
|
|
||||||
# expired, refresh
|
|
||||||
self.log.info(" + Refreshing and using cached auth tokens...")
|
|
||||||
return self.save_token(self.refresh(token["uid"], token["sub"]["deviceId"]), token_cache_path)
|
|
||||||
# get new token
|
|
||||||
if self.cookies:
|
|
||||||
token = self.session.cookies.get("sessionUserUP", None, 'www.hotstar.com', '/' + self.region)
|
|
||||||
else:
|
|
||||||
raise self.log.exit(f" - Please add cookies")
|
|
||||||
# token = self.login()
|
|
||||||
return self.save_token(token, token_cache_path)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def save_token(token, to):
|
|
||||||
# Decode the JWT data component
|
|
||||||
data = json.loads(base64.b64decode(token.split(".")[1] + "===").decode("utf-8"))
|
|
||||||
data["uid"] = token
|
|
||||||
data["sub"] = json.loads(data["sub"])
|
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(to), exist_ok=True)
|
|
||||||
with open(to, "w", encoding="utf-8") as fd:
|
|
||||||
json.dump(data, fd)
|
|
||||||
|
|
||||||
return token
|
|
||||||
|
|
||||||
def refresh(self, user_id_token, device_id):
|
|
||||||
json_data = {
|
|
||||||
'deeplink_url': f'/{self.region}?client_capabilities=%7B%22ads%22%3A%5B%22non_ssai%22%5D%2C%22audio_channel%22%3A%5B%22stereo%22%5D%2C%22container%22%3A%5B%22fmp4%22%2C%22ts%22%5D%2C%22dvr%22%3A%5B%22short%22%5D%2C%22dynamic_range%22%3A%5B%22sdr%22%5D%2C%22encryption%22%3A%5B%22widevine%22%2C%22plain%22%5D%2C%22ladder%22%3A%5B%22web%22%2C%22tv%22%2C%22phone%22%5D%2C%22package%22%3A%5B%22dash%22%2C%22hls%22%5D%2C%22resolution%22%3A%5B%22sd%22%2C%22hd%22%5D%2C%22video_codec%22%3A%5B%22h264%22%5D%2C%22true_resolution%22%3A%5B%22sd%22%2C%22hd%22%2C%22fhd%22%5D%7D&drm_parameters=%7B%22hdcp_version%22%3A%5B%22HDCP_V2_2%22%5D%2C%22widevine_security_level%22%3A%5B%22SW_SECURE_DECODE%22%5D%2C%22playready_security_level%22%3A%5B%5D%7D',
|
|
||||||
'app_launch_count': 1,
|
|
||||||
}
|
|
||||||
r = self.session.post(
|
|
||||||
url=self.config["endpoints"]["refresh"],
|
|
||||||
headers={
|
|
||||||
'x-hs-usertoken': user_id_token,
|
|
||||||
'X-HS-Platform': self.config["device"]["platform"]["name"],
|
|
||||||
'X-Country-Code': self.region,
|
|
||||||
'X-HS-Accept-language': 'eng',
|
|
||||||
'X-Request-Id': str(uuid.uuid4()),
|
|
||||||
'x-hs-device-id': device_id,
|
|
||||||
'X-HS-Client-Targeting': f'ad_id:{device_id};user_lat:false',
|
|
||||||
'x-hs-request-id': str(uuid.uuid4()),
|
|
||||||
'X-HS-Client': 'platform:web;app_version:23.06.23.3;browser:Firefox;schema_version:0.0.911',
|
|
||||||
'Origin': 'https://www.hotstar.com',
|
|
||||||
'Referer': f'https://www.hotstar.com/{self.region}',
|
|
||||||
},
|
|
||||||
json=json_data
|
|
||||||
)
|
|
||||||
for cookie in self.cookies:
|
|
||||||
if cookie.name == 'sessionUserUP' and cookie.path == f"/{self.region}" and cookie.domain == 'www.hotstar.com':
|
|
||||||
cookie.value = r.headers["x-hs-usertoken"]
|
|
||||||
for x in self.ALIASES:
|
|
||||||
cookie_file = os.path.join(directories.cookies, x.lower(), f"{self.profile}.txt")
|
|
||||||
if not os.path.isfile(cookie_file):
|
|
||||||
cookie_file = os.path.join(directories.cookies, x, f"{self.profile}.txt")
|
|
||||||
if os.path.isfile(cookie_file):
|
|
||||||
self.cookies.save(cookie_file, ignore_discard=True, ignore_expires=True)
|
|
||||||
break
|
|
||||||
return r.headers["x-hs-usertoken"]
|
|
||||||
|
|
||||||
def login(self):
|
|
||||||
"""
|
|
||||||
Log in to HOTSTAR and return a JWT User Identity token.
|
|
||||||
:returns: JWT User Identity token.
|
|
||||||
"""
|
|
||||||
if self.credentials.username == "username" and self.credentials.password == "password":
|
|
||||||
logincode_url = f"https://api.hotstar.com/{self.region}/aadhar/v2/firetv/{self.region}/users/logincode/"
|
|
||||||
logincode_headers = {
|
|
||||||
"Content-Length": "0",
|
|
||||||
"User-Agent": "Hotstar;in.startv.hotstar/3.3.0 (Android/8.1.0)"
|
|
||||||
}
|
|
||||||
logincode = self.session.post(
|
|
||||||
url = logincode_url,
|
|
||||||
headers = logincode_headers
|
|
||||||
).json()["description"]["code"]
|
|
||||||
print(f"Go to tv.hotstar.com and put {logincode}")
|
|
||||||
logincode_choice = input('Did you put as informed above? (y/n): ')
|
|
||||||
if logincode_choice.lower() == 'y':
|
|
||||||
res = self.session.get(
|
|
||||||
url = logincode_url+logincode,
|
|
||||||
headers = logincode_headers
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.log.exit(" - Exited.")
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
res = self.session.post(
|
|
||||||
url=self.config["endpoints"]["login"],
|
|
||||||
json={
|
|
||||||
"isProfileRequired": "false",
|
|
||||||
"userData": {
|
|
||||||
"deviceId": self.device_id,
|
|
||||||
"password": self.credentials.password,
|
|
||||||
"username": self.credentials.username,
|
|
||||||
"usertype": "email"
|
|
||||||
},
|
|
||||||
"verification": {}
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"hotstarauth": self.hotstar_auth,
|
|
||||||
"content-type": "application/json"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
data = res.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.log.exit(f" - Failed to get auth token, response was not JSON: {res.text}")
|
|
||||||
raise
|
|
||||||
if "errorCode" in data:
|
|
||||||
self.log.exit(f" - Login failed: {data['description']} [{data['errorCode']}]")
|
|
||||||
raise
|
|
||||||
return data["description"]["userIdentity"]
|
|
||||||
308
vinetrimmer/services/hulu.py
Normal file
308
vinetrimmer/services/hulu.py
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import click
|
||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from langcodes import Language
|
||||||
|
|
||||||
|
from vinetrimmer.objects import TextTrack, Title, Tracks
|
||||||
|
from vinetrimmer.services.BaseService import BaseService
|
||||||
|
from vinetrimmer.utils.pyhulu import Device, HuluClient
|
||||||
|
from vinetrimmer.utils.widevine.device import LocalDevice
|
||||||
|
|
||||||
|
class Hulu(BaseService):
|
||||||
|
"""
|
||||||
|
Service code for the Hulu streaming service (https://hulu.com).
|
||||||
|
|
||||||
|
\b
|
||||||
|
Authorization: Cookies
|
||||||
|
Security: UHD@L3
|
||||||
|
"""
|
||||||
|
|
||||||
|
ALIASES = ["HULU"]
|
||||||
|
#GEOFENCE = ["us"]
|
||||||
|
TITLE_RE = (r"^(?:https?://(?:www\.)?hulu\.com/(?P<type>movie|series)/)?(?:[a-z0-9-]+-)?"
|
||||||
|
r"(?P<id>[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})")
|
||||||
|
|
||||||
|
AUDIO_CODEC_MAP = {
|
||||||
|
"AAC": "mp4a",
|
||||||
|
"EC3": "ec-3"
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="Hulu", short_help="https://hulu.com")
|
||||||
|
@click.argument("title", type=str, required=False)
|
||||||
|
@click.option("-m", "--movie", is_flag=True, default=False, help="Title is a movie.")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, **kwargs):
|
||||||
|
return Hulu(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx, title, movie):
|
||||||
|
super().__init__(ctx)
|
||||||
|
m = self.parse_title(ctx, title)
|
||||||
|
self.movie = movie or m.get("type") == "movie"
|
||||||
|
|
||||||
|
self.vcodec = ctx.parent.params["vcodec"]
|
||||||
|
self.acodec = ctx.parent.params["acodec"]
|
||||||
|
|
||||||
|
quality = ctx.parent.params.get("quality") or 0
|
||||||
|
if quality != "SD" and quality > 1080 and self.vcodec != "H265":
|
||||||
|
self.log.info("Switched video codec to H265 to be able to get 2160p video track")
|
||||||
|
self.vcodec = "H265"
|
||||||
|
|
||||||
|
if ctx.parent.params["range_"] == "HDR10":
|
||||||
|
self.log.info("Switched dynamic range to DV as Hulu only has HDR10+ compatible DV tracks")
|
||||||
|
ctx.parent.params["range_"] = "DV"
|
||||||
|
|
||||||
|
if ctx.parent.params["range_"] != "SDR" and self.vcodec != "H265":
|
||||||
|
self.log.info(f"Switched video codec to H265 to be able to get {ctx.parent.params['range_']} dynamic range")
|
||||||
|
self.vcodec = "H265"
|
||||||
|
|
||||||
|
self.device = None
|
||||||
|
self.playready = True if "group_certificate" in dir(ctx.obj.cdm.device) else False # ctx.obj.cdm.device.type == LocalDevice.Types.PLAYREADY
|
||||||
|
self.playback_params = {}
|
||||||
|
self.hulu_client = None
|
||||||
|
self.license_url = None
|
||||||
|
|
||||||
|
self.configure()
|
||||||
|
|
||||||
|
def get_titles(self):
|
||||||
|
titles = []
|
||||||
|
|
||||||
|
if self.movie:
|
||||||
|
res = self.session.get(self.config["endpoints"]["movie"].format(id=self.title)).json()
|
||||||
|
title_data = res["details"]["vod_items"]["focus"]["entity"]
|
||||||
|
titles.append(Title(
|
||||||
|
id_=self.title,
|
||||||
|
type_=Title.Types.MOVIE,
|
||||||
|
name=title_data["name"],
|
||||||
|
year=int(title_data["premiere_date"][:4]),
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
service_data=title_data
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
res = self.session.get(self.config["endpoints"]["series"].format(id=self.title)).json()
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
res = e.response.json()
|
||||||
|
raise self.log.exit(f" - Failed to get titles for {self.title}: {res['message']} [{res['code']}]")
|
||||||
|
|
||||||
|
season_data = next((x for x in res["components"] if x["name"] == "Episodes"), None)
|
||||||
|
if not season_data:
|
||||||
|
raise self.log.exit(" - Unable to get episodes. Maybe you need a proxy?")
|
||||||
|
|
||||||
|
for season in season_data["items"]:
|
||||||
|
episodes = self.session.get(
|
||||||
|
self.config["endpoints"]["season"].format(
|
||||||
|
id=self.title,
|
||||||
|
season=season["id"].rsplit("::", 1)[1]
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
for episode in episodes["items"]:
|
||||||
|
titles.append(Title(
|
||||||
|
id_=f"{season['id']}::{episode['season']}::{episode['number']}",
|
||||||
|
type_=Title.Types.TV,
|
||||||
|
name=episode["series_name"],
|
||||||
|
season=int(episode["season"]),
|
||||||
|
episode=int(episode["number"]),
|
||||||
|
episode_name=episode["name"],
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
service_data=episode
|
||||||
|
))
|
||||||
|
|
||||||
|
playlist = self.hulu_client.load_playlist(titles[0].service_data["bundle"]["eab_id"])
|
||||||
|
for title in titles:
|
||||||
|
title.original_lang = Language.get(playlist["video_metadata"]["language"])
|
||||||
|
|
||||||
|
return titles
|
||||||
|
|
||||||
|
def remove_parts_mpd(self, mpd):
|
||||||
|
pattern = r'<Representation[^>]*id="(?![^"]*ALT_1)[^"]*CENC_CTR_[^"]*"[^>]*width="1920"[^>]*height="1080"[^>]*>.*?</Representation>\s*'
|
||||||
|
m = re.sub(pattern, "", mpd, flags=re.DOTALL)
|
||||||
|
return m
|
||||||
|
|
||||||
|
def get_pssh(self, kid) -> str:
|
||||||
|
array_of_bytes = bytearray(b'\x00\x00\x002pssh\x00\x00\x00\x00')
|
||||||
|
array_of_bytes.extend(bytes.fromhex("edef8ba979d64acea3c827dcd51d21ed"))
|
||||||
|
array_of_bytes.extend(b'\x00\x00\x00\x12\x12\x10')
|
||||||
|
array_of_bytes.extend(bytes.fromhex(str(kid).replace("-", "")))
|
||||||
|
pssh: str = base64.b64encode(bytes.fromhex(array_of_bytes.hex())).decode("utf-8")
|
||||||
|
return pssh
|
||||||
|
|
||||||
|
def get_pssh_mpd(self, xml_mpd):
|
||||||
|
root = ET.fromstring(xml_mpd)
|
||||||
|
pssh = None
|
||||||
|
namespaces = {
|
||||||
|
'': 'urn:mpeg:dash:schema:mpd:2011',
|
||||||
|
'cenc': 'urn:mpeg:cenc:2013'
|
||||||
|
}
|
||||||
|
content_protection = root.find(".//{urn:mpeg:dash:schema:mpd:2011}AdaptationSet//{urn:mpeg:dash:schema:mpd:2011}ContentProtection[@schemeIdUri='urn:mpeg:dash:mp4protection:2011'][@value='cenc']", namespaces)
|
||||||
|
if content_protection is not None:
|
||||||
|
default_kid = content_protection.get('{urn:mpeg:cenc:2013}default_KID')
|
||||||
|
kid = default_kid.replace('-', '')
|
||||||
|
pssh = self.get_pssh(kid)
|
||||||
|
|
||||||
|
return pssh
|
||||||
|
|
||||||
|
def get_tracks(self, title):
|
||||||
|
try:
|
||||||
|
playlist = self.hulu_client.load_playlist(title.service_data["bundle"]["eab_id"])
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
res = e.response.json()
|
||||||
|
raise self.log.exit(f" - {res['message']} ({res['code']})")
|
||||||
|
|
||||||
|
self.license_url = playlist["dash_pr_server"] if self.playready else playlist["wv_server"]
|
||||||
|
|
||||||
|
manifest = playlist["stream_url"]
|
||||||
|
|
||||||
|
if 'disney' in manifest:
|
||||||
|
mpd = self.session.get(manifest).text
|
||||||
|
mpd_data = self.remove_parts_mpd(mpd)
|
||||||
|
pssh = self.get_pssh_mpd(mpd_data)
|
||||||
|
|
||||||
|
tracks = Tracks.from_mpd(
|
||||||
|
url=manifest,
|
||||||
|
session=self.session,
|
||||||
|
source=self.ALIASES[0]
|
||||||
|
)
|
||||||
|
#for track in tracks:
|
||||||
|
# print("pssh:",track.pssh)
|
||||||
|
|
||||||
|
if not self.playready:
|
||||||
|
tracks0 = []
|
||||||
|
for track in tracks.videos:
|
||||||
|
track.psshWV = [pssh]
|
||||||
|
if int(track.width) >= int(1920):
|
||||||
|
rep = track.extra[0]
|
||||||
|
id = rep.get("id")
|
||||||
|
if 'ALT_1' in id:
|
||||||
|
tracks0.append(track)
|
||||||
|
else:
|
||||||
|
tracks0.append(track)
|
||||||
|
tracks.videos = tracks0
|
||||||
|
else:
|
||||||
|
tracks = Tracks.from_mpd(
|
||||||
|
url=manifest,
|
||||||
|
session=self.session,
|
||||||
|
source=self.ALIASES[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
for track in tracks.videos:
|
||||||
|
if track.hdr10:
|
||||||
|
# MPD only says HDR10+, but Hulu HDR streams are always Dolby Vision Profile 8 with HDR10+ compatibility
|
||||||
|
track.hdr10 = False
|
||||||
|
track.dv = True
|
||||||
|
|
||||||
|
for track in tracks.audios:
|
||||||
|
if not track.psshPR:
|
||||||
|
track.psshPR = next(x.psshPR for x in tracks.videos if x.psshPR)
|
||||||
|
if not track.psshWV:
|
||||||
|
track.psshWV = next(x.psshWV for x in tracks.videos if x.psshWV)
|
||||||
|
|
||||||
|
if self.acodec:
|
||||||
|
tracks.audios = [x for x in tracks.audios if (x.codec or "")[:4] == self.AUDIO_CODEC_MAP[self.acodec]]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for sub_lang, sub_url in playlist["transcripts_urls"]["webvtt"].items():
|
||||||
|
tracks.add(TextTrack(
|
||||||
|
id_=hashlib.md5(sub_url.encode()).hexdigest()[0:6],
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
url=sub_url,
|
||||||
|
# metadata
|
||||||
|
codec="vtt",
|
||||||
|
language=sub_lang,
|
||||||
|
forced=False, # TODO: find out if sub is forced
|
||||||
|
sdh=False # TODO: find out if sub is SDH/CC, it's actually quite likely to be true
|
||||||
|
))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def get_chapters(self, title):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def certificate(self, **_):
|
||||||
|
return None # will use common privacy cert
|
||||||
|
|
||||||
|
def license(self, challenge, track, **_):
|
||||||
|
res = self.session.post(
|
||||||
|
url=self.license_url,
|
||||||
|
data=challenge # expects bytes
|
||||||
|
)
|
||||||
|
self.log.debug(res.text) if self.playready else self.log.debug(res.content)
|
||||||
|
return base64.b64encode(res.text.encode()).decode() if self.playready else res.content
|
||||||
|
|
||||||
|
# Service specific functions
|
||||||
|
|
||||||
|
def configure(self):
|
||||||
|
self.device = Device(
|
||||||
|
device_code=self.config["device"]["FireTV4K"]["code"],
|
||||||
|
device_key=self.config["device"]["FireTV4K"]["key"]
|
||||||
|
)
|
||||||
|
self.session.headers.update({
|
||||||
|
"User-Agent": self.config["user_agent"],
|
||||||
|
})
|
||||||
|
for schemas in self.config["drm"]["schemas"]:
|
||||||
|
if schemas["type"] == "WIDEVINE":
|
||||||
|
schemas_widevine = [schemas]
|
||||||
|
elif schemas["type"] == "PLAYREADY":
|
||||||
|
schemas_playready = [schemas]
|
||||||
|
self.playback_params = {
|
||||||
|
"all_cdn": False,
|
||||||
|
"region": "US",
|
||||||
|
"language": "en",
|
||||||
|
"interface_version": "1.9.0",
|
||||||
|
"network_mode": "wifi",
|
||||||
|
"play_intent": "resume",
|
||||||
|
"playback": {
|
||||||
|
"version": 2,
|
||||||
|
"video": {
|
||||||
|
"dynamic_range": "DOLBY_VISION",
|
||||||
|
"codecs": {
|
||||||
|
"values": [x for x in self.config["codecs"]["video"] if x["type"] == self.vcodec],
|
||||||
|
"selection_mode": self.config["codecs"]["video_selection"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"codecs": {
|
||||||
|
"values": self.config["codecs"]["audio"],
|
||||||
|
"selection_mode": self.config["codecs"]["audio_selection"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"drm": {
|
||||||
|
"multi_key": True,
|
||||||
|
"values": schemas_playready if self.playready else schemas_widevine,
|
||||||
|
"selection_mode": self.config["drm"]["selection_mode"],
|
||||||
|
"hdcp": self.config["drm"]["hdcp"]
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"type": "DASH",
|
||||||
|
"https": True,
|
||||||
|
"multiple_cdns": False,
|
||||||
|
"patch_updates": True,
|
||||||
|
"hulu_types": True,
|
||||||
|
"live_dai": True,
|
||||||
|
"secondary_audio": True,
|
||||||
|
"live_fragment_delay": 3
|
||||||
|
},
|
||||||
|
"segments": {
|
||||||
|
"values": [{
|
||||||
|
"type": "FMP4",
|
||||||
|
"encryption": {
|
||||||
|
"mode": "CENC",
|
||||||
|
"type": "CENC"
|
||||||
|
},
|
||||||
|
"https": True
|
||||||
|
}],
|
||||||
|
"selection_mode": "ONE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.hulu_client = HuluClient(
|
||||||
|
device=self.device,
|
||||||
|
session=self.session,
|
||||||
|
version=self.config["device"].get("device_version"),
|
||||||
|
**self.playback_params
|
||||||
|
)
|
||||||
436
vinetrimmer/services/paramountplus.py
Normal file
436
vinetrimmer/services/paramountplus.py
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
import m3u8
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import jsonpickle
|
||||||
|
|
||||||
|
from vinetrimmer.objects import Title, Tracks
|
||||||
|
from vinetrimmer.objects.tracks import AudioTrack, MenuTrack, TextTrack, VideoTrack
|
||||||
|
from vinetrimmer.services.BaseService import BaseService
|
||||||
|
from vinetrimmer.utils.widevine.device import LocalDevice
|
||||||
|
|
||||||
|
class ParamountPlus(BaseService):
|
||||||
|
"""
|
||||||
|
Service code for Paramount's Paramount+ streaming service (https://paramountplus.com).
|
||||||
|
|
||||||
|
\b
|
||||||
|
Authorization: Credentials
|
||||||
|
Security: UHD@L3, doesn't care about releases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ALIASES = ["PMTP", "paramountplus", "paramount+"]
|
||||||
|
TITLE_RE = [
|
||||||
|
r"^https?://(?:www\.)?paramountplus\.com/(?P<type>movies)/[a-z0-9_-]+/(?P<id>\w+)",
|
||||||
|
r"^https?://(?:www\.)?paramountplus\.com/(?P<type>shows)/(?P<id>[a-zA-Z0-9_-]+)(/)?",
|
||||||
|
r"^https?://(?:www\.)?paramountplus\.com(?:/[a-z]{2})?/(?P<type>movies)/[a-z0-9_-]+/(?P<id>\w+)",
|
||||||
|
r"^https?://(?:www\.)?paramountplus\.com(?:/[a-z]{2})?/(?P<type>shows)/(?P<id>[a-zA-Z0-9_-]+)(/)?",
|
||||||
|
r"^(?P<id>\d+)$",
|
||||||
|
]
|
||||||
|
VIDEO_CODEC_MAP = {"H264": ["avc", "avc1"], "H265": ["hvc", "dvh", "hvc1", "hev1", "dvh1", "dvhe"]}
|
||||||
|
AUDIO_CODEC_MAP = {"AAC": "mp4a", "AC3": "ac-3", "EC3": "ec-3"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="ParamountPlus", short_help="https://paramountplus.com")
|
||||||
|
@click.argument("title", type=str, required=False)
|
||||||
|
@click.option("-m", "--movie", is_flag=True, default=False, help="Title is a Movie.")
|
||||||
|
@click.option(
|
||||||
|
"-c", "--clips", is_flag=True, default=False, help="Download clips instead of episodes (for TV shows)"
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx: click.Context, **kwargs):
|
||||||
|
return ParamountPlus(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx: click.Context, title: str, movie: bool, clips: bool):
|
||||||
|
super().__init__(ctx)
|
||||||
|
m = self.parse_title(ctx, title)
|
||||||
|
self.movie = movie or m.get("type") == "movies"
|
||||||
|
self.clips = clips
|
||||||
|
|
||||||
|
self.vcodec = ctx.parent.params["vcodec"]
|
||||||
|
self.acodec = ctx.parent.params["acodec"]
|
||||||
|
self.range = ctx.parent.params["range_"]
|
||||||
|
self.wanted = ctx.parent.params["wanted"]
|
||||||
|
self.shorts = False
|
||||||
|
|
||||||
|
self.profile = ctx.obj.profile
|
||||||
|
self.playready = ctx.obj.cdm.device.type == LocalDevice.Types.PLAYREADY
|
||||||
|
|
||||||
|
ctx.parent.params["acodec"] = "EC3"
|
||||||
|
|
||||||
|
if self.range != "SDR":
|
||||||
|
# vcodec must be H265 for High Dynamic Range
|
||||||
|
self.vcodec = "H265"
|
||||||
|
|
||||||
|
self.configure()
|
||||||
|
|
||||||
|
def get_titles(self):
|
||||||
|
if self.movie:
|
||||||
|
res = self.session.get(
|
||||||
|
url=self.config[self.region]["movie"].format(title_id=self.title),
|
||||||
|
params={
|
||||||
|
"includeTrailerInfo": "true",
|
||||||
|
"includeContentInfo": "true",
|
||||||
|
"locale": "en-us",
|
||||||
|
"at": self.config[self.region]["at_token"],
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
if not res["success"]:
|
||||||
|
if res["message"] == "No movie found for contentId.":
|
||||||
|
raise self.log.exit(" - Unable to find movie. For TV shows, use the numeric ID.")
|
||||||
|
else:
|
||||||
|
raise self.log.exit(f" - Failed to get title information: {res['message']}")
|
||||||
|
|
||||||
|
title = res["movie"]["movieContent"]
|
||||||
|
|
||||||
|
return Title(
|
||||||
|
id_=title["contentId"],
|
||||||
|
type_=Title.Types.MOVIE,
|
||||||
|
name=title["title"],
|
||||||
|
year=title["_airDateISO"][:4], # todo: find a way to get year, this api doesnt return it
|
||||||
|
original_lang="en", # TODO: Don't assume
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
service_data=title,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
res = self.session.get(
|
||||||
|
url=self.config[self.region]["shows"].format(title=self.title)
|
||||||
|
).json()
|
||||||
|
links = next((x.get("links") for x in res["showMenu"] if x.get("device_app_id") == "all_platforms"), None)
|
||||||
|
config = next((x.get("videoConfigUniqueName") for x in links if x.get("title").strip() == "Episodes"), None)
|
||||||
|
show = next((x for x in res["show"]["results"] if x.get("type") == "show"), None)
|
||||||
|
seasons = [x["seasonNum"] for x in res["available_video_seasons"]["itemList"] if x.get("seasonNum")]
|
||||||
|
showId = show.get("show_id")
|
||||||
|
|
||||||
|
show_data = self.session.get(
|
||||||
|
url=self.config[self.region]["section"].format(showId=showId, config=config),
|
||||||
|
params={"platformType": "apps", "rows": "1", "begin": "0"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
section = next(
|
||||||
|
(x["sectionId"] for x in show_data["videoSectionMetadata"] if x["title"] == "Full Episodes"), None
|
||||||
|
)
|
||||||
|
|
||||||
|
episodes = []
|
||||||
|
for season in seasons:
|
||||||
|
res = self.session.get(
|
||||||
|
url=self.config[self.region]["seasons"].format(section=section),
|
||||||
|
params={"begin": "0", "rows": "999", "params": f"seasonNum={season}", "seasonNum": season},
|
||||||
|
).json()
|
||||||
|
episodes.extend(res["sectionItems"].get("itemList"))
|
||||||
|
|
||||||
|
titles = []
|
||||||
|
for episode in episodes:
|
||||||
|
titles.append(
|
||||||
|
Title(
|
||||||
|
id_=episode.get("contentId") or episode.get("content_id"),
|
||||||
|
type_=Title.Types.TV,
|
||||||
|
name=episode.get("seriesTitle") or episode.get("series_title"),
|
||||||
|
season=episode.get("seasonNum") or episode.get("season_number") or 0,
|
||||||
|
episode=episode["episodeNum"] if episode["fullEpisode"] else episode["positionNum"],
|
||||||
|
episode_name=episode["label"],
|
||||||
|
original_lang="en", # TODO: Don't assume
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
service_data=episode,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return titles
|
||||||
|
|
||||||
|
def get_tracks(self, title: Title):
|
||||||
|
assets = (
|
||||||
|
["DASH_CENC_HDR10"],
|
||||||
|
[
|
||||||
|
"HLS_AES",
|
||||||
|
"DASH_LIVE",
|
||||||
|
"DASH_CENC_HDR10",
|
||||||
|
"DASH_TA",
|
||||||
|
"DASH_CENC",
|
||||||
|
"DASH_CENC_PRECON",
|
||||||
|
"DASH_CENC_PS4",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for asset in assets:
|
||||||
|
r = requests.Request(
|
||||||
|
"GET",
|
||||||
|
url=self.config["LINK_PLATFORM_URL"].format(video_id=title.id),
|
||||||
|
params={
|
||||||
|
"format": "redirect",
|
||||||
|
"formats": "MPEG-DASH",
|
||||||
|
"assetTypes": "|".join(asset),
|
||||||
|
"manifest": "M3U",
|
||||||
|
"Tracking": "true",
|
||||||
|
"mbr": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req = self.session.send(self.session.prepare_request(r), allow_redirects=False)
|
||||||
|
if req.ok:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Manifest Error: {req.text}")
|
||||||
|
|
||||||
|
mpd_url = req.headers.get('location')
|
||||||
|
|
||||||
|
try:
|
||||||
|
tracks: Tracks = Tracks.from_mpd(
|
||||||
|
url=mpd_url.replace("cenc_precon_dash", "cenc_dash"),
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
session=self.session,
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
tracks: Tracks = Tracks.from_mpd(
|
||||||
|
url=mpd_url,
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
session=self.session,
|
||||||
|
)
|
||||||
|
tracks.subtitles.clear()
|
||||||
|
|
||||||
|
req = self.session.get(
|
||||||
|
url=self.config["LINK_PLATFORM_URL"].format(video_id=title.id),
|
||||||
|
params={
|
||||||
|
"format": "redirect",
|
||||||
|
"formats": "M3U",
|
||||||
|
"assetTypes": "|".join(["HLS_FPS_PRECON"]),
|
||||||
|
"manifest": "M3U",
|
||||||
|
"Tracking": "true",
|
||||||
|
"mbr": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hls_url = req.url
|
||||||
|
|
||||||
|
tracks_m3u8 = Tracks.from_m3u8(
|
||||||
|
m3u8.load(hls_url),
|
||||||
|
source=self.ALIASES[0],
|
||||||
|
)
|
||||||
|
tracks.subtitles = tracks_m3u8.subtitles
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
# track.id = track.id
|
||||||
|
if isinstance(track, VideoTrack):
|
||||||
|
track.hdr10 = (
|
||||||
|
track.codec[:4] in ("hvc1", "hev1") and track.extra[0].attrib.get("codecs")[5] == "2"
|
||||||
|
) or (track.codec[:4] in ("hvc1", "hev1") and "HDR10plus" in track.url)
|
||||||
|
|
||||||
|
track.dv = track.codec[:4] in ("dvh1", "dvhe")
|
||||||
|
|
||||||
|
if isinstance(track, VideoTrack) or isinstance(track, AudioTrack):
|
||||||
|
if self.shorts:
|
||||||
|
track.encrypted = False
|
||||||
|
|
||||||
|
if isinstance(track, TextTrack):
|
||||||
|
track.codec = "vtt"
|
||||||
|
#if track.language.language == "en":
|
||||||
|
# track.sdh = True # TODO: don't assume SDH
|
||||||
|
|
||||||
|
if self.vcodec:
|
||||||
|
tracks.videos = [x for x in tracks.videos if (x.codec or "")[:4] in self.VIDEO_CODEC_MAP[self.vcodec]]
|
||||||
|
|
||||||
|
if self.acodec:
|
||||||
|
tracks.audios = [x for x in tracks.audios if (x.codec or "")[:4] == self.AUDIO_CODEC_MAP[self.acodec]]
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def get_chapters(self, title: Title):
|
||||||
|
chapters = []
|
||||||
|
events = title.service_data.get("playbackEvents")
|
||||||
|
events = {k: v for k, v in events.items() if v is not None}
|
||||||
|
events = dict(sorted(events.items(), key=lambda item: item[1]))
|
||||||
|
if not events:
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
chapters_titles = {
|
||||||
|
"endCreditChapterTimeMs": "Credits",
|
||||||
|
"previewStartTimeMs": "Preview Start",
|
||||||
|
"previewEndTimeMs": "Preview End",
|
||||||
|
"openCreditEndTimeMs": "openCreditEnd",
|
||||||
|
"openCreditStartTime": "openCreditStart",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, time_ in events.items():
|
||||||
|
if isinstance(time_, (int, float)):
|
||||||
|
chapters.append(
|
||||||
|
MenuTrack(
|
||||||
|
number=len(chapters) + 1,
|
||||||
|
title=chapters_titles.get(name),
|
||||||
|
timecode=MenuTrack.format_duration(time_ / 1000),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# chapters = sorted(chapters, key=self.converter_timecode)
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
def certificate(self, **_):
|
||||||
|
return None # will use common privacy cert
|
||||||
|
|
||||||
|
def license(self, challenge, title, **_):
|
||||||
|
contentId = title.service_data.get("contentId") or title.service_data.get("content_id")
|
||||||
|
if not contentId:
|
||||||
|
raise self.log.exit("Error")
|
||||||
|
|
||||||
|
r = self.session.post(
|
||||||
|
url=self.config["license_pr"] if self.playready else self.config["license"],
|
||||||
|
params={
|
||||||
|
"CrmId": "cbsi",
|
||||||
|
"AccountId": "cbsi",
|
||||||
|
"SubContentType": "Default",
|
||||||
|
"ContentId": title.service_data.get("contentId") or title.service_data.get("content_id"),
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {self.get_barrear(content_id=contentId)}"},
|
||||||
|
data=challenge, # expects bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.headers["Content-Type"].startswith("application/json"):
|
||||||
|
res = r.json()
|
||||||
|
raise self.log.exit(res["message"])
|
||||||
|
|
||||||
|
return base64.b64encode(r.content).decode() if self.playready else r.content
|
||||||
|
|
||||||
|
def configure(self):
|
||||||
|
self.region = self.session.get("https://ipinfo.io/json").json()["country"]
|
||||||
|
if self.region != "US":
|
||||||
|
if self.region != "FR":
|
||||||
|
self.region = "INTL"
|
||||||
|
|
||||||
|
#self.device_cache_path = Path(self.get_cache("device_tokens_{profile}.json".format(
|
||||||
|
#profile=self.profile,
|
||||||
|
#)))
|
||||||
|
|
||||||
|
#if self.device_cache_path.exists():
|
||||||
|
#with open(self.device_cache_path, encoding="utf-8") as fd:
|
||||||
|
#date = jsonpickle.decode(fd.read())
|
||||||
|
#if "expiry" in date and datetime.fromisoformat(date["expiry"]) > datetime.now():
|
||||||
|
#self.log.warning(" + Using cached device tokens")
|
||||||
|
#cache = date
|
||||||
|
#else:
|
||||||
|
#self.log.warning(" + Refreshing cookies")
|
||||||
|
#self.device_cache_path.unlink()
|
||||||
|
#if not self.credentials:
|
||||||
|
#raise self.log.exit(" - No credentials provided, unable to log in.")
|
||||||
|
#self.session.headers.update({"user-agent": self.config["Android"]["UserAgent"]})
|
||||||
|
#self.session.params.update({"at": self.config[self.region]["at_token"]})
|
||||||
|
#username = self.credentials.username
|
||||||
|
#password = self.credentials.password
|
||||||
|
#expiry = (datetime.now() + timedelta(minutes=3)).isoformat()
|
||||||
|
#cookie = self.login(username=username, password=password)
|
||||||
|
#cache = {"cookie": cookie, "expiry": expiry}
|
||||||
|
#self.device_cache_path.parent.mkdir(exist_ok=True, parents=True)
|
||||||
|
#with open(self.device_cache_path, "w", encoding="utf-8") as fd:
|
||||||
|
#fd.write(jsonpickle.encode(cache))
|
||||||
|
#else:
|
||||||
|
if not self.credentials:
|
||||||
|
raise self.log.exit(" - No credentials provided, unable to log in.")
|
||||||
|
self.log.warning(" + Logging in")
|
||||||
|
self.session.headers.update({"user-agent": self.config["Android"]["UserAgent"]})
|
||||||
|
self.session.params.update({"at": self.config[self.region]["at_token"]})
|
||||||
|
username = self.credentials.username
|
||||||
|
password = self.credentials.password
|
||||||
|
#expiry = (datetime.now() + timedelta(minutes=3)).isoformat()
|
||||||
|
cookie = self.login(username=username, password=password)
|
||||||
|
#cache = {"cookie": cookie, "expiry": expiry}
|
||||||
|
#self.device_cache_path.parent.mkdir(exist_ok=True, parents=True)
|
||||||
|
#with open(self.device_cache_path, "w", encoding="utf-8") as fd:
|
||||||
|
#fd.write(jsonpickle.encode(cache))
|
||||||
|
#cookie = cache["cookie"]
|
||||||
|
self.session.headers.update({"cookie": cookie})
|
||||||
|
else:
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"Origin": "https://www.paramountplus.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.session.params.update({"at": self.config[self.region]["at_token"]})
|
||||||
|
|
||||||
|
#if not self.is_logged_in():
|
||||||
|
#raise ValueError("InvalidCookies")
|
||||||
|
|
||||||
|
#if not self.is_subscribed():
|
||||||
|
#raise ValueError("NotEntitled")
|
||||||
|
|
||||||
|
# Service specific functions
|
||||||
|
|
||||||
|
def get_prop(self, prop):
|
||||||
|
res = self.session.get("https://www.paramountplus.com")
|
||||||
|
prop_re = prop.replace(".", r"\.")
|
||||||
|
search = re.search(rf"{prop_re} ?= ?[\"']?([^\"';]+)", res.text)
|
||||||
|
if not search:
|
||||||
|
raise ValueError("InvalidCookies")
|
||||||
|
|
||||||
|
return search.group(1)
|
||||||
|
|
||||||
|
def is_logged_in(self):
|
||||||
|
return self.get_prop("CBS.UserAuthStatus") == "true"
|
||||||
|
|
||||||
|
def is_subscribed(self):
|
||||||
|
return self.get_prop("CBS.Registry.user.sub_status") == "SUBSCRIBER"
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
login_params = {
|
||||||
|
"j_username": username,
|
||||||
|
"j_password": password
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.session.post(url=self.config[self.region]["login"], params=login_params)
|
||||||
|
|
||||||
|
status_response = self.session.get(url=self.config[self.region]["status"]).json()
|
||||||
|
self.log.debug(status_response)
|
||||||
|
if status_response["success"] == False:
|
||||||
|
raise self.log.exit("InvalidCredentials")
|
||||||
|
#if not status_response["userStatus"]["description"] == "SUBSCRIBER":
|
||||||
|
#raise ValueError("NotEntitled")
|
||||||
|
|
||||||
|
cookies = ";".join([f"{key}={value}" for key, value in response.cookies.get_dict().items()])
|
||||||
|
|
||||||
|
return cookies
|
||||||
|
|
||||||
|
def get_barrear(self, content_id):
|
||||||
|
#license_data = self.session.get(url="https://www.intl.paramountplus.com/apps-api/v3.0/androidphone/irdeto-control/session-token.json?contentId=%s&locale=en-us&at=ABATOpD5wXyjhjIMO0BaNh/gW0iCu0ISRy2U7/tyGiKZTQTlYDFL1NPD58CcuJLOQYY=" % (content_id)).json()
|
||||||
|
try:
|
||||||
|
res = self.session.get(
|
||||||
|
url=self.config[self.region]["barrearUrl"].replace("iphone", "androidtv") if self.playready else self.config[self.region]["barrearUrl"],
|
||||||
|
params={"contentId": content_id}
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
if e.response.status_code == 401:
|
||||||
|
self.log.warning("Received a 401 error, deleting cached cookies")
|
||||||
|
self.device_cache_path.unlink()
|
||||||
|
self.session.headers.clear()
|
||||||
|
self.session.params = {}
|
||||||
|
self.configure()
|
||||||
|
self.retrying = True
|
||||||
|
|
||||||
|
res = res.json()
|
||||||
|
|
||||||
|
if not res["success"]:
|
||||||
|
raise self.log.exit("Unable to get license token: %s" % (res["errors"]))
|
||||||
|
|
||||||
|
self.license_url = res["url"]
|
||||||
|
ls_session = res["ls_session"]
|
||||||
|
|
||||||
|
return ls_session
|
||||||
|
|
||||||
|
def parse_movie_year(self, url):
|
||||||
|
html_raw = self.session.get(url)
|
||||||
|
|
||||||
|
if html_raw.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.year = int(
|
||||||
|
re.findall('"movie__air-year">[0-9]+<', html_raw.text)[0].replace('"movie__air-year">', "").replace("<", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_show_id(self, url):
|
||||||
|
html_raw = self.session.get(url)
|
||||||
|
|
||||||
|
if html_raw.status_code != 200:
|
||||||
|
self.log.exit("Could not parse Show Id.")
|
||||||
|
|
||||||
|
show = json.loads('{"' + re.search('CBS.Registry.Show = {"(.*)"}', html_raw.text).group(1) + '"}')
|
||||||
|
|
||||||
|
return str(show["id"])
|
||||||
Loading…
Reference in New Issue
Block a user