This repository has been archived on 2024-07-02. You can view files and clone it, but cannot push or open issues or pull requests.
Sony-Bravia-Core-Script/braviacore.py
widevinedump 3ef6dbd5e1 New leak
2021-12-21 22:48:40 +05:30

652 lines
25 KiB
Python

# -*- coding: utf-8 -*-
# Module: BRAVIA-CORE
# Created on: 01-12-2021
# Authors: -∞WKS∞-
# Version: 1.0
from __future__ import annotations
import base64
import hashlib
import json
import sys
from enum import Enum
from typing import Any, Optional, Union
import click
import jsonpickle
import requests
import braviacoreConfig
from click import Context
class BraviaCORE(BaseService):
"""
Service code for Sony's Bravia CORE streaming service (https://electronics.sony.com/bravia-core).
\b
Authorization: Credentials
Security: UHD@L3 HD@L3
\b
Tip: It's currently using unintentionally open internal API endpoints, use while you can!
"""
ALIASES = ["CORE", "braviacore"]
@staticmethod
@click.command(name="BraviaCORE", short_help="https://electronics.sony.com/bravia-core")
@click.argument("title", type=str)
@click.option("-x", "--internal", is_flag=True, default=False,
help="Use the weird unintentionally open API endpoint with unrestricted title access.")
@click.option("-vp", "--vprofile", default=None,
type=click.Choice(["h264", "sdr", "hdr", "imax"], case_sensitive=False),
help="Video Profile. Default will be highest quality/best compression.")
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> BraviaCORE:
return BraviaCORE(ctx, **kwargs)
def __init__(self, ctx: Context, title: str, internal: bool, vprofile: Optional[str]):
self.title = int(title) if title != "list" else title
self.internal = internal
self.vprofile = vprofile
super().__init__(ctx)
self.session_id: str
self.credits: int
self.configure()
def get_titles(self) -> Union[Title, list[Title]]:
if self.title == "list":
if self.internal:
pages = self.get_cache("manifest_internal")
if not pages.is_dir() or len(list(pages.iterdir())) == 0:
self.log.exit(" - Endpoint was patched. Can only search cached pages, which you have none of.")
raise
samples = [
sample
for page in pages.iterdir()
for sample in jsonpickle.decode(page.read_text("utf8"))
]
samples = sorted(samples, key=lambda s: int(s["ppId"]))
for sample in samples:
self.log.info(
"{} | {} [{}] [{}]".format(
sample["ppId"],
sample["parent_product_name"],
sample["alpha"],
sample["quality"]
)
)
else:
self.list_playlist(self.config["playlists"]["unlimited"], "Unlimited Streaming")
self.list_playlist(self.config["playlists"]["library"], "Library")
sys.exit(0)
res = self.session.get(
url=f"https://service.privilegemovies.com/content/v6/metadata/{self.title}",
params={"width": "300"}
)
title = res.json()
if title["responseCode"] >= 19999:
raise ValueError(
f"Could not get metadata for {self.title}. "
f"Error: {repr(ResponseCode(title['responseCode']))}. "
f"URL: {res.request.url}"
)
title["id"] = self.title
search = next(filter(lambda x: x["parentProductId"] == title["id"], self.search(title["title"])), None)
if not search:
self.log.exit(f"Could not get search result for {self.title}.")
raise
title["transactionTypes"] = sorted(search["transactionTypes"])
return Title(
id_=title["id"],
type_=Title.Types.MOVIE,
name=title["title"],
year=title["year"],
original_lang=title["language"],
source=self.ALIASES[0],
service_data=title
)
def get_tracks(self, title: Title) -> Tracks:
profiles = sorted(
[x.lower() for x in title.service_data['availableProfiles']],
key=["imax", "hdr", "sdr", "h264"].index
)
profile = None
if self.vprofile and self.vprofile in profiles:
profile = self.vprofile.lower()
elif profiles:
profile = profiles[0]
self.log.debug(f"Available Profiles: {profiles}")
if self.internal:
res = self.search_manifest_internal(pp_id=title.service_data["id"])
# disabled for now until true full format is discovered, specifically "s" (signature).
# res["uri"] = self.prepare_manifest_url(res["uri"], res["movieId"])
else:
res = self.get_video(
title.service_data["id"],
transaction_type=title.service_data["transactionTypes"][-1],
profile=profile
)
tracks = Tracks.from_mpds(
data=requests.get(res["uri"]).text,
url=res["uri"].replace("service.privilegemovies.com/mg/drm", "cf.privilegemovies.com/drm"),
lang=title.original_lang,
source=self.ALIASES[0]
)
for sub in res.get("subtitles") or []:
if sub["extension"] == "vtt":
continue # SRT should be available for the exact same sub
if sub["languageCode"].lower() == "pp":
sub["languageCode"] = "pt-BR"
if sub["languageCode"].lower() == "cn":
sub["languageCode"] = "zh-Hant"
if sub["languageCode"].lower() == "zh":
sub["languageCode"] = "zh-Hans"
if self.session.head(sub["subtitleUrl"]).status_code == 404:
self.log.warning(f" - Subtitle returned 404, skipping: {sub['subtitleUrl']}")
continue
tracks.add(TextTrack(
id_="{}_{}_{}_sub".format(
self.title,
sub["languageCode"],
hashlib.md5(sub["subtitleUrl"].encode()).hexdigest()[0:6]
),
source=self.ALIASES[0],
url=sub["subtitleUrl"],
# metadata
codec=sub["extension"],
language=sub["languageCode"],
is_original_lang=title.original_lang and is_close_match(sub["languageCode"], [title.original_lang]),
forced=sub["forced"],
sdh="_CC_" in sub["subtitleUrl"]
))
for track in tracks:
if not track.language and title.original_lang:
track.language = title.original_lang
if isinstance(track, VideoTrack):
track.hdr10 = profile in ("hdr", "imax") # TODO: What about DV? Could it be DV?
track.extra = {"license_url": res["widevineLicenseServer"]}
return tracks
def get_chapters(self, title: Title) -> list[MenuTrack]:
return []
def certificate(self, **kwargs: Any) -> bytes:
# TODO: Hardcode the certificate
return self.license(**kwargs)
def license(self, challenge: bytes, track: Track, **_: Any) -> bytes:
for n in range(5):
# even the official APK seems to need to retry at least twice
res = self.session.post(
url=track.extra["license_url"],
data=challenge, # expects bytes
# TODO: Need session ID? headers={"Session": self.session_id}
).content
if res and res != b"Unauthorized":
print(res)
return res
self.log.exit(" - License api call failed, unable to get certificate or license.")
raise
# Service specific functions
def configure(self) -> None:
self.session.headers.update({
"ApiKey": self.config["api_key"],
"AppLanguage": "EN"
})
self.session_id = self.login()
self.credits = self.get_available_credits()
self.log.info(f" - Credits available: {self.credits}.")
def login(self) -> str:
"""
Log in to BraviaCORE and return a Session ID.
:returns: Session ID.
"""
if not self.credentials:
self.log.exit(" - No credentials provided, unable to log in.")
raise
res = self.session.post(
url=self.config["endpoints"]["login"],
json={
"deviceIdentifier": self.config["device_id"],
"deviceModel": self.config["device_model"],
"softwareVersion": self.config["software_version"],
"email": self.credentials.username,
"password": self.credentials.password,
}
)
try:
data = res.json()
except json.JSONDecodeError:
self.log.exit(f" - Failed to get Session ID, response was not JSON: {res.text}")
raise
if data["responseCode"] >= 19999:
self.log.exit(f" - Failed to log in. Error: {repr(ResponseCode(data['responseCode']))}.")
raise
return data["session"]
def get_available_credits(self) -> int:
"""Get the amount of available credits in the account."""
if not self.session_id:
self.log.exit(" - Cannot get available credits, you must log in first")
raise
res = self.session.get(
url=self.config["endpoints"]["credits"],
headers={"Session": self.session_id}
)
try:
data = res.json()
except json.JSONDecodeError:
self.log.debug(res.text)
self.log.exit(" - Failed to get available credits, response was not JSON")
raise
return data["creditsAvailable"]
def redeem(self, pp_id: int) -> None:
"""Redeem title by Parent Product ID using available credits."""
if not self.session_id:
self.log.exit(f" - Cannot redeem title {pp_id}, you must log in first")
raise
definition = "-6" # TODO: what's the -6? api refers to it as a "definition", seen -2 in v1.1.0 apk
res = self.session.post(
url=self.config["endpoints"]["redeem"],
json={"parentProductIds": f"{pp_id}{definition}"}, # can be multiple values separated by ','.
headers={"Session": self.session_id}
)
res_code = ResponseCode(res.json()["productResponseCodes"][0]["responseCode"])
if res_code not in [ResponseCode.SUCCESS_REDEEMED, ResponseCode.SUCCESS_ALREAD_REDEEMED]:
self.log.exit(f" - Failed to redeem title {pp_id}{definition}. Error: {repr(res_code)}")
raise
self.log.info(f" - Redeemed title {pp_id}{definition} [{repr(res_code)}]")
def get_video(self, pp_id: int, profile: Optional[str] = None, quality: int = 3000, sub_type: str = "srt",
is_3d: bool = False, is_4k: bool = True, stream_type: int = 2, transaction_type: int = 1,
restrictions_enabled: bool = True) -> dict:
"""
Get Video Manifest Information.
Parameters:
pp_id: Parent Product ID.
profile: Profile. imax, hdr, h264, None (best?)
quality: Quality. 3000, 2160, 1080
sub_type: Subtitle Format. vtt, srt
is_3d: Request 3D Video.
is_4k: Request UHD Video.
stream_type: ? 2 Seems to be hardcoded.
transaction_type: ? 1 is typical default.
restrictions_enabled: ? True Seems to be hardcoded.
"""
res = self.session.get(
url=f"https://service.privilegemovies.com/content/v6/video/{pp_id}",
params={
"profile": profile,
"quality": quality,
"subType": sub_type,
"is3D": is_3d,
"is4K": is_4k,
"streamType": stream_type,
"transactionType": transaction_type,
"restrictionsEnabled": restrictions_enabled
},
headers={"Session": self.session_id}
)
res.raise_for_status()
video = res.json()
if video["responseCode"] > 19999:
raise ValueError(
f"Could not get manifest for {pp_id}. "
f"Error: {repr(ResponseCode(video['responseCode']))}. "
f"URL: {res.request.url}"
)
return dict(
alpha=video["alpha"],
audioLanguages=video["audioLanguages"].split(","),
downloadable=video["downloadable"],
expiryDate=video["linkExpiry"],
fairPlayLicenseServer=video["fairPlayLicenseServer"],
playReadyLicenseServer=video["playReadyLicenseServer"],
widevineLicenseServer=video["widevineLicenseServer"],
movieId=video["movieId"],
trackingId=video["trackingId"],
tracks=video["productTracks"],
uri=next((x["url"] for x in video["productTracks"] if x["fileType"] in (20, 11)), None)
)
def search_manifest_internal(self, pp_id: int, movie_id: Optional[int] = None) -> dict:
"""
Gets all manifest pages from the internal endpoint that was briefly open and returns only wanted title.
Since it was closed/fixed, only the cached pages are searchable.
It intentionally gets all manifests before checking for a match to have a safe sorted() check.
"""
samples = []
pages = self.get_cache("manifest_internal")
if pages.is_dir():
samples = [
sample
for page in pages.iterdir()
for sample in jsonpickle.decode(page.read_text("utf8"))
if sample["ppId"] == pp_id
]
if samples:
alphas = list(set(x["alpha"].upper() for x in samples))
if len(alphas) > 1:
print("Alpha List:")
for i, a in enumerate(alphas):
sub_count = sum(len(x.get('subtitles') or []) for x in samples if x['alpha'].upper() == a)
print(f"{i + 1:02}: {a} (Has up to {sub_count} Subtitles)")
alpha = input("Which alpha (version) do you wish to get? (#): ")
alpha = alphas[int(alpha or 1) - 1]
else:
alpha = alphas[0]
samples = [x for x in samples if x["alpha"].upper() == alpha.upper()]
samples = sorted(samples, key=lambda t: int(t["job_number"] or 0))
samples = sorted(samples, key=lambda t: "SDR" in t["quality"])
samples = sorted(samples, key=lambda t: "HDR" in t["quality"])
samples = sorted(samples, key=lambda t: "4K" in t["quality"])
samples = sorted(samples, key=lambda t: "IMAX" in t["quality"])
if movie_id:
samples = sorted(samples, key=lambda t: int(t["movieId"]) == movie_id)
chosen = samples[-1]
if not chosen.get("subtitles"):
chosen["subtitles"] = []
for sample in samples:
if sample["alpha"] != chosen["alpha"]:
continue
for subtitle in (sample.get("subtitles") or []):
subtitle_data = "".join(reversed(subtitle["subtitleUrl"])).split("_", 1)[-1]
if not any([
"".join(reversed(x["subtitleUrl"])).split("_", 1)[-1] == subtitle_data
for x in chosen["subtitles"]
]):
chosen["subtitles"].append(subtitle)
chosen["widevineLicenseServer"] = chosen["drm_license_url"]
return chosen
self.log.exit(" - Title was not found in the internal endpoint, possibly in broken pages :/")
raise
def get_playlist(self, playlist: str) -> list[Title]:
titles = []
page = 0
while True:
page += 1
res = self.session.get(
url=f"https://service.privilegemovies.com/content/v6/playlist/{playlist}/content",
params={
"kids": "false",
"width": "300",
"PageSize": "48",
"PageNumber": str(page)
}
).json()
res = res["products"]
titles.extend([Title(
id_=x["parentProductId"],
type_=Title.Types.MOVIE if x["contentType"] == 1 else Title.Types.TV,
name=x["title"],
year=x["year"],
season=x.get("season"),
episode=x.get("episode"),
episode_name=None, # TODO: Implement episode_name
original_lang=x["language"],
source=self.ALIASES[0],
service_data=x
) for x in res])
if len(res) < 48:
break
return titles
def list_playlist(self, playlist: str, name: str) -> None:
titles = self.get_playlist(playlist)
self.log.info(f" > {name} ({len(titles)}):")
for title in titles:
self.log.info(
"{} | {} ({}) [{}]".format(
title.id,
title.name,
title.year or "???",
",".join(map(str, title.service_data["transactionTypes"]))
)
)
def search(self, query: str) -> list[dict]:
res = self.session.get(
url=f"https://service.privilegemovies.com/content/v6/search/{query}",
params={
"kids": "false",
"width": "0"
}
).json()
return res["results"]
@staticmethod
def prepare_manifest_url(url: str, movie_id: int) -> str:
if "cf.privilegemovies.com/drm" in url:
mr = base64.b64encode(json.dumps({
"v": "7", # version
"m": movie_id, # movie id, title.service_data["parentId"] maybe?
"u": url, # original uri
"minB": "0", # min bitrate
"e": "Production", # environment
"maxB": "2147483647", # max bitrate
"mvas": "false", # ?
"al": ["EN", "en-US", "ENG", "UKE", "UKH", "ENH", "ENA", "en-EN"], # audio languages, what purpose?
"up": "2021-04-19T16:03:10.373", # when the file was uploaded
"o": "cf", # output, CDN maybe?
"f": base64.b64encode("-".join([
# string format of above?
str(movie_id),
"manifest.mpd",
"0-2147483647",
"False",
"cf",
"637544449903730000",
"Production",
"7",
"EN-en-US-ENG-UKE-UKH-ENH-ENA-en-EN"
]).encode()).decode() + ".mpd",
"s": "CRhH/PTdzH6zzowZu2k3jnRh7zw=" # hmac signature of f?
}).encode()).decode()
url = url.replace("cf.privilegemovies.com/drm", "service.privilegemovies.com/mg/drm")
url += f"?mr={mr}"
return url
class ResponseCode(Enum):
ACCEPTANCE_REQUIRED = 40070
ACCOUNT_EXISTS = 40016
AGE_NOT_CHECKED = 40015
AUTO_REDEMPTION_UNAVAILABLE = 40027
CANNOT_SET_EMPTY_WEBHOOK_URL = 40115
CANT_DELETE_LAST_CONSUMER_PROFILE = 40092
CANT_EXPIRE_LAST_PROFILE = 40108
CANT_MAKE_LAST_PROFILE_KIDS = 40107
CATEGORY_DEFINITION_ALREADY_EXISTS = 40135
CATEGORY_DEFINITION_NOT_FOUND = 40134
CHILD_PRODUCT_NOT_ACTIVE = 40031
CODE_GENERATION_ERROR = 20006
CONCURRENT_STREAM_LIMIT_REACHED = 40079
CONSUMER_BLACK_LISTED = 40047
CONSUMER_DEVICE_NOT_AUTHENTICATED = 40082
CONSUMER_NOT_FOUND = 40103
CONTENT_NOT_RENTED = 40128
CREDIT_BUNDLE_NOT_FOUND = 40111
CREDIT_BUNDLE_PRICE_NOT_FOUND = 40113
DECLINED_PRIVACY_POLICY = 40013
DECLINED_TERMS_AND_CONDITIONS = 40014
DEFINITION_REQUIRED = 40065
DELIVERY_TYPE_NOT_ALLOWED = 40028
DEVICE_ACTIVATED_TOO_SOON = 40083
DEVICE_BLACK_LISTED = 40049
DEVICE_ID_REQUIRED = 40012
DEVICE_LIMIT_REACHED = 40081
DEVICE_MEMBERSHIP_NOT_FOUND = 20020
DEVICE_MODEL_NOT_FOUND = 40140
DEVICE_MODEL_REQUIRED = 40022
DEVICE_NO_LONGER_ACTIVE = 40045
DOWNLOAD_LIMIT_EXCEEDED = 40032
DOWNLOAD_UNAVAILABLE = 40037
EMAIL_ALREADY_IN_USE = 40102
EMAIL_BLACK_LISTED = 40048
FACEBOOK_LOGIN_DISABLED = 30002
FACEBOOK_LOGIN_REQUIRED = 40072
FAILED_TO_BLOCK = 40094
FAILED_TO_DELETE_BLOCKED = 40095
FAILED_TO_REDEEM_PRODUCT = 40023
FAILED_TO_REDEEM_PRODUCT_DEFINITION = 40059
FAILED_TO_REDEEM_VOUCHER = 20013
FAILED_TO_REGISTER_DEVICE = 20010
FAILED_TO_SEND_EMAIL = 20012
FAILED_TO_UPDATE_DOWNLOAD_STATE = 20011
FORCED_REDEMPTION_DOES_NOT_EXIST = 40064
GCM_FAILED_TO_UPDATE_SERVICE = 40074
GCM_INVALID_INSTANCE_ID = 40073
GENERIC_NETWORK_ERROR = -1
INCORRECT_PAYMENT_STATE = 40124
INSUFFICIENT_PERMISSIONS = 40101
INVALID_ACCEPTANCE_FORMAT = 40071
INVALID_ACCESS_TOKEN = 40090
INVALID_API_KEY = 30001
INVALID_CHARACTERS_DETECTED = 40093
INVALID_CONCURRENT_STREAM_EVENT = 40091
INVALID_CONSUMER_DEVICE = 40080
INVALID_CONSUMER_PROFILE = 40089
INVALID_CONTENT_SELECTION_TYPE = 40053
INVALID_COUNTRY_CODE = 40010
INVALID_DOWNLOAD_REQUEST_CODE = 40006
INVALID_EMAIL_FORMAT = 40008
INVALID_FACEBOOK_TOKEN = 40051
INVALID_IP_COUNTRY = 40038
INVALID_IP_FORMAT = 40084
INVALID_LICENSE_REQUEST_CODE = 40007
INVALID_NONCE = 40000
INVALID_PASSWORD = 40003
INVALID_PASSWORD_FORMAT = 40009
INVALID_PIN = 40096
INVALID_PIN_FORMAT = 40097
INVALID_PLAYSTATION_AUTH_CODE = 40139
INVALID_PURCHASE_OPTION = 40109
INVALID_PUSH_NOTIFICATION_DEVICE = 40086
INVALID_QUALITY = 40060
INVALID_REDEEM_DEVICE = 40138
INVALID_REDEMPTION = 40026
INVALID_SESSION_ID = 40001
INVALID_SOFTWARE_VERSION = 40046
INVALID_SPDID = 40041
INVALID_TEMPORARY_PASSWORD = 40002
INVALID_URL_PROVIDED = 40117
INVALID_USERNAME = 40137
INVALID_USERNAME_OR_PASSWORD = 40005
INVALID_VOUCHER_CODE = 40004
IP_BLACK_LISTED = 40050
MOVIE_CREDIT_REDEMPTION_UNAVAILABLE = 40036
MOVIE_NOT_FOUND = 40119
MOVIE_TRACK_NOT_FOUND = 40118
NOT_ENOUGH_CREDITS = 40021
NOT_PRIMARY_DEVICE = 40033
NO_CONSUMER_PREFERENCE_FOUND = 40078
NO_CONTENT_FOUND = 40025
NO_CONTENT_PATH = 40039
NO_EMAIL_ADDRESS_RETRIEVED_FROM_FACEBOOK = 40069
NO_MOVIES_OF_THE_MONTH_DEFINED = 40133
NO_PIN_SET = 40098
NO_STATIC_BANNER_FOUND = 40077
OUT_DATED_SOFTWARE_VERSION = 40062
PARENT_PRODUCT_DEFINITION_NOT_REDEEMED = 40061
PARENT_PRODUCT_DEFINITION_NOT_RENTED = 40131
PARENT_PRODUCT_DOES_NOT_EXIST = 40058
PARENT_PRODUCT_EXISTS_IN_PLAYLIST = 40106
PARENT_PRODUCT_IDS_MISSING = 40063
PARENT_PRODUCT_ID_REQUIRED = 40068
PARENT_PRODUCT_NOT_ACTIVE = 40056
PARENT_PRODUCT_NOT_FOUND = 40110
PARENT_PRODUCT_NOT_FOUND_IN_PLAYLIST = 40105
PARENT_PRODUCT_NOT_REDEEMED = 40029
PARENT_PRODUCT_UNAVAILABLE = 40044
PASSWORDS_DO_NOT_MATCH = 40024
PLAYLIST_CUSTOM_LIST_TYPE_NOT_SET = 40132
PLAYLIST_NOT_FOUND = 40088
PRODUCT_UNKNOWN_ERROR = 20007
PROFILE_EXPIRED = 40099
PROFILE_NAME_EXISTS = 40104
PROMOTION_UNAVAILABLE = 40035
PROMO_STATE_NOT_FOUND = 20018
PURCHASE_LOCATION_REQUIRED = 40030
REGISTRATION_FAILED = 20009
RENTAL_PERIOD_ALREADY_EXISTS = 40126
RENTAL_PERIOD_NOT_FOUND = 40125
RENTING_DEVICE_LIMIT_REACHED = 40130
RENTING_NOT_SUPPORTED_BY_WHITE_LABEL_CAMPAIGN = 40129
REQUIREMENTS_NOT_FOUND = 40085
SECONDARY_EMAIL_EXISTS = 40100
SERIES_NOT_FOUND = 40136
SERVER_ERROR = 20000
SESSION_ERROR = 20008
SESSION_EXPIRED = 40011
SOFTWARE_VERSION_NOT_FOUND = 20019
SPDID_EXPIRED = 40042
SPDID_NO_SETUP = 20015
SPDID_RAW_DEVICE_INVALID = 40043
SPDID_REQUIRED = 40040
SUBSCRIPTION_ALREADY_CANCELLED = 40127
SUBSCRIPTION_INVOICE_NOT_FOUND = 40123
SUBSCRIPTION_NOT_FOUND = 40122
SUBSCRIPTION_PLAN_NOT_FOUND = 40121
SUBTITLE_NOT_FOUND = 40120
SUCCESS = 10000
SUCCESS_ACCEPTANCE_REQUIRED = 10008
SUCCESS_ALREAD_REDEEMED = 10002
SUCCESS_END = 19999
SUCCESS_FAILED_EMAIL = 10005
SUCCESS_FAILED_REDEEMED = 10007
SUCCESS_INVALID_LANGUAGE = 10001
SUCCESS_INVALID_VOUCHER = 10004
SUCCESS_OK_ALREADY_REDEEMED_VOUCHER_CODE = 10011
SUCCESS_OK_ALREADY_RENTING = 10013
SUCCESS_OK_INVALID_NOTIFICATION_MESSAGE = 10010
SUCCESS_OK_SOME_FAILED = 10012
SUCCESS_REDEEMED = 10006
SUCCESS_TEMPORARY_PASSWORD_USED = 10003
TEAM_NOT_FOUND = 40114
THIRD_PARTY_INACTIVE = 30000
TIER_PRICE_NOT_FOUND = 40112
UNKNOWN_CAMPAIN_GROUP_ERROR = 20002
UNKNOWN_CONSUMER_ERROR = 20004
UNKNOWN_DEVICE_ERROR = 20003
UNKNOWN_SESSION_ERROR = 20005
UNKNOWN_SESSION_ERROR_2 = 20014
UNKNOWN_THEME_ERROR = 20016
UNKNOWN_WHITE_LABEL_CAMPAIGN_ERROR = 20017
UNKNOWN_WHITE_LABEL_ERROR = 20001
VIDEO_UNLIMITED_NOT_SUPPORTED = 30003
VOUCHER_BUNDLE_NOT_ACTIVE = 40057
VOUCHER_BUNDLE_NOT_STARTED = 40075
VOUCHER_CODE_EXPIRED = 40019
VOUCHER_CODE_HASNT_STARTED = 40055
VOUCHER_CODE_INVALID = 40018
VOUCHER_CODE_INVALID_COUNTRY = 40054
VOUCHER_CODE_INVALID_USER_TYPE = 40052
VOUCHER_CODE_REQUIRED = 40017
VOUCHER_CODE_USED = 40020
VOUCHER_CODE_WRONG_PROMO = 40076
VOUCHER_REDEMPTION_UNAVAILABLE = 40034
VOUCHER_RULE_INVALID_DEVICE = 40067
WEBHOOK_EVENT_NOT_FOUND = 40116
WHITE_LABEL_CAMPAIGN_DOWNLOAD_DISABLED = 40066
WHITE_LABEL_CAMPAIGN_NOT_FOUND = 40087