mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 12:15:18 +03:00
commit
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
__pycache__/
|
||||
!gamdl.py
|
||||
!song_genres.py
|
||||
!music_video_genres.py
|
||||
!storefront_ids.py
|
||||
!pywidevine
|
||||
!requirements.txt
|
||||
!.gitignore
|
||||
device_client_id_blob
|
||||
device_private_key
|
||||
@@ -0,0 +1,504 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import requests
|
||||
import urllib3
|
||||
import storefront_ids
|
||||
import json
|
||||
import m3u8
|
||||
from yt_dlp import YoutubeDL
|
||||
from pywidevine.L3.decrypt.wvdecrypt import WvDecrypt
|
||||
from pywidevine.L3.decrypt.wvdecryptconfig import WvDecryptConfig
|
||||
import base64
|
||||
from pywidevine.L3.cdm.formats.widevine_pssh_data_pb2 import WidevinePsshData
|
||||
from mutagen.mp4 import MP4Cover, MP4
|
||||
import song_genres
|
||||
import music_video_genres
|
||||
from xml.dom import minidom
|
||||
import datetime
|
||||
import os
|
||||
from argparse import ArgumentParser
|
||||
import shutil
|
||||
import traceback
|
||||
|
||||
class Gamdl:
|
||||
def __init__(self, disable_music_video_skip, auth_path, temp_path, prefer_hevc, final_path):
|
||||
self.disable_music_video_skip = disable_music_video_skip
|
||||
self.auth_path = auth_path
|
||||
self.temp_path = temp_path
|
||||
self.prefer_hevc = prefer_hevc
|
||||
self.final_path = final_path
|
||||
self.login()
|
||||
|
||||
|
||||
def login(self):
|
||||
cookies = {}
|
||||
with open(Path(self.auth_path) / 'cookies.txt', 'r') as f:
|
||||
for l in f:
|
||||
if not re.match(r"^#", l) and not re.match(r"^\n", l):
|
||||
line_fields = l.strip().replace('"', '"').split('\t')
|
||||
cookies[line_fields[5]] = line_fields[6]
|
||||
with open(Path(self.auth_path) / 'token.txt', 'r') as f:
|
||||
token = f.read()
|
||||
self.session = requests.Session()
|
||||
self.session.verify = False
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
self.session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0"})
|
||||
self.session.cookies.update(cookies)
|
||||
self.session.headers.update({
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"authorization": token,
|
||||
"content-type": "application/json",
|
||||
"Media-User-Token": self.session.cookies.get_dict()["media-user-token"],
|
||||
"x-apple-renewal": "true",
|
||||
"DNT": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
'origin': 'https://beta.music.apple.com'
|
||||
})
|
||||
self.country = cookies['itua']
|
||||
self.storefront = getattr(storefront_ids, self.country)
|
||||
|
||||
|
||||
def get_download_queue(self, url):
|
||||
download_queue = []
|
||||
product_id = url.split('/')[-1].split('i=')[-1].split('&')[0].split('?')[0]
|
||||
response = self.session.get(f'https://api.music.apple.com/v1/catalog/{self.country}/?ids[songs]={product_id}&ids[albums]={product_id}&ids[playlists]={product_id}&ids[music-videos]={product_id}').json()['data'][0]
|
||||
if response['type'] == 'songs':
|
||||
download_queue.append({
|
||||
'track_id': response['id'],
|
||||
'title': response['attributes']['name'],
|
||||
})
|
||||
if response['type'] == 'albums' or response['type'] == 'playlists':
|
||||
for track in response['relationships']['tracks']['data']:
|
||||
if 'playParams' in track['attributes'].keys():
|
||||
if track['type'] == 'music-videos' and self.disable_music_video_skip:
|
||||
download_queue.append({
|
||||
'track_id': track['attributes']['playParams']['id'],
|
||||
'alt_track_id': track['attributes']['url'].split('/')[-1],
|
||||
'title': track['attributes']['name'],
|
||||
})
|
||||
if track['type'] == 'songs':
|
||||
download_queue.append({
|
||||
'track_id': track['attributes']['playParams']['id'],
|
||||
'title': track['attributes']['name'],
|
||||
})
|
||||
if response['type'] == 'music-videos':
|
||||
download_queue.append({
|
||||
'track_id': response['attributes']['playParams']['id'],
|
||||
'alt_track_id': response['attributes']['url'].split('/')[-1],
|
||||
'title': response['attributes']['name'],
|
||||
})
|
||||
return download_queue
|
||||
|
||||
|
||||
def get_webplayback(self, track_id):
|
||||
response = self.session.post(
|
||||
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback',
|
||||
json.dumps({
|
||||
'salableAdamId': track_id
|
||||
})
|
||||
).json()["songList"][0]
|
||||
return response
|
||||
|
||||
|
||||
def get_playlist_music_video(self, webplayback):
|
||||
return m3u8.load(webplayback['hls-playlist-url'])
|
||||
|
||||
|
||||
def get_stream_url_song(self, webplayback):
|
||||
return next((x for x in webplayback["assets"] if x["flavor"] == "28:ctrp256"))['URL']
|
||||
|
||||
|
||||
def get_stream_url_music_video_audio(self, playlist):
|
||||
return [x for x in playlist.media if x.type == "AUDIO"][-1].uri
|
||||
|
||||
|
||||
def get_stream_url_music_video_video(self, playlist):
|
||||
if self.prefer_hevc:
|
||||
return playlist.playlists[-1].uri
|
||||
else:
|
||||
return [x for x in playlist.playlists if 'avc' in x.stream_info.codecs][-1].uri
|
||||
|
||||
|
||||
def get_encrypted_location_audio(self, track_id):
|
||||
return Path(self.temp_path) / f'{track_id}e.m4a'
|
||||
|
||||
|
||||
def get_encrypted_location_video(self, track_id):
|
||||
return Path(self.temp_path) / f'{track_id}e.mp4'
|
||||
|
||||
|
||||
def get_decrypted_location_audio(self, track_id):
|
||||
return Path(self.temp_path) / f'{track_id}d.m4a'
|
||||
|
||||
|
||||
def get_decrypted_location_video(self, track_id):
|
||||
return Path(self.temp_path) / f'{track_id}d.mp4'
|
||||
|
||||
|
||||
def download(self, encrypted_location, stream_url):
|
||||
with YoutubeDL({
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'outtmpl': str(encrypted_location),
|
||||
'allow_unplayable_formats': True,
|
||||
'fixup': 'never',
|
||||
'external_downloader': 'aria2c'
|
||||
}) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
|
||||
def get_license_b64(self, challenge, track_uri, track_id):
|
||||
return self.session.post(
|
||||
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense',
|
||||
json.dumps({
|
||||
'challenge': challenge,
|
||||
'key-system': 'com.widevine.alpha',
|
||||
'uri': track_uri,
|
||||
'adamId': track_id,
|
||||
'isLibrary': False,
|
||||
'user-initiated': True
|
||||
})
|
||||
).json()['license']
|
||||
|
||||
|
||||
def decrypt_music_video(self, decrypted_location, encrypted_location, stream_url, track_id):
|
||||
playlist = m3u8.load(stream_url)
|
||||
track_uri = next(x for x in playlist.keys if x.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed").uri
|
||||
wvdecryptconfig = WvDecryptConfig(decrypted_location, encrypted_location, track_uri)
|
||||
wvdecryptconfig.init_data_b64 = wvdecryptconfig.init_data_b64.split(",")[1]
|
||||
wvdecrypt = WvDecrypt(wvdecryptconfig)
|
||||
challenge = base64.b64encode(wvdecrypt.get_challenge()).decode('utf-8')
|
||||
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
|
||||
wvdecrypt.update_license(license_b64)
|
||||
wvdecrypt.start_process()
|
||||
|
||||
|
||||
def decrypt_song(self, decrypted_location, encrypted_location, stream_url, track_id):
|
||||
track_uri = m3u8.load(stream_url).keys[0].uri
|
||||
wvpsshdata = WidevinePsshData()
|
||||
wvpsshdata.algorithm = 1
|
||||
wvdecryptconfig = WvDecryptConfig(decrypted_location, encrypted_location, track_uri)
|
||||
wvpsshdata.key_id.append(base64.b64decode(wvdecryptconfig.init_data_b64.split(",")[1]))
|
||||
wvdecryptconfig.init_data_b64 = base64.b64encode(wvpsshdata.SerializeToString()).decode("utf8")
|
||||
wvdecrypt = WvDecrypt(wvdecryptconfig)
|
||||
challenge = base64.b64encode(wvdecrypt.get_challenge()).decode('utf-8')
|
||||
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
|
||||
wvdecrypt.update_license(license_b64)
|
||||
wvdecrypt.start_process()
|
||||
|
||||
|
||||
def get_synced_lyrics_formated_time(self, unformatted_time):
|
||||
if 's' in unformatted_time:
|
||||
unformatted_time = unformatted_time.replace('s', '')
|
||||
if '.' not in unformatted_time:
|
||||
unformatted_time += '.0'
|
||||
s = int(unformatted_time.split('.')[-2].split(':')[-1]) * 1000
|
||||
try:
|
||||
m = int(unformatted_time.split('.')[-2].split(':')[-2]) * 60000
|
||||
except:
|
||||
m = 0
|
||||
ms = int(unformatted_time.split('.')[-1])
|
||||
formated_time = datetime.datetime.fromtimestamp((s + m + ms)/1000.0)
|
||||
return f'{formated_time.minute:02d}:{formated_time.second:02d}.{int(str(formated_time.microsecond)[:2]):02d}'
|
||||
|
||||
|
||||
def get_lyrics(self, track_id):
|
||||
try:
|
||||
raw_lyrics = minidom.parseString(self.session.get(f'https://amp-api.music.apple.com/v1/catalog/{self.country}/songs/{track_id}/lyrics').json()['data'][0]['attributes']['ttml'])
|
||||
except:
|
||||
return
|
||||
unsynced_lyrics = ''
|
||||
synced_lyrics = ''
|
||||
for stanza in raw_lyrics.getElementsByTagName("div"):
|
||||
for verse in stanza.getElementsByTagName("p"):
|
||||
if not verse.firstChild.nodeValue:
|
||||
subverse_time = []
|
||||
subverse_text = []
|
||||
for subserve in verse.getElementsByTagName("span"):
|
||||
if subserve.firstChild.nodeValue:
|
||||
subverse_time.append(subserve.getAttribute('begin'))
|
||||
subverse_text.append(subserve.firstChild.nodeValue)
|
||||
subverse_time = subverse_time[0]
|
||||
subverse_text = ' '.join(subverse_text)
|
||||
unsynced_lyrics += subverse_text + '\n'
|
||||
if subverse_time:
|
||||
synced_lyrics += f'[{self.get_synced_lyrics_formated_time(subverse_time)}]{subverse_text}\n'
|
||||
else:
|
||||
unsynced_lyrics += verse.firstChild.nodeValue + '\n'
|
||||
if verse.getAttribute('begin'):
|
||||
synced_lyrics += f'[{self.get_synced_lyrics_formated_time(verse.getAttribute("begin"))}]{verse.firstChild.nodeValue}\n'
|
||||
unsynced_lyrics += '\n'
|
||||
return [unsynced_lyrics.strip(), synced_lyrics]
|
||||
|
||||
|
||||
def get_tags_song(self, webplayback, lyrics):
|
||||
metadata = next((x for x in webplayback["assets"] if x["flavor"] == "28:ctrp256"))['metadata']
|
||||
artwork_url = next((x for x in webplayback["assets"] if x["flavor"] == "28:ctrp256"))['artworkURL']
|
||||
tags = {
|
||||
'\xa9nam': [metadata['itemName']],
|
||||
'\xa9gen': [getattr(song_genres, f'ID{metadata["genreId"]}')],
|
||||
'aART': [metadata['playlistArtistName']],
|
||||
'\xa9alb': [metadata['playlistName']],
|
||||
'soar': [metadata['sort-artist']],
|
||||
'soal': [metadata['sort-album']],
|
||||
'sonm': [metadata['sort-name']],
|
||||
'\xa9alb': [metadata['playlistName']],
|
||||
'\xa9ART': [metadata['artistName']],
|
||||
'geID': [metadata['genreId']],
|
||||
'atID': [int(metadata['artistId'])],
|
||||
'plID': [int(metadata['playlistId'])],
|
||||
'cnID': [int(metadata['itemId'])],
|
||||
'sfID': [metadata['s']],
|
||||
'rtng': [metadata['explicit']],
|
||||
'pgap': metadata['gapless'],
|
||||
'cpil': metadata['compilation'],
|
||||
'disk': [(metadata['discNumber'], metadata['discCount'])],
|
||||
'trkn': [(metadata['trackNumber'], metadata['trackCount'])],
|
||||
'covr': [MP4Cover(requests.get(artwork_url).content, MP4Cover.FORMAT_JPEG)],
|
||||
'stik': [1]
|
||||
}
|
||||
if 'copyright' in metadata.keys():
|
||||
tags['cprt'] = [metadata['copyright']]
|
||||
if 'releaseDate' in metadata.keys():
|
||||
tags['\xa9day'] = [metadata['releaseDate']]
|
||||
if 'comments' in metadata.keys():
|
||||
tags['\xa9cmt'] = [metadata['comments']]
|
||||
if 'xid' in metadata.keys():
|
||||
tags['xid '] = [metadata['xid']]
|
||||
if 'composerId' in metadata.keys():
|
||||
tags['cmID'] = [int(metadata['composerId'])]
|
||||
tags['\xa9wrt'] = [metadata['composerName']]
|
||||
tags['soco'] = [metadata['sort-composer']]
|
||||
if lyrics:
|
||||
tags['\xa9lyr'] = [lyrics[0]]
|
||||
return tags
|
||||
|
||||
|
||||
def get_tags_music_video(self, track_id):
|
||||
metadata = requests.get(f'https://itunes.apple.com/lookup?id={track_id}&entity=album&limit=200&country={self.country}').json()['results']
|
||||
extra_metadata = requests.get(f'https://music.apple.com/music-video/{metadata[0]["trackId"]}', headers = {'X-Apple-Store-Front': f'{self.storefront} t:music31'}).json()['storePlatformData']['product-dv']['results'][str(metadata[0]['trackId'])]
|
||||
tags = {
|
||||
'\xa9ART': [metadata[0]["artistName"]],
|
||||
'\xa9nam': [metadata[0]["trackCensoredName"]],
|
||||
'\xa9day': [metadata[0]["releaseDate"]],
|
||||
'cprt': [extra_metadata['copyright']],
|
||||
'\xa9gen': [getattr(music_video_genres, f'ID{extra_metadata["genres"][0]["genreId"]}')],
|
||||
'stik': [6],
|
||||
'atID': [metadata[0]['artistId']],
|
||||
'cnID': [metadata[0]["trackId"]],
|
||||
'geID': [int(extra_metadata['genres'][0]['genreId'])],
|
||||
'sfID': [int(self.storefront.split('-')[0])],
|
||||
'covr': [MP4Cover(requests.get(metadata[0]["artworkUrl30"].replace('30x30bb.jpg', '600x600bb.jpg')).content, MP4Cover.FORMAT_JPEG)]
|
||||
}
|
||||
if metadata[0]['trackExplicitness'] == 'notExplicit':
|
||||
tags['rtng'] = [0]
|
||||
elif metadata[0]['trackExplicitness'] == 'explicit':
|
||||
tags['rtng'] = [1]
|
||||
else:
|
||||
tags['rtng'] = [2]
|
||||
if len(metadata) > 1:
|
||||
tags['\xa9alb'] = [metadata[1]["collectionCensoredName"]]
|
||||
tags['aART'] = [metadata[1]["artistName"]]
|
||||
tags['plID'] = [metadata[1]["collectionId"]]
|
||||
tags['disk'] = [(metadata[0]["discNumber"], metadata[0]["discCount"])]
|
||||
tags['trkn'] = [(metadata[0]["trackNumber"], metadata[0]["trackCount"])]
|
||||
return tags
|
||||
|
||||
|
||||
def get_sanizated_string(self, dirty_string, is_folder = False):
|
||||
illegal_characters = ['\\', '/', ':', '*', '?', '"', '<', '>', '|', ';']
|
||||
for character in illegal_characters:
|
||||
dirty_string = dirty_string.replace(character, '_')
|
||||
if is_folder:
|
||||
if dirty_string[-1:] == '.':
|
||||
dirty_string = dirty_string[:-1] + '_'
|
||||
dirty_string = dirty_string[:40]
|
||||
else:
|
||||
dirty_string = dirty_string[:36]
|
||||
return dirty_string.strip()
|
||||
|
||||
|
||||
def get_final_location(self, file_extension, tags):
|
||||
final_location = Path(self.final_path)
|
||||
if 'plID' in tags.keys():
|
||||
if 'cpil' in tags.keys() and tags['cpil']:
|
||||
final_location /= f'Compilations/{self.get_sanizated_string(tags["©alb"][0], True)}'
|
||||
else:
|
||||
final_location /= f'{self.get_sanizated_string(tags["aART"][0], True)}/{self.get_sanizated_string(tags["©alb"][0], True)}'
|
||||
if tags['disk'][0][1] > 1:
|
||||
filename = self.get_sanizated_string(f'{tags["disk"][0][0]}-{tags["trkn"][0][0]:02d} {tags["©nam"][0]}')
|
||||
else:
|
||||
filename = self.get_sanizated_string(f'{tags["trkn"][0][0]:02d} {tags["©nam"][0]}')
|
||||
else:
|
||||
filename = self.get_sanizated_string(tags["©nam"][0])
|
||||
final_location /= f'{self.get_sanizated_string(tags["©ART"][0], True)}/Unknown Album/'
|
||||
final_location /= f'{filename}{file_extension}'
|
||||
return final_location
|
||||
|
||||
|
||||
def fixup_music_video(self, decrypted_location_audio, decrypted_location_video, final_location):
|
||||
os.makedirs(final_location.parents[0], exist_ok = True)
|
||||
os.system(f'MP4Box -quiet -add {decrypted_location_audio} -add {decrypted_location_video} -itags title=placeholder -new "{final_location}"')
|
||||
|
||||
|
||||
def fixup_song(self, decrypted_location, final_location):
|
||||
os.makedirs(final_location.parents[0], exist_ok = True)
|
||||
os.system(f'MP4Box -quiet -add {decrypted_location} -itags title=placeholder -new "{final_location}"')
|
||||
|
||||
|
||||
def make_lrc(self, final_download_location, lyrics):
|
||||
with open(final_download_location.with_suffix('.lrc'), 'w', encoding = 'utf8') as f:
|
||||
f.write(lyrics[1])
|
||||
|
||||
|
||||
def apply_tags(self, final_download_location, tags):
|
||||
file = MP4(final_download_location).tags
|
||||
for key, value in tags.items():
|
||||
file[key] = value
|
||||
file.save(final_download_location)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if not shutil.which('mp4decrypt'):
|
||||
print('mp4decrypt is not on PATH.')
|
||||
exit(1)
|
||||
if not shutil.which('MP4Box'):
|
||||
print('MP4Box is not on PATH.')
|
||||
exit(1)
|
||||
parser = ArgumentParser(description = 'A Python script to download Apple Music albums/music videos/playlists/songs.')
|
||||
parser.add_argument(
|
||||
'url',
|
||||
help='Apple Music albums/music videso/playlists/songs URL',
|
||||
nargs='+',
|
||||
metavar='<url>'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-d',
|
||||
'--final-path',
|
||||
default = 'Apple Music',
|
||||
help = 'Set Final Path.',
|
||||
metavar = '<final path>'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-a',
|
||||
'--auth-path',
|
||||
default = 'login',
|
||||
help = 'Set Auth Path.',
|
||||
metavar = '<auth path>'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-m',
|
||||
'--disable-music-video-skip',
|
||||
action = 'store_true',
|
||||
help = 'Disable music video skip on playlists/albums.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-p',
|
||||
'--prefer-hevc',
|
||||
action = 'store_true',
|
||||
help = 'Prefer HEVC over AVC.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n',
|
||||
'--no-lrc',
|
||||
action = 'store_true',
|
||||
help = "Don't create .lrc file."
|
||||
)
|
||||
parser.add_argument(
|
||||
'-t',
|
||||
'--temp-path',
|
||||
default = 'temp',
|
||||
help = 'Set Temp Path.',
|
||||
metavar = '<temp path>'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-s',
|
||||
'--skip-cleanup',
|
||||
action = 'store_true',
|
||||
help = 'Skip cleanup.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-e',
|
||||
'--print-exception',
|
||||
action = 'store_true',
|
||||
help = 'Print Execeptions.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-u',
|
||||
'--urls-txt',
|
||||
action = 'store_true',
|
||||
help = 'Use urls.txt to download URLs.'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not args.url and not args.download_txt:
|
||||
parser.error('the following arguments are required: <url>')
|
||||
if args.urls_txt:
|
||||
with open('urls.txt', 'r', encoding = 'utf8') as f:
|
||||
args.url = f.read().splitlines()
|
||||
gamdl = Gamdl(args.disable_music_video_skip, args.auth_path, args.temp_path, args.prefer_hevc, args.final_path)
|
||||
error_count = 0
|
||||
for i in range(len(args.url)):
|
||||
try:
|
||||
download_queue = gamdl.get_download_queue(args.url[i])
|
||||
except KeyboardInterrupt:
|
||||
exit(0)
|
||||
except:
|
||||
print(f'* Failed to check URL ({i + 1} of {len(args.url)}).')
|
||||
error_count += 1
|
||||
continue
|
||||
for j in range(len(download_queue)):
|
||||
track_id = download_queue[j]['track_id']
|
||||
print(f'Downloading "{download_queue[j]["title"]}" ({j + 1} of {len(download_queue)})...')
|
||||
try:
|
||||
webplayback = gamdl.get_webplayback(track_id)
|
||||
if len(webplayback['assets']) == 0:
|
||||
playlist = gamdl.get_playlist_music_video(webplayback)
|
||||
stream_url_audio = gamdl.get_stream_url_music_video_audio(playlist)
|
||||
encrypted_location_audio = gamdl.get_encrypted_location_audio(track_id)
|
||||
gamdl.download(encrypted_location_audio, stream_url_audio)
|
||||
decrypted_location_audio = gamdl.get_decrypted_location_audio(track_id)
|
||||
gamdl.decrypt_music_video(decrypted_location_audio, encrypted_location_audio, stream_url_audio, track_id)
|
||||
stream_url_video = gamdl.get_stream_url_music_video_video(playlist)
|
||||
encrypted_location_video = gamdl.get_encrypted_location_video(track_id)
|
||||
gamdl.download(encrypted_location_video, stream_url_video)
|
||||
decrypted_location_video = gamdl.get_decrypted_location_video(track_id)
|
||||
gamdl.decrypt_music_video(decrypted_location_video, encrypted_location_video, stream_url_video, track_id)
|
||||
tags = gamdl.get_tags_music_video(download_queue[j]['alt_track_id'])
|
||||
final_download_location = gamdl.get_final_location('.m4v', tags)
|
||||
gamdl.fixup_music_video(decrypted_location_audio, decrypted_location_video, final_download_location)
|
||||
else:
|
||||
stream_url = gamdl.get_stream_url_song(webplayback)
|
||||
encrypted_location = gamdl.get_encrypted_location_audio(track_id)
|
||||
gamdl.download(encrypted_location, stream_url)
|
||||
decrypted_location = gamdl.get_decrypted_location_audio(track_id)
|
||||
gamdl.decrypt_song(decrypted_location, encrypted_location, stream_url, track_id)
|
||||
lyrics = gamdl.get_lyrics(track_id)
|
||||
tags = gamdl.get_tags_song(webplayback, lyrics)
|
||||
final_download_location = gamdl.get_final_location('.m4a', tags)
|
||||
gamdl.fixup_song(decrypted_location, final_download_location)
|
||||
if not args.no_lrc and lyrics and lyrics[1]:
|
||||
gamdl.make_lrc(final_download_location, lyrics)
|
||||
gamdl.apply_tags(final_download_location, tags)
|
||||
if not args.skip_cleanup and os.path.exists(args.temp_path):
|
||||
shutil.rmtree(args.temp_path)
|
||||
except KeyboardInterrupt:
|
||||
exit(0)
|
||||
except:
|
||||
error_count += 1
|
||||
print(f'* Failed to dowload "{download_queue[j]["title"]}" ({j + 1} of {len(download_queue)}).')
|
||||
if args.print_exception:
|
||||
traceback.print_exc()
|
||||
print(f'All done ({error_count} error(s)).')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
ID31 = "Music Videos"
|
||||
ID1904 = "African"
|
||||
ID2037 = "African Dancehall"
|
||||
ID2038 = "African Reggae"
|
||||
ID1710 = "Afrikaans"
|
||||
ID1976 = "Afro House"
|
||||
ID1977 = "Afro Soul"
|
||||
ID1905 = "Afro-Beat"
|
||||
ID1906 = "Afro-Pop"
|
||||
ID2035 = "Afro-folk"
|
||||
ID2036 = "Afro-fusion"
|
||||
ID1978 = "Afrobeats"
|
||||
ID2033 = "Alte"
|
||||
ID2034 = "Amapiano"
|
||||
ID1979 = "Benga"
|
||||
ID1980 = "Bongo-Flava"
|
||||
ID1981 = "Coupé-Décalé"
|
||||
ID1982 = "Gqom"
|
||||
ID1983 = "Highlife"
|
||||
ID1985 = "Kizomba"
|
||||
ID1984 = "Kuduro"
|
||||
ID1986 = "Kwaito"
|
||||
ID2000 = "Maskandi"
|
||||
ID1987 = "Mbalax"
|
||||
ID1988 = "Ndombolo"
|
||||
ID1989 = "Shangaan Electro"
|
||||
ID1990 = "Soukous"
|
||||
ID1991 = "Taarab"
|
||||
ID1992 = "Zouglou"
|
||||
ID1620 = "Alternative"
|
||||
ID1635 = "Chinese Alt"
|
||||
ID1731 = "College Rock"
|
||||
ID1945 = "EMO"
|
||||
ID1732 = "Goth Rock"
|
||||
ID1733 = "Grunge"
|
||||
ID2025 = "Indie Egyptian"
|
||||
ID2024 = "Indie Levant"
|
||||
ID2026 = "Indie Maghreb"
|
||||
ID1947 = "Indie Pop"
|
||||
ID1734 = "Indie Rock"
|
||||
ID1636 = "Korean Indie"
|
||||
ID1735 = "New Wave"
|
||||
ID1946 = "Pop Punk"
|
||||
ID1736 = "Punk"
|
||||
ID1998 = "Turkish Alternative"
|
||||
ID1629 = "Anime"
|
||||
ID1713 = "Arabic"
|
||||
ID1716 = "Arabic Pop"
|
||||
ID1717 = "Islamic"
|
||||
ID1714 = "Khaleeji"
|
||||
ID2009 = "Khaleeji Jalsat"
|
||||
ID2010 = "Khaleeji Shailat"
|
||||
ID2006 = "Levant"
|
||||
ID2007 = "Dabke"
|
||||
ID2008 = "Maghreb Rai"
|
||||
ID1715 = "North African"
|
||||
ID1602 = "Blues"
|
||||
ID1737 = "Acoustic Blues"
|
||||
ID1738 = "Chicago Blues"
|
||||
ID1739 = "Classic Blues"
|
||||
ID1740 = "Contemporary Blues"
|
||||
ID1741 = "Country Blues"
|
||||
ID1742 = "Delta Blues"
|
||||
ID1743 = "Electric Blues"
|
||||
ID1671 = "Brazilian"
|
||||
ID1672 = "Axé"
|
||||
ID1673 = "Baile Funk"
|
||||
ID1674 = "Bossa Nova"
|
||||
ID1675 = "Choro"
|
||||
ID1676 = "Forro"
|
||||
ID1677 = "Frevo"
|
||||
ID1678 = "MPB"
|
||||
ID1679 = "Pagode"
|
||||
ID1680 = "Samba"
|
||||
ID1681 = "Sertanejo"
|
||||
ID1604 = "Children's Music"
|
||||
ID1744 = "Lullabies"
|
||||
ID1745 = "Sing-Along"
|
||||
ID1746 = "Stories"
|
||||
ID1637 = "Chinese"
|
||||
ID1638 = "Chinese Classical"
|
||||
ID1639 = "Chinese Flute"
|
||||
ID1640 = "Chinese Opera"
|
||||
ID1641 = "Chinese Orchestral"
|
||||
ID1642 = "Chinese Regional Folk"
|
||||
ID1643 = "Chinese Strings"
|
||||
ID1644 = "Taiwanese Folk"
|
||||
ID1645 = "Tibetan Native Music"
|
||||
ID1622 = "Christian"
|
||||
ID1747 = "CCM"
|
||||
ID1748 = "Christian Metal"
|
||||
ID1749 = "Christian Pop"
|
||||
ID1750 = "Christian Rap"
|
||||
ID1751 = "Christian Rock"
|
||||
ID1752 = "Classic Christian"
|
||||
ID1753 = "Contemporary Gospel"
|
||||
ID1754 = "Gospel"
|
||||
ID1755 = "Praise & Worship"
|
||||
ID1756 = "Southern Gospel"
|
||||
ID1757 = "Traditional Gospel"
|
||||
ID1605 = "Classical"
|
||||
ID1928 = "Art Song"
|
||||
ID1758 = "Avant-Garde"
|
||||
ID1759 = "Baroque Era"
|
||||
ID1929 = "Brass & Woodwinds"
|
||||
ID1933 = "Cantata"
|
||||
ID1939 = "Cello"
|
||||
ID1760 = "Chamber Music"
|
||||
ID1761 = "Chant"
|
||||
ID1762 = "Choral"
|
||||
ID1763 = "Classical Crossover"
|
||||
ID1682 = "High Classical"
|
||||
ID1931 = "Contemporary Era"
|
||||
ID1764 = "Early Music"
|
||||
ID1934 = "Electronic"
|
||||
ID1936 = "Guitar"
|
||||
ID1765 = "Impressionist"
|
||||
ID1766 = "Medieval Era"
|
||||
ID1767 = "Minimalism"
|
||||
ID1768 = "Modern Era"
|
||||
ID1609 = "Opera"
|
||||
ID1932 = "Oratorio"
|
||||
ID1769 = "Orchestral"
|
||||
ID1940 = "Percussion"
|
||||
ID1337 = "Piano"
|
||||
ID1770 = "Renaissance"
|
||||
ID1771 = "Romantic Era"
|
||||
ID1935 = "Sacred"
|
||||
ID1930 = "Solo Instrumental"
|
||||
ID1938 = "Violin"
|
||||
ID1772 = "Wedding Music"
|
||||
ID1603 = "Comedy"
|
||||
ID1773 = "Novelty"
|
||||
ID1774 = "Standup Comedy"
|
||||
ID1606 = "Country"
|
||||
ID1775 = "Alternative Country"
|
||||
ID1776 = "Americana"
|
||||
ID1777 = "Bluegrass"
|
||||
ID1778 = "Contemporary Bluegrass"
|
||||
ID1779 = "Contemporary Country"
|
||||
ID1780 = "Country Gospel"
|
||||
ID1781 = "Honky Tonk"
|
||||
ID1782 = "Outlaw Country"
|
||||
ID1724 = "Thai Country"
|
||||
ID1783 = "Traditional Bluegrass"
|
||||
ID1784 = "Traditional Country"
|
||||
ID1785 = "Urban Cowboy"
|
||||
ID1951 = "Cuban"
|
||||
ID1956 = "Bolero"
|
||||
ID1953 = "Chachacha"
|
||||
ID1954 = "Guajira"
|
||||
ID1957 = "Guaracha"
|
||||
ID1952 = "Mambo"
|
||||
ID1955 = "Son"
|
||||
ID1958 = "Timba"
|
||||
ID1617 = "Dance"
|
||||
ID1786 = "Breakbeat"
|
||||
ID1787 = "Exercise"
|
||||
ID1788 = "Garage"
|
||||
ID1789 = "Hardcore"
|
||||
ID1790 = "House"
|
||||
ID1791 = "Jungle/Drum'n'bass"
|
||||
ID2032 = "Maghreb Dance"
|
||||
ID1792 = "Techno"
|
||||
ID1793 = "Trance"
|
||||
ID1631 = "Disney"
|
||||
ID1625 = "Easy Listening"
|
||||
ID1794 = "Lounge"
|
||||
ID1795 = "Swing"
|
||||
ID1607 = "Electronic"
|
||||
ID1796 = "Ambient"
|
||||
ID1942 = "Bass"
|
||||
ID1797 = "Downtempo"
|
||||
ID1941 = "Dubstep"
|
||||
ID2028 = "Electro-Cha'abi"
|
||||
ID1798 = "Electronica"
|
||||
ID1799 = "IDM/Experimental"
|
||||
ID1800 = "Industrial"
|
||||
ID2027 = "Levant Electronic"
|
||||
ID2029 = "Maghreb Electronic"
|
||||
ID1628 = "Enka"
|
||||
ID1683 = "Fitness & Workout"
|
||||
ID1719 = "Folk"
|
||||
ID2030 = "Iraqi Folk"
|
||||
ID2031 = "Khaleeji Folk"
|
||||
ID1632 = "French Pop"
|
||||
ID1634 = "German Folk"
|
||||
ID1633 = "German Pop"
|
||||
ID1618 = "Hip-Hop/Rap"
|
||||
ID1801 = "Alternative Rap"
|
||||
ID1646 = "Chinese Hip-Hop"
|
||||
ID1802 = "Dirty South"
|
||||
ID1803 = "East Coast Rap"
|
||||
ID2021 = "Egyptian Hip-Hop"
|
||||
ID1804 = "Gangsta Rap"
|
||||
ID1805 = "Hardcore Rap"
|
||||
ID1806 = "Hip-Hop"
|
||||
ID2023 = "Khaleeji Hip-Hop"
|
||||
ID1647 = "Korean Hip-Hop"
|
||||
ID1807 = "Latin Rap"
|
||||
ID2020 = "Levant Hip-Hop"
|
||||
ID2022 = "Maghreb Hip-Hop"
|
||||
ID1808 = "Old School Rap"
|
||||
ID1809 = "Rap"
|
||||
ID2005 = "Hip-Hop in Russian"
|
||||
ID1999 = "Turkish Hip-Hop/Rap"
|
||||
ID1943 = "UK Hip-Hop"
|
||||
ID1810 = "Underground Rap"
|
||||
ID1811 = "West Coast Rap"
|
||||
ID1608 = "Holiday"
|
||||
ID1812 = "Chanukah"
|
||||
ID1813 = "Christmas"
|
||||
ID1814 = "Christmas: Children's"
|
||||
ID1815 = "Christmas: Classic"
|
||||
ID1816 = "Christmas: Classical"
|
||||
ID2039 = "Christmas: Country"
|
||||
ID1817 = "Christmas: Jazz"
|
||||
ID1818 = "Christmas: Modern"
|
||||
ID1819 = "Christmas: Pop"
|
||||
ID1820 = "Christmas: R&B"
|
||||
ID1821 = "Christmas: Religious"
|
||||
ID1822 = "Christmas: Rock"
|
||||
ID1823 = "Easter"
|
||||
ID1824 = "Halloween"
|
||||
ID1825 = "Thanksgiving"
|
||||
ID2040 = "Hörspiele"
|
||||
ID1690 = "Indian"
|
||||
ID1691 = "Bollywood"
|
||||
ID1695 = "Devotional & Spiritual"
|
||||
ID1707 = "Ghazals"
|
||||
ID1697 = "Indian Classical"
|
||||
ID1974 = "Carnatic Classical"
|
||||
ID1975 = "Hindustani Classical"
|
||||
ID1708 = "Indian Folk"
|
||||
ID1704 = "Indian Pop"
|
||||
ID1694 = "Regional Indian"
|
||||
ID1966 = "Assamese"
|
||||
ID1973 = "Bengali"
|
||||
ID1961 = "Rabindra Sangeet"
|
||||
ID1967 = "Bhojpuri"
|
||||
ID1965 = "Gujarati"
|
||||
ID1968 = "Haryanvi"
|
||||
ID1963 = "Kannada"
|
||||
ID1962 = "Malayalam"
|
||||
ID1964 = "Marathi"
|
||||
ID1969 = "Odia"
|
||||
ID1972 = "Punjabi"
|
||||
ID1960 = "Punjabi Pop"
|
||||
ID1970 = "Rajasthani"
|
||||
ID1692 = "Tamil"
|
||||
ID1693 = "Telugu"
|
||||
ID1971 = "Urdu"
|
||||
ID1696 = "Sufi"
|
||||
ID1926 = "Inspirational"
|
||||
ID1684 = "Instrumental"
|
||||
ID1627 = "J-Pop"
|
||||
ID1611 = "Jazz"
|
||||
ID1826 = "Avant-Garde Jazz"
|
||||
ID1828 = "Bop"
|
||||
ID1685 = "Big Band"
|
||||
ID1829 = "Contemporary Jazz"
|
||||
ID1830 = "Cool Jazz"
|
||||
ID1831 = "Crossover Jazz"
|
||||
ID1832 = "Dixieland"
|
||||
ID1833 = "Fusion"
|
||||
ID1834 = "Hard Bop"
|
||||
ID1835 = "Latin Jazz"
|
||||
ID1836 = "Mainstream Jazz"
|
||||
ID1837 = "Ragtime"
|
||||
ID1838 = "Smooth Jazz"
|
||||
ID1839 = "Trad Jazz"
|
||||
ID1902 = "Vocal Jazz"
|
||||
ID1687 = "Karaoke"
|
||||
ID1630 = "Kayokyoku"
|
||||
ID1648 = "Korean"
|
||||
ID1651 = "Korean Trad Instrumental"
|
||||
ID1650 = "Korean Trad Song"
|
||||
ID1652 = "Korean Trad Theater"
|
||||
ID1649 = "Korean Classical"
|
||||
ID1612 = "Latin"
|
||||
ID1840 = "Alternative & Rock in Spanish"
|
||||
ID1841 = "Baladas y Boleros"
|
||||
ID1842 = "Contemporary Latin"
|
||||
ID1843 = "Latin Jazz"
|
||||
ID1844 = "Latin Urban"
|
||||
ID1845 = "Pop in Spanish"
|
||||
ID1846 = "Raices"
|
||||
ID1847 = "Música Mexicana"
|
||||
ID1848 = "Salsa y Tropical"
|
||||
ID1721 = "Marching"
|
||||
ID1613 = "New Age"
|
||||
ID1849 = "Healing"
|
||||
ID1850 = "Meditation"
|
||||
ID1851 = "Nature"
|
||||
ID1852 = "Relaxation"
|
||||
ID1853 = "Travel"
|
||||
ID1948 = "Yoga"
|
||||
ID1720 = "Orchestral"
|
||||
ID1626 = "Podcasts"
|
||||
ID1614 = "Pop"
|
||||
ID1854 = "Adult Contemporary"
|
||||
ID1855 = "Britpop"
|
||||
ID1655 = "C-Pop"
|
||||
ID1656 = "Cantopop/HK-Pop"
|
||||
ID2017 = "Egyptian Pop"
|
||||
ID1664 = "Indo Pop"
|
||||
ID2016 = "Iraqi Pop"
|
||||
ID1686 = "K-Pop"
|
||||
ID2019 = "Khaleeji Pop"
|
||||
ID1657 = "Korean Folk-Pop"
|
||||
ID2015 = "Levant Pop"
|
||||
ID2018 = "Maghreb Pop"
|
||||
ID1660 = "Malaysian Pop"
|
||||
ID1658 = "Mandopop"
|
||||
ID1663 = "Manilla Sound"
|
||||
ID1723 = "Oldies"
|
||||
ID1662 = "Original Pilipino Music"
|
||||
ID1661 = "Pinoy Pop"
|
||||
ID1856 = "Pop/Rock"
|
||||
ID2003 = "Pop in Russian"
|
||||
ID1950 = "Shows"
|
||||
ID1857 = "Soft Rock"
|
||||
ID1659 = "Tai-Pop"
|
||||
ID1858 = "Teen Pop"
|
||||
ID1665 = "Thai Pop"
|
||||
ID1949 = "Tribute"
|
||||
ID1996 = "Turkish Pop"
|
||||
ID1615 = "R&B/Soul"
|
||||
ID1859 = "Contemporary R&B"
|
||||
ID1860 = "Disco"
|
||||
ID1861 = "Doo Wop"
|
||||
ID1862 = "Funk"
|
||||
ID1863 = "Motown"
|
||||
ID1864 = "Neo-Soul"
|
||||
ID1865 = "Soul"
|
||||
ID1624 = "Reggae"
|
||||
ID1867 = "Dub"
|
||||
ID1944 = "Lovers Rock"
|
||||
ID1866 = "Modern Dancehall"
|
||||
ID1868 = "Roots Reggae"
|
||||
ID1869 = "Ska"
|
||||
ID1621 = "Rock"
|
||||
ID1870 = "Adult Alternative"
|
||||
ID1871 = "American Trad Rock"
|
||||
ID1872 = "Arena Rock"
|
||||
ID1873 = "Blues-Rock"
|
||||
ID1874 = "British Invasion"
|
||||
ID1653 = "Chinese Rock"
|
||||
ID1875 = "Death Metal/Black Metal"
|
||||
ID1876 = "Glam Rock"
|
||||
ID1877 = "Hair Metal"
|
||||
ID1878 = "Hard Rock"
|
||||
ID1688 = "Heavy Metal"
|
||||
ID1879 = "Jam Bands"
|
||||
ID1654 = "Korean Rock"
|
||||
ID1880 = "Prog-Rock/Art Rock"
|
||||
ID1881 = "Psychedelic"
|
||||
ID1882 = "Rock & Roll"
|
||||
ID1883 = "Rockabilly"
|
||||
ID1884 = "Roots Rock"
|
||||
ID2004 = "Rock in Russian"
|
||||
ID1885 = "Singer/Songwriter"
|
||||
ID1886 = "Southern Rock"
|
||||
ID1887 = "Surf"
|
||||
ID1888 = "Tex-Mex"
|
||||
ID1997 = "Turkish Rock"
|
||||
ID1729 = "Russian"
|
||||
ID2002 = "Bard in Russian"
|
||||
ID1698 = "Chanson in Russian"
|
||||
ID2001 = "Romance in Russian"
|
||||
ID1610 = "Singer/Songwriter"
|
||||
ID1889 = "Alternative Folk"
|
||||
ID1890 = "Contemporary Folk"
|
||||
ID1891 = "Contemporary Singer/Songwriter"
|
||||
ID1892 = "Folk-Rock"
|
||||
ID1893 = "New Acoustic"
|
||||
ID1894 = "Traditional Folk"
|
||||
ID1616 = "Soundtrack"
|
||||
ID1895 = "Foreign Cinema"
|
||||
ID1896 = "Musicals"
|
||||
ID1897 = "Original Score"
|
||||
ID1718 = "Sound Effects"
|
||||
ID1898 = "Soundtrack"
|
||||
ID1899 = "TV Soundtrack"
|
||||
ID1959 = "Video Game"
|
||||
ID1689 = "Spoken Word"
|
||||
ID2011 = "Tarab"
|
||||
ID2013 = "Egyptian Tarab"
|
||||
ID2012 = "Iraqi Tarab"
|
||||
ID2014 = "Khaleeji Tarab"
|
||||
ID1730 = "Turkish"
|
||||
ID1709 = "Arabesque"
|
||||
ID1994 = "Fantezi"
|
||||
ID1700 = "Halk"
|
||||
ID1995 = "Religious"
|
||||
ID1701 = "Sanat"
|
||||
ID1993 = "Özgün"
|
||||
ID1623 = "Vocal"
|
||||
ID1900 = "Standards"
|
||||
ID1901 = "Traditional Pop"
|
||||
ID1666 = "Trot"
|
||||
ID1903 = "Vocal Pop"
|
||||
ID1619 = "World"
|
||||
ID1907 = "Asia"
|
||||
ID1908 = "Australia"
|
||||
ID1909 = "Cajun"
|
||||
ID1705 = "Calypso"
|
||||
ID1910 = "Caribbean"
|
||||
ID1911 = "Celtic"
|
||||
ID1912 = "Celtic Folk"
|
||||
ID1913 = "Contemporary Celtic"
|
||||
ID1702 = "Dangdut"
|
||||
ID1699 = "Dini"
|
||||
ID1914 = "Europe"
|
||||
ID1727 = "Fado"
|
||||
ID1711 = "Farsi"
|
||||
ID1725 = "Flamenco"
|
||||
ID1915 = "France"
|
||||
ID1916 = "Hawaii"
|
||||
ID1728 = "Iberia"
|
||||
ID1703 = "Indonesian Religious"
|
||||
ID1712 = "Israeli"
|
||||
ID1917 = "Japan"
|
||||
ID1918 = "Klezmer"
|
||||
ID1919 = "North America"
|
||||
ID1920 = "Polka"
|
||||
ID1706 = "Soca"
|
||||
ID1921 = "South Africa"
|
||||
ID1922 = "South America"
|
||||
ID1726 = "Tango"
|
||||
ID1923 = "Traditional Celtic"
|
||||
ID1924 = "Worldbeat"
|
||||
ID1925 = "Zydeco"
|
||||
@@ -0,0 +1,362 @@
|
||||
import base64
|
||||
|
||||
import os
|
||||
import time
|
||||
import binascii
|
||||
|
||||
from google.protobuf.message import DecodeError
|
||||
from google.protobuf import text_format
|
||||
|
||||
from pywidevine.L3.cdm.formats import wv_proto2_pb2 as wv_proto2
|
||||
from pywidevine.L3.cdm.session import Session
|
||||
from pywidevine.L3.cdm.key import Key
|
||||
from Cryptodome.Random import get_random_bytes
|
||||
from Cryptodome.Random import random
|
||||
from Cryptodome.Cipher import PKCS1_OAEP, AES
|
||||
from Cryptodome.Hash import CMAC, SHA256, HMAC, SHA1
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from Cryptodome.Signature import pss
|
||||
from Cryptodome.Util import Padding
|
||||
import logging
|
||||
|
||||
class Cdm:
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.sessions = {}
|
||||
|
||||
def open_session(self, init_data_b64, device, raw_init_data = None, offline=False):
|
||||
self.logger.debug("open_session(init_data_b64={}, device={}".format(init_data_b64, device))
|
||||
self.logger.info("opening new cdm session")
|
||||
if device.session_id_type == 'android':
|
||||
# format: 16 random hexdigits, 2 digit counter, 14 0s
|
||||
rand_ascii = ''.join(random.choice('ABCDEF0123456789') for _ in range(16))
|
||||
counter = '01' # this resets regularly so its fine to use 01
|
||||
rest = '00000000000000'
|
||||
session_id = rand_ascii + counter + rest
|
||||
session_id = session_id.encode('ascii')
|
||||
elif device.session_id_type == 'chrome':
|
||||
rand_bytes = get_random_bytes(16)
|
||||
session_id = rand_bytes
|
||||
else:
|
||||
# other formats NYI
|
||||
self.logger.error("device type is unusable")
|
||||
return 1
|
||||
if raw_init_data and isinstance(raw_init_data, (bytes, bytearray)):
|
||||
# used for NF key exchange, where they don't provide a valid PSSH
|
||||
init_data = raw_init_data
|
||||
self.raw_pssh = True
|
||||
else:
|
||||
init_data = self._parse_init_data(init_data_b64)
|
||||
self.raw_pssh = False
|
||||
|
||||
if init_data:
|
||||
new_session = Session(session_id, init_data, device, offline)
|
||||
else:
|
||||
self.logger.error("unable to parse init data")
|
||||
return 1
|
||||
self.sessions[session_id] = new_session
|
||||
self.logger.info("session opened and init data parsed successfully")
|
||||
return session_id
|
||||
|
||||
def _parse_init_data(self, init_data_b64):
|
||||
parsed_init_data = wv_proto2.WidevineCencHeader()
|
||||
try:
|
||||
self.logger.debug("trying to parse init_data directly")
|
||||
parsed_init_data.ParseFromString(base64.b64decode(init_data_b64)[32:])
|
||||
except DecodeError:
|
||||
self.logger.debug("unable to parse as-is, trying with removed pssh box header")
|
||||
try:
|
||||
id_bytes = parsed_init_data.ParseFromString(base64.b64decode(init_data_b64)[32:])
|
||||
except DecodeError:
|
||||
self.logger.error("unable to parse, unsupported init data format")
|
||||
return None
|
||||
self.logger.debug("init_data:")
|
||||
for line in text_format.MessageToString(parsed_init_data).splitlines():
|
||||
self.logger.debug(line)
|
||||
return parsed_init_data
|
||||
|
||||
def close_session(self, session_id):
|
||||
self.logger.debug("close_session(session_id={})".format(session_id))
|
||||
self.logger.info("closing cdm session")
|
||||
if session_id in self.sessions:
|
||||
self.sessions.pop(session_id)
|
||||
self.logger.info("cdm session closed")
|
||||
return 0
|
||||
else:
|
||||
self.logger.info("session {} not found".format(session_id))
|
||||
return 1
|
||||
|
||||
def set_service_certificate(self, session_id, cert_b64):
|
||||
self.logger.debug("set_service_certificate(session_id={}, cert={})".format(session_id, cert_b64))
|
||||
self.logger.info("setting service certificate")
|
||||
|
||||
if session_id not in self.sessions:
|
||||
self.logger.error("session id doesn't exist")
|
||||
return 1
|
||||
|
||||
session = self.sessions[session_id]
|
||||
|
||||
message = wv_proto2.SignedMessage()
|
||||
|
||||
try:
|
||||
message.ParseFromString(base64.b64decode(cert_b64))
|
||||
except DecodeError:
|
||||
self.logger.error("failed to parse cert as SignedMessage")
|
||||
|
||||
service_certificate = wv_proto2.SignedDeviceCertificate()
|
||||
|
||||
if message.Type:
|
||||
self.logger.debug("service cert provided as signedmessage")
|
||||
try:
|
||||
service_certificate.ParseFromString(message.Msg)
|
||||
except DecodeError:
|
||||
self.logger.error("failed to parse service certificate")
|
||||
return 1
|
||||
else:
|
||||
self.logger.debug("service cert provided as signeddevicecertificate")
|
||||
try:
|
||||
service_certificate.ParseFromString(base64.b64decode(cert_b64))
|
||||
except DecodeError:
|
||||
self.logger.error("failed to parse service certificate")
|
||||
return 1
|
||||
|
||||
self.logger.debug("service certificate:")
|
||||
for line in text_format.MessageToString(service_certificate).splitlines():
|
||||
self.logger.debug(line)
|
||||
|
||||
session.service_certificate = service_certificate
|
||||
session.privacy_mode = True
|
||||
|
||||
return 0
|
||||
|
||||
def get_license_request(self, session_id):
|
||||
self.logger.debug("get_license_request(session_id={})".format(session_id))
|
||||
self.logger.info("getting license request")
|
||||
|
||||
if session_id not in self.sessions:
|
||||
self.logger.error("session ID does not exist")
|
||||
return 1
|
||||
|
||||
session = self.sessions[session_id]
|
||||
|
||||
# raw pssh will be treated as bytes and not parsed
|
||||
if self.raw_pssh:
|
||||
license_request = wv_proto2.SignedLicenseRequestRaw()
|
||||
else:
|
||||
license_request = wv_proto2.SignedLicenseRequest()
|
||||
client_id = wv_proto2.ClientIdentification()
|
||||
|
||||
if not os.path.exists(session.device_config.device_client_id_blob_filename):
|
||||
self.logger.error("no client ID blob available for this device")
|
||||
return 1
|
||||
|
||||
with open(session.device_config.device_client_id_blob_filename, "rb") as f:
|
||||
try:
|
||||
cid_bytes = client_id.ParseFromString(f.read())
|
||||
except DecodeError:
|
||||
self.logger.error("client id failed to parse as protobuf")
|
||||
return 1
|
||||
|
||||
self.logger.debug("building license request")
|
||||
if not self.raw_pssh:
|
||||
license_request.Type = wv_proto2.SignedLicenseRequest.MessageType.Value('LICENSE_REQUEST')
|
||||
license_request.Msg.ContentId.CencId.Pssh.CopyFrom(session.init_data)
|
||||
else:
|
||||
license_request.Type = wv_proto2.SignedLicenseRequestRaw.MessageType.Value('LICENSE_REQUEST')
|
||||
license_request.Msg.ContentId.CencId.Pssh = session.init_data # bytes
|
||||
|
||||
if session.offline:
|
||||
license_type = wv_proto2.LicenseType.Value('OFFLINE')
|
||||
else:
|
||||
license_type = wv_proto2.LicenseType.Value('DEFAULT')
|
||||
license_request.Msg.ContentId.CencId.LicenseType = license_type
|
||||
license_request.Msg.ContentId.CencId.RequestId = session_id
|
||||
license_request.Msg.Type = wv_proto2.LicenseRequest.RequestType.Value('NEW')
|
||||
license_request.Msg.RequestTime = int(time.time())
|
||||
license_request.Msg.ProtocolVersion = wv_proto2.ProtocolVersion.Value('CURRENT')
|
||||
if session.device_config.send_key_control_nonce:
|
||||
license_request.Msg.KeyControlNonce = random.randrange(1, 2**31)
|
||||
|
||||
if session.privacy_mode:
|
||||
if session.device_config.vmp:
|
||||
self.logger.debug("vmp required, adding to client_id")
|
||||
self.logger.debug("reading vmp hashes")
|
||||
vmp_hashes = wv_proto2.FileHashes()
|
||||
with open(session.device_config.device_vmp_blob_filename, "rb") as f:
|
||||
try:
|
||||
vmp_bytes = vmp_hashes.ParseFromString(f.read())
|
||||
except DecodeError:
|
||||
self.logger.error("vmp hashes failed to parse as protobuf")
|
||||
return 1
|
||||
client_id._FileHashes.CopyFrom(vmp_hashes)
|
||||
self.logger.debug("privacy mode & service certificate loaded, encrypting client id")
|
||||
self.logger.debug("unencrypted client id:")
|
||||
for line in text_format.MessageToString(client_id).splitlines():
|
||||
self.logger.debug(line)
|
||||
cid_aes_key = get_random_bytes(16)
|
||||
cid_iv = get_random_bytes(16)
|
||||
|
||||
cid_cipher = AES.new(cid_aes_key, AES.MODE_CBC, cid_iv)
|
||||
|
||||
encrypted_client_id = cid_cipher.encrypt(Padding.pad(client_id.SerializeToString(), 16))
|
||||
|
||||
service_public_key = RSA.importKey(session.service_certificate._DeviceCertificate.PublicKey)
|
||||
|
||||
service_cipher = PKCS1_OAEP.new(service_public_key)
|
||||
|
||||
encrypted_cid_key = service_cipher.encrypt(cid_aes_key)
|
||||
|
||||
encrypted_client_id_proto = wv_proto2.EncryptedClientIdentification()
|
||||
|
||||
encrypted_client_id_proto.ServiceId = session.service_certificate._DeviceCertificate.ServiceId
|
||||
encrypted_client_id_proto.ServiceCertificateSerialNumber = session.service_certificate._DeviceCertificate.SerialNumber
|
||||
encrypted_client_id_proto.EncryptedClientId = encrypted_client_id
|
||||
encrypted_client_id_proto.EncryptedClientIdIv = cid_iv
|
||||
encrypted_client_id_proto.EncryptedPrivacyKey = encrypted_cid_key
|
||||
|
||||
license_request.Msg.EncryptedClientId.CopyFrom(encrypted_client_id_proto)
|
||||
else:
|
||||
license_request.Msg.ClientId.CopyFrom(client_id)
|
||||
|
||||
if session.device_config.private_key_available:
|
||||
key = RSA.importKey(open(session.device_config.device_private_key_filename).read())
|
||||
session.device_key = key
|
||||
else:
|
||||
self.logger.error("need device private key, other methods unimplemented")
|
||||
return 1
|
||||
|
||||
self.logger.debug("signing license request")
|
||||
|
||||
hash = SHA1.new(license_request.Msg.SerializeToString())
|
||||
signature = pss.new(key).sign(hash)
|
||||
|
||||
license_request.Signature = signature
|
||||
|
||||
session.license_request = license_request
|
||||
|
||||
self.logger.debug("license request:")
|
||||
for line in text_format.MessageToString(session.license_request).splitlines():
|
||||
self.logger.debug(line)
|
||||
self.logger.info("license request created")
|
||||
self.logger.debug("license request b64: {}".format(base64.b64encode(license_request.SerializeToString())))
|
||||
return license_request.SerializeToString()
|
||||
|
||||
def provide_license(self, session_id, license_b64):
|
||||
self.logger.debug("provide_license(session_id={}, license_b64={})".format(session_id, license_b64))
|
||||
self.logger.info("decrypting provided license")
|
||||
|
||||
if session_id not in self.sessions:
|
||||
self.logger.error("session does not exist")
|
||||
return 1
|
||||
|
||||
session = self.sessions[session_id]
|
||||
|
||||
if not session.license_request:
|
||||
self.logger.error("generate a license request first!")
|
||||
return 1
|
||||
|
||||
license = wv_proto2.SignedLicense()
|
||||
try:
|
||||
license.ParseFromString(base64.b64decode(license_b64))
|
||||
except DecodeError:
|
||||
self.logger.error("unable to parse license - check protobufs")
|
||||
return 1
|
||||
|
||||
session.license = license
|
||||
|
||||
self.logger.debug("license:")
|
||||
for line in text_format.MessageToString(license).splitlines():
|
||||
self.logger.debug(line)
|
||||
|
||||
self.logger.debug("deriving keys from session key")
|
||||
|
||||
oaep_cipher = PKCS1_OAEP.new(session.device_key)
|
||||
|
||||
session.session_key = oaep_cipher.decrypt(license.SessionKey)
|
||||
|
||||
lic_req_msg = session.license_request.Msg.SerializeToString()
|
||||
|
||||
enc_key_base = b"ENCRYPTION\000" + lic_req_msg + b"\0\0\0\x80"
|
||||
auth_key_base = b"AUTHENTICATION\0" + lic_req_msg + b"\0\0\2\0"
|
||||
|
||||
enc_key = b"\x01" + enc_key_base
|
||||
auth_key_1 = b"\x01" + auth_key_base
|
||||
auth_key_2 = b"\x02" + auth_key_base
|
||||
auth_key_3 = b"\x03" + auth_key_base
|
||||
auth_key_4 = b"\x04" + auth_key_base
|
||||
|
||||
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||
cmac_obj.update(enc_key)
|
||||
|
||||
enc_cmac_key = cmac_obj.digest()
|
||||
|
||||
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||
cmac_obj.update(auth_key_1)
|
||||
auth_cmac_key_1 = cmac_obj.digest()
|
||||
|
||||
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||
cmac_obj.update(auth_key_2)
|
||||
auth_cmac_key_2 = cmac_obj.digest()
|
||||
|
||||
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||
cmac_obj.update(auth_key_3)
|
||||
auth_cmac_key_3 = cmac_obj.digest()
|
||||
|
||||
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||
cmac_obj.update(auth_key_4)
|
||||
auth_cmac_key_4 = cmac_obj.digest()
|
||||
|
||||
auth_cmac_combined_1 = auth_cmac_key_1 + auth_cmac_key_2
|
||||
auth_cmac_combined_2 = auth_cmac_key_3 + auth_cmac_key_4
|
||||
|
||||
session.derived_keys['enc'] = enc_cmac_key
|
||||
session.derived_keys['auth_1'] = auth_cmac_combined_1
|
||||
session.derived_keys['auth_2'] = auth_cmac_combined_2
|
||||
|
||||
self.logger.debug('verifying license signature')
|
||||
|
||||
lic_hmac = HMAC.new(session.derived_keys['auth_1'], digestmod=SHA256)
|
||||
lic_hmac.update(license.Msg.SerializeToString())
|
||||
|
||||
self.logger.debug("calculated sig: {} actual sig: {}".format(lic_hmac.hexdigest(), binascii.hexlify(license.Signature)))
|
||||
|
||||
if lic_hmac.digest() != license.Signature:
|
||||
self.logger.info("license signature doesn't match - writing bin so they can be debugged")
|
||||
with open("original_lic.bin", "wb") as f:
|
||||
f.write(base64.b64decode(license_b64))
|
||||
with open("parsed_lic.bin", "wb") as f:
|
||||
f.write(license.SerializeToString())
|
||||
self.logger.info("continuing anyway")
|
||||
|
||||
self.logger.debug("key count: {}".format(len(license.Msg.Key)))
|
||||
for key in license.Msg.Key:
|
||||
if key.Id:
|
||||
key_id = key.Id
|
||||
else:
|
||||
key_id = wv_proto2.License.KeyContainer.KeyType.Name(key.Type).encode('utf-8')
|
||||
encrypted_key = key.Key
|
||||
iv = key.Iv
|
||||
type = wv_proto2.License.KeyContainer.KeyType.Name(key.Type)
|
||||
|
||||
cipher = AES.new(session.derived_keys['enc'], AES.MODE_CBC, iv=iv)
|
||||
decrypted_key = cipher.decrypt(encrypted_key)
|
||||
if type == "OPERATOR_SESSION":
|
||||
permissions = []
|
||||
perms = key._OperatorSessionKeyPermissions
|
||||
for (descriptor, value) in perms.ListFields():
|
||||
if value == 1:
|
||||
permissions.append(descriptor.name)
|
||||
print(permissions)
|
||||
else:
|
||||
permissions = []
|
||||
session.keys.append(Key(key_id, type, Padding.unpad(decrypted_key, 16), permissions))
|
||||
|
||||
self.logger.info("decrypted all keys")
|
||||
return 0
|
||||
|
||||
def get_keys(self, session_id):
|
||||
if session_id in self.sessions:
|
||||
return self.sessions[session_id].keys
|
||||
else:
|
||||
self.logger.error("session not found")
|
||||
return 1
|
||||
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
|
||||
device_android_generic = {
|
||||
'name': 'android_generic',
|
||||
'description': 'android studio cdm',
|
||||
'security_level': 3,
|
||||
'session_id_type': 'android',
|
||||
'private_key_available': True,
|
||||
'vmp': False,
|
||||
'send_key_control_nonce': True
|
||||
}
|
||||
|
||||
devices_available = [device_android_generic]
|
||||
|
||||
FILES_FOLDER = 'devices'
|
||||
|
||||
class DeviceConfig:
|
||||
def __init__(self, device):
|
||||
self.device_name = device['name']
|
||||
self.description = device['description']
|
||||
self.security_level = device['security_level']
|
||||
self.session_id_type = device['session_id_type']
|
||||
self.private_key_available = device['private_key_available']
|
||||
self.vmp = device['vmp']
|
||||
self.send_key_control_nonce = device['send_key_control_nonce']
|
||||
|
||||
if 'keybox_filename' in device:
|
||||
self.keybox_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['keybox_filename'])
|
||||
else:
|
||||
self.keybox_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'keybox')
|
||||
|
||||
if 'device_cert_filename' in device:
|
||||
self.device_cert_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_cert_filename'])
|
||||
else:
|
||||
self.device_cert_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_cert')
|
||||
|
||||
if 'device_private_key_filename' in device:
|
||||
self.device_private_key_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_private_key_filename'])
|
||||
else:
|
||||
self.device_private_key_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_private_key')
|
||||
|
||||
if 'device_client_id_blob_filename' in device:
|
||||
self.device_client_id_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_client_id_blob_filename'])
|
||||
else:
|
||||
self.device_client_id_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_client_id_blob')
|
||||
|
||||
if 'device_vmp_blob_filename' in device:
|
||||
self.device_vmp_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_vmp_blob_filename'])
|
||||
else:
|
||||
self.device_vmp_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_vmp_blob')
|
||||
|
||||
def __repr__(self):
|
||||
return "DeviceConfig(name={}, description={}, security_level={}, session_id_type={}, private_key_available={}, vmp={})".format(self.device_name, self.description, self.security_level, self.session_id_type, self.private_key_available, self.vmp)
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2016 Google Inc. All rights reserved.
|
||||
//
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file or at
|
||||
// https://developers.google.com/open-source/licenses/bsd
|
||||
//
|
||||
// This file defines Widevine Pssh Data proto format.
|
||||
|
||||
syntax = "proto2";
|
||||
|
||||
package shaka.media;
|
||||
|
||||
message WidevinePsshData {
|
||||
enum Algorithm {
|
||||
UNENCRYPTED = 0;
|
||||
AESCTR = 1;
|
||||
};
|
||||
optional Algorithm algorithm = 1;
|
||||
repeated bytes key_id = 2;
|
||||
|
||||
// Content provider name.
|
||||
optional string provider = 3;
|
||||
|
||||
// A content identifier, specified by content provider.
|
||||
optional bytes content_id = 4;
|
||||
|
||||
// The name of a registered policy to be used for this asset.
|
||||
optional string policy = 6;
|
||||
|
||||
// Crypto period index, for media using key rotation.
|
||||
optional uint32 crypto_period_index = 7;
|
||||
|
||||
// Optional protected context for group content. The grouped_license is a
|
||||
// serialized SignedMessage.
|
||||
optional bytes grouped_license = 8;
|
||||
|
||||
// Protection scheme identifying the encryption algorithm. Represented as one
|
||||
// of the following 4CC values: 'cenc' (AES-CTR), 'cbc1' (AES-CBC),
|
||||
// 'cens' (AES-CTR subsample), 'cbcs' (AES-CBC subsample).
|
||||
optional uint32 protection_scheme = 9;
|
||||
}
|
||||
|
||||
// Derived from WidevinePsshData. The JSON format of this proto is used in
|
||||
// Widevine HLS DRM signaling v1.
|
||||
// We cannot build JSON from WidevinePsshData as |key_id| is required to be in
|
||||
// hex format, while |bytes| type is translated to base64 by JSON formatter, so
|
||||
// we have to use |string| type and do hex conversion in the code.
|
||||
message WidevineHeader {
|
||||
repeated string key_ids = 2;
|
||||
|
||||
// Content provider name.
|
||||
optional string provider = 3;
|
||||
|
||||
// A content identifier, specified by content provider.
|
||||
optional bytes content_id = 4;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: widevine_pssh_data.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import message as _message
|
||||
from google.protobuf import reflection as _reflection
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18widevine_pssh_data.proto\x12\x0bshaka.media\"\x8f\x02\n\x10WidevinePsshData\x12:\n\talgorithm\x18\x01 \x01(\x0e\x32\'.shaka.media.WidevinePsshData.Algorithm\x12\x0e\n\x06key_id\x18\x02 \x03(\x0c\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x12\n\ncontent_id\x18\x04 \x01(\x0c\x12\x0e\n\x06policy\x18\x06 \x01(\t\x12\x1b\n\x13\x63rypto_period_index\x18\x07 \x01(\r\x12\x17\n\x0fgrouped_license\x18\x08 \x01(\x0c\x12\x19\n\x11protection_scheme\x18\t \x01(\r\"(\n\tAlgorithm\x12\x0f\n\x0bUNENCRYPTED\x10\x00\x12\n\n\x06\x41\x45SCTR\x10\x01\"G\n\x0eWidevineHeader\x12\x0f\n\x07key_ids\x18\x02 \x03(\t\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x12\n\ncontent_id\x18\x04 \x01(\x0c')
|
||||
|
||||
|
||||
|
||||
_WIDEVINEPSSHDATA = DESCRIPTOR.message_types_by_name['WidevinePsshData']
|
||||
_WIDEVINEHEADER = DESCRIPTOR.message_types_by_name['WidevineHeader']
|
||||
_WIDEVINEPSSHDATA_ALGORITHM = _WIDEVINEPSSHDATA.enum_types_by_name['Algorithm']
|
||||
WidevinePsshData = _reflection.GeneratedProtocolMessageType('WidevinePsshData', (_message.Message,), {
|
||||
'DESCRIPTOR' : _WIDEVINEPSSHDATA,
|
||||
'__module__' : 'widevine_pssh_data_pb2'
|
||||
# @@protoc_insertion_point(class_scope:shaka.media.WidevinePsshData)
|
||||
})
|
||||
_sym_db.RegisterMessage(WidevinePsshData)
|
||||
|
||||
WidevineHeader = _reflection.GeneratedProtocolMessageType('WidevineHeader', (_message.Message,), {
|
||||
'DESCRIPTOR' : _WIDEVINEHEADER,
|
||||
'__module__' : 'widevine_pssh_data_pb2'
|
||||
# @@protoc_insertion_point(class_scope:shaka.media.WidevineHeader)
|
||||
})
|
||||
_sym_db.RegisterMessage(WidevineHeader)
|
||||
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
_WIDEVINEPSSHDATA._serialized_start=42
|
||||
_WIDEVINEPSSHDATA._serialized_end=313
|
||||
_WIDEVINEPSSHDATA_ALGORITHM._serialized_start=273
|
||||
_WIDEVINEPSSHDATA_ALGORITHM._serialized_end=313
|
||||
_WIDEVINEHEADER._serialized_start=315
|
||||
_WIDEVINEHEADER._serialized_end=386
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,466 @@
|
||||
syntax = "proto2";
|
||||
|
||||
// from x86 (partial), most of it from the ARM version:
|
||||
message ClientIdentification {
|
||||
enum TokenType {
|
||||
KEYBOX = 0;
|
||||
DEVICE_CERTIFICATE = 1;
|
||||
REMOTE_ATTESTATION_CERTIFICATE = 2;
|
||||
}
|
||||
message NameValue {
|
||||
required string Name = 1;
|
||||
required string Value = 2;
|
||||
}
|
||||
message ClientCapabilities {
|
||||
enum HdcpVersion {
|
||||
HDCP_NONE = 0;
|
||||
HDCP_V1 = 1;
|
||||
HDCP_V2 = 2;
|
||||
HDCP_V2_1 = 3;
|
||||
HDCP_V2_2 = 4;
|
||||
}
|
||||
optional uint32 ClientToken = 1;
|
||||
optional uint32 SessionToken = 2;
|
||||
optional uint32 VideoResolutionConstraints = 3;
|
||||
optional HdcpVersion MaxHdcpVersion = 4;
|
||||
optional uint32 OemCryptoApiVersion = 5;
|
||||
}
|
||||
required TokenType Type = 1;
|
||||
//optional bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one:
|
||||
optional SignedDeviceCertificate Token = 2; // use this when parsing, "bytes" when building a client id blob
|
||||
repeated NameValue ClientInfo = 3;
|
||||
optional bytes ProviderClientToken = 4;
|
||||
optional uint32 LicenseCounter = 5;
|
||||
optional ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later
|
||||
optional FileHashes _FileHashes = 7; // vmp blob goes here
|
||||
}
|
||||
|
||||
message DeviceCertificate {
|
||||
enum CertificateType {
|
||||
ROOT = 0;
|
||||
INTERMEDIATE = 1;
|
||||
USER_DEVICE = 2;
|
||||
SERVICE = 3;
|
||||
}
|
||||
required CertificateType Type = 1; // the compiled code reused this as ProvisionedDeviceInfo.WvSecurityLevel, however that is incorrect (compiler aliased it as they're both identical as a structure)
|
||||
optional bytes SerialNumber = 2;
|
||||
optional uint32 CreationTimeSeconds = 3;
|
||||
optional bytes PublicKey = 4;
|
||||
optional uint32 SystemId = 5;
|
||||
optional uint32 TestDeviceDeprecated = 6; // is it bool or int?
|
||||
optional bytes ServiceId = 7; // service URL for service certificates
|
||||
}
|
||||
|
||||
// missing some references,
|
||||
message DeviceCertificateStatus {
|
||||
enum CertificateStatus {
|
||||
VALID = 0;
|
||||
REVOKED = 1;
|
||||
}
|
||||
optional bytes SerialNumber = 1;
|
||||
optional CertificateStatus Status = 2;
|
||||
optional ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated?
|
||||
}
|
||||
|
||||
message DeviceCertificateStatusList {
|
||||
optional uint32 CreationTimeSeconds = 1;
|
||||
repeated DeviceCertificateStatus CertificateStatus = 2;
|
||||
}
|
||||
|
||||
message EncryptedClientIdentification {
|
||||
required string ServiceId = 1;
|
||||
optional bytes ServiceCertificateSerialNumber = 2;
|
||||
required bytes EncryptedClientId = 3;
|
||||
required bytes EncryptedClientIdIv = 4;
|
||||
required bytes EncryptedPrivacyKey = 5;
|
||||
}
|
||||
|
||||
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||
enum LicenseType {
|
||||
ZERO = 0;
|
||||
DEFAULT = 1; // 1 is STREAMING/temporary license; on recent versions may go up to 3 (latest x86); it might be persist/don't persist type, unconfirmed
|
||||
OFFLINE = 2;
|
||||
}
|
||||
|
||||
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||
// this is just a guess because these globals got lost, but really, do we need more?
|
||||
enum ProtocolVersion {
|
||||
CURRENT = 21; // don't have symbols for this
|
||||
}
|
||||
|
||||
|
||||
message LicenseIdentification {
|
||||
optional bytes RequestId = 1;
|
||||
optional bytes SessionId = 2;
|
||||
optional bytes PurchaseId = 3;
|
||||
optional LicenseType Type = 4;
|
||||
optional uint32 Version = 5;
|
||||
optional bytes ProviderSessionToken = 6;
|
||||
}
|
||||
|
||||
|
||||
message License {
|
||||
message Policy {
|
||||
optional bool CanPlay = 1; // changed from uint32 to bool
|
||||
optional bool CanPersist = 2;
|
||||
optional bool CanRenew = 3;
|
||||
optional uint32 RentalDurationSeconds = 4;
|
||||
optional uint32 PlaybackDurationSeconds = 5;
|
||||
optional uint32 LicenseDurationSeconds = 6;
|
||||
optional uint32 RenewalRecoveryDurationSeconds = 7;
|
||||
optional string RenewalServerUrl = 8;
|
||||
optional uint32 RenewalDelaySeconds = 9;
|
||||
optional uint32 RenewalRetryIntervalSeconds = 10;
|
||||
optional bool RenewWithUsage = 11; // was uint32
|
||||
}
|
||||
message KeyContainer {
|
||||
enum KeyType {
|
||||
SIGNING = 1;
|
||||
CONTENT = 2;
|
||||
KEY_CONTROL = 3;
|
||||
OPERATOR_SESSION = 4;
|
||||
}
|
||||
enum SecurityLevel {
|
||||
SW_SECURE_CRYPTO = 1;
|
||||
SW_SECURE_DECODE = 2;
|
||||
HW_SECURE_CRYPTO = 3;
|
||||
HW_SECURE_DECODE = 4;
|
||||
HW_SECURE_ALL = 5;
|
||||
}
|
||||
message OutputProtection {
|
||||
enum CGMS {
|
||||
COPY_FREE = 0;
|
||||
COPY_ONCE = 2;
|
||||
COPY_NEVER = 3;
|
||||
CGMS_NONE = 0x2A; // PC default!
|
||||
}
|
||||
optional ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away
|
||||
optional CGMS CgmsFlags = 2;
|
||||
}
|
||||
message KeyControl {
|
||||
required bytes KeyControlBlock = 1; // what is this?
|
||||
required bytes Iv = 2;
|
||||
}
|
||||
message OperatorSessionKeyPermissions {
|
||||
optional uint32 AllowEncrypt = 1;
|
||||
optional uint32 AllowDecrypt = 2;
|
||||
optional uint32 AllowSign = 3;
|
||||
optional uint32 AllowSignatureVerify = 4;
|
||||
}
|
||||
message VideoResolutionConstraint {
|
||||
optional uint32 MinResolutionPixels = 1;
|
||||
optional uint32 MaxResolutionPixels = 2;
|
||||
optional OutputProtection RequiredProtection = 3;
|
||||
}
|
||||
optional bytes Id = 1;
|
||||
optional bytes Iv = 2;
|
||||
optional bytes Key = 3;
|
||||
optional KeyType Type = 4;
|
||||
optional SecurityLevel Level = 5;
|
||||
optional OutputProtection RequiredProtection = 6;
|
||||
optional OutputProtection RequestedProtection = 7;
|
||||
optional KeyControl _KeyControl = 8; // duped names, etc
|
||||
optional OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc
|
||||
repeated VideoResolutionConstraint VideoResolutionConstraints = 10;
|
||||
}
|
||||
optional LicenseIdentification Id = 1;
|
||||
optional Policy _Policy = 2; // duped names, etc
|
||||
repeated KeyContainer Key = 3;
|
||||
optional uint32 LicenseStartTime = 4;
|
||||
optional uint32 RemoteAttestationVerified = 5; // bool?
|
||||
optional bytes ProviderClientToken = 6;
|
||||
// there might be more, check with newer versions (I see field 7-8 in a lic)
|
||||
// this appeared in latest x86:
|
||||
optional uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc)
|
||||
}
|
||||
|
||||
message LicenseError {
|
||||
enum Error {
|
||||
INVALID_DEVICE_CERTIFICATE = 1;
|
||||
REVOKED_DEVICE_CERTIFICATE = 2;
|
||||
SERVICE_UNAVAILABLE = 3;
|
||||
}
|
||||
//LicenseRequest.RequestType ErrorCode; // clang mismatch
|
||||
optional Error ErrorCode = 1;
|
||||
}
|
||||
|
||||
message LicenseRequest {
|
||||
message ContentIdentification {
|
||||
message CENC {
|
||||
//optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||
optional WidevineCencHeader Pssh = 1;
|
||||
optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!)
|
||||
optional bytes RequestId = 3;
|
||||
}
|
||||
message WebM {
|
||||
optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||
optional LicenseType LicenseType = 2;
|
||||
optional bytes RequestId = 3;
|
||||
}
|
||||
message ExistingLicense {
|
||||
optional LicenseIdentification LicenseId = 1;
|
||||
optional uint32 SecondsSinceStarted = 2;
|
||||
optional uint32 SecondsSinceLastPlayed = 3;
|
||||
optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB!
|
||||
}
|
||||
optional CENC CencId = 1;
|
||||
optional WebM WebmId = 2;
|
||||
optional ExistingLicense License = 3;
|
||||
}
|
||||
enum RequestType {
|
||||
NEW = 1;
|
||||
RENEWAL = 2;
|
||||
RELEASE = 3;
|
||||
}
|
||||
optional ClientIdentification ClientId = 1;
|
||||
optional ContentIdentification ContentId = 2;
|
||||
optional RequestType Type = 3;
|
||||
optional uint32 RequestTime = 4;
|
||||
optional bytes KeyControlNonceDeprecated = 5;
|
||||
optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||
optional uint32 KeyControlNonce = 7;
|
||||
optional EncryptedClientIdentification EncryptedClientId = 8;
|
||||
}
|
||||
|
||||
// raw pssh hack
|
||||
message LicenseRequestRaw {
|
||||
message ContentIdentification {
|
||||
message CENC {
|
||||
optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||
//optional WidevineCencHeader Pssh = 1;
|
||||
optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!)
|
||||
optional bytes RequestId = 3;
|
||||
}
|
||||
message WebM {
|
||||
optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||
optional LicenseType LicenseType = 2;
|
||||
optional bytes RequestId = 3;
|
||||
}
|
||||
message ExistingLicense {
|
||||
optional LicenseIdentification LicenseId = 1;
|
||||
optional uint32 SecondsSinceStarted = 2;
|
||||
optional uint32 SecondsSinceLastPlayed = 3;
|
||||
optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB!
|
||||
}
|
||||
optional CENC CencId = 1;
|
||||
optional WebM WebmId = 2;
|
||||
optional ExistingLicense License = 3;
|
||||
}
|
||||
enum RequestType {
|
||||
NEW = 1;
|
||||
RENEWAL = 2;
|
||||
RELEASE = 3;
|
||||
}
|
||||
optional ClientIdentification ClientId = 1;
|
||||
optional ContentIdentification ContentId = 2;
|
||||
optional RequestType Type = 3;
|
||||
optional uint32 RequestTime = 4;
|
||||
optional bytes KeyControlNonceDeprecated = 5;
|
||||
optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||
optional uint32 KeyControlNonce = 7;
|
||||
optional EncryptedClientIdentification EncryptedClientId = 8;
|
||||
}
|
||||
|
||||
|
||||
message ProvisionedDeviceInfo {
|
||||
enum WvSecurityLevel {
|
||||
LEVEL_UNSPECIFIED = 0;
|
||||
LEVEL_1 = 1;
|
||||
LEVEL_2 = 2;
|
||||
LEVEL_3 = 3;
|
||||
}
|
||||
optional uint32 SystemId = 1;
|
||||
optional string Soc = 2;
|
||||
optional string Manufacturer = 3;
|
||||
optional string Model = 4;
|
||||
optional string DeviceType = 5;
|
||||
optional uint32 ModelYear = 6;
|
||||
optional WvSecurityLevel SecurityLevel = 7;
|
||||
optional uint32 TestDevice = 8; // bool?
|
||||
}
|
||||
|
||||
|
||||
// todo: fill
|
||||
message ProvisioningOptions {
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message ProvisioningRequest {
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message ProvisioningResponse {
|
||||
}
|
||||
|
||||
message RemoteAttestation {
|
||||
optional EncryptedClientIdentification Certificate = 1;
|
||||
optional string Salt = 2;
|
||||
optional string Signature = 3;
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message SessionInit {
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message SessionState {
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message SignedCertificateStatusList {
|
||||
}
|
||||
|
||||
message SignedDeviceCertificate {
|
||||
|
||||
//optional bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is:
|
||||
optional DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later
|
||||
optional bytes Signature = 2;
|
||||
optional SignedDeviceCertificate Signer = 3;
|
||||
}
|
||||
|
||||
|
||||
// todo: fill
|
||||
message SignedProvisioningMessage {
|
||||
}
|
||||
|
||||
// the root of all messages, from either server or client
|
||||
message SignedMessage {
|
||||
enum MessageType {
|
||||
LICENSE_REQUEST = 1;
|
||||
LICENSE = 2;
|
||||
ERROR_RESPONSE = 3;
|
||||
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||
SERVICE_CERTIFICATE = 5;
|
||||
}
|
||||
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||
optional bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||
optional RemoteAttestation RemoteAttestation = 5;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// This message is copied from google's docs, not reversed:
|
||||
message WidevineCencHeader {
|
||||
enum Algorithm {
|
||||
UNENCRYPTED = 0;
|
||||
AESCTR = 1;
|
||||
};
|
||||
optional Algorithm algorithm = 1;
|
||||
repeated bytes key_id = 2;
|
||||
|
||||
// Content provider name.
|
||||
optional string provider = 3;
|
||||
|
||||
// A content identifier, specified by content provider.
|
||||
optional bytes content_id = 4;
|
||||
|
||||
// Track type. Acceptable values are SD, HD and AUDIO. Used to
|
||||
// differentiate content keys used by an asset.
|
||||
optional string track_type_deprecated = 5;
|
||||
|
||||
// The name of a registered policy to be used for this asset.
|
||||
optional string policy = 6;
|
||||
|
||||
// Crypto period index, for media using key rotation.
|
||||
optional uint32 crypto_period_index = 7;
|
||||
|
||||
// Optional protected context for group content. The grouped_license is a
|
||||
// serialized SignedMessage.
|
||||
optional bytes grouped_license = 8;
|
||||
|
||||
// Protection scheme identifying the encryption algorithm.
|
||||
// Represented as one of the following 4CC values:
|
||||
// 'cenc' (AESCTR), 'cbc1' (AESCBC),
|
||||
// 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample).
|
||||
optional uint32 protection_scheme = 9;
|
||||
|
||||
// Optional. For media using key rotation, this represents the duration
|
||||
// of each crypto period in seconds.
|
||||
optional uint32 crypto_period_seconds = 10;
|
||||
}
|
||||
|
||||
|
||||
// remove these when using it outside of protoc:
|
||||
|
||||
// from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically
|
||||
message SignedLicenseRequest {
|
||||
enum MessageType {
|
||||
LICENSE_REQUEST = 1;
|
||||
LICENSE = 2;
|
||||
ERROR_RESPONSE = 3;
|
||||
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||
SERVICE_CERTIFICATE = 5;
|
||||
}
|
||||
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||
optional LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||
optional RemoteAttestation RemoteAttestation = 5;
|
||||
}
|
||||
|
||||
// hack
|
||||
message SignedLicenseRequestRaw {
|
||||
enum MessageType {
|
||||
LICENSE_REQUEST = 1;
|
||||
LICENSE = 2;
|
||||
ERROR_RESPONSE = 3;
|
||||
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||
SERVICE_CERTIFICATE = 5;
|
||||
}
|
||||
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||
optional LicenseRequestRaw Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||
optional RemoteAttestation RemoteAttestation = 5;
|
||||
}
|
||||
|
||||
|
||||
message SignedLicense {
|
||||
enum MessageType {
|
||||
LICENSE_REQUEST = 1;
|
||||
LICENSE = 2;
|
||||
ERROR_RESPONSE = 3;
|
||||
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||
SERVICE_CERTIFICATE = 5;
|
||||
}
|
||||
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||
optional License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||
optional RemoteAttestation RemoteAttestation = 5;
|
||||
}
|
||||
|
||||
message SignedServiceCertificate {
|
||||
enum MessageType {
|
||||
LICENSE_REQUEST = 1;
|
||||
LICENSE = 2;
|
||||
ERROR_RESPONSE = 3;
|
||||
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||
SERVICE_CERTIFICATE = 5;
|
||||
}
|
||||
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||
optional SignedDeviceCertificate Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||
optional RemoteAttestation RemoteAttestation = 5;
|
||||
}
|
||||
|
||||
//vmp support
|
||||
message FileHashes {
|
||||
message Signature {
|
||||
optional string filename = 1;
|
||||
optional bool test_signing = 2; //0 - release, 1 - testing
|
||||
optional bytes SHA512Hash = 3;
|
||||
optional bool main_exe = 4; //0 for dlls, 1 for exe, this is field 3 in file
|
||||
optional bytes signature = 5;
|
||||
}
|
||||
optional bytes signer = 1;
|
||||
repeated Signature signatures = 2;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,389 @@
|
||||
// beware proto3 won't show missing fields it seems, need to change to "proto2" and add "optional" before every field, and remove all the dummy enum members I added:
|
||||
syntax = "proto3";
|
||||
|
||||
// from x86 (partial), most of it from the ARM version:
|
||||
message ClientIdentification {
|
||||
enum TokenType {
|
||||
KEYBOX = 0;
|
||||
DEVICE_CERTIFICATE = 1;
|
||||
REMOTE_ATTESTATION_CERTIFICATE = 2;
|
||||
}
|
||||
message NameValue {
|
||||
string Name = 1;
|
||||
string Value = 2;
|
||||
}
|
||||
message ClientCapabilities {
|
||||
enum HdcpVersion {
|
||||
HDCP_NONE = 0;
|
||||
HDCP_V1 = 1;
|
||||
HDCP_V2 = 2;
|
||||
HDCP_V2_1 = 3;
|
||||
HDCP_V2_2 = 4;
|
||||
}
|
||||
uint32 ClientToken = 1;
|
||||
uint32 SessionToken = 2;
|
||||
uint32 VideoResolutionConstraints = 3;
|
||||
HdcpVersion MaxHdcpVersion = 4;
|
||||
uint32 OemCryptoApiVersion = 5;
|
||||
}
|
||||
TokenType Type = 1;
|
||||
//bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one:
|
||||
SignedDeviceCertificate Token = 2;
|
||||
repeated NameValue ClientInfo = 3;
|
||||
bytes ProviderClientToken = 4;
|
||||
uint32 LicenseCounter = 5;
|
||||
ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later
|
||||
}
|
||||
|
||||
message DeviceCertificate {
|
||||
enum CertificateType {
|
||||
ROOT = 0;
|
||||
INTERMEDIATE = 1;
|
||||
USER_DEVICE = 2;
|
||||
SERVICE = 3;
|
||||
}
|
||||
//ProvisionedDeviceInfo.WvSecurityLevel Type = 1; // is this how one is supposed to call it? (it's an enum) there might be a bug here, with CertificateType getting confused with WvSecurityLevel, for now renaming it (verify against other binaries)
|
||||
CertificateType Type = 1;
|
||||
bytes SerialNumber = 2;
|
||||
uint32 CreationTimeSeconds = 3;
|
||||
bytes PublicKey = 4;
|
||||
uint32 SystemId = 5;
|
||||
uint32 TestDeviceDeprecated = 6; // is it bool or int?
|
||||
bytes ServiceId = 7; // service URL for service certificates
|
||||
}
|
||||
|
||||
// missing some references,
|
||||
message DeviceCertificateStatus {
|
||||
enum CertificateStatus {
|
||||
VALID = 0;
|
||||
REVOKED = 1;
|
||||
}
|
||||
bytes SerialNumber = 1;
|
||||
CertificateStatus Status = 2;
|
||||
ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated?
|
||||
}
|
||||
|
||||
message DeviceCertificateStatusList {
|
||||
uint32 CreationTimeSeconds = 1;
|
||||
repeated DeviceCertificateStatus CertificateStatus = 2;
|
||||
}
|
||||
|
||||
message EncryptedClientIdentification {
|
||||
string ServiceId = 1;
|
||||
bytes ServiceCertificateSerialNumber = 2;
|
||||
bytes EncryptedClientId = 3;
|
||||
bytes EncryptedClientIdIv = 4;
|
||||
bytes EncryptedPrivacyKey = 5;
|
||||
}
|
||||
|
||||
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||
enum LicenseType {
|
||||
ZERO = 0;
|
||||
DEFAULT = 1; // do not know what this is either, but should be 1; on recent versions may go up to 3 (latest x86)
|
||||
}
|
||||
|
||||
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||
// this is just a guess because these globals got lost, but really, do we need more?
|
||||
enum ProtocolVersion {
|
||||
DUMMY = 0;
|
||||
CURRENT = 21; // don't have symbols for this
|
||||
}
|
||||
|
||||
|
||||
message LicenseIdentification {
|
||||
bytes RequestId = 1;
|
||||
bytes SessionId = 2;
|
||||
bytes PurchaseId = 3;
|
||||
LicenseType Type = 4;
|
||||
uint32 Version = 5;
|
||||
bytes ProviderSessionToken = 6;
|
||||
}
|
||||
|
||||
|
||||
message License {
|
||||
message Policy {
|
||||
uint32 CanPlay = 1;
|
||||
uint32 CanPersist = 2;
|
||||
uint32 CanRenew = 3;
|
||||
uint32 RentalDurationSeconds = 4;
|
||||
uint32 PlaybackDurationSeconds = 5;
|
||||
uint32 LicenseDurationSeconds = 6;
|
||||
uint32 RenewalRecoveryDurationSeconds = 7;
|
||||
string RenewalServerUrl = 8;
|
||||
uint32 RenewalDelaySeconds = 9;
|
||||
uint32 RenewalRetryIntervalSeconds = 10;
|
||||
uint32 RenewWithUsage = 11;
|
||||
uint32 UnknownPolicy12 = 12;
|
||||
}
|
||||
message KeyContainer {
|
||||
enum KeyType {
|
||||
_NOKEYTYPE = 0; // dummy, added to satisfy proto3, not present in original
|
||||
SIGNING = 1;
|
||||
CONTENT = 2;
|
||||
KEY_CONTROL = 3;
|
||||
OPERATOR_SESSION = 4;
|
||||
}
|
||||
enum SecurityLevel {
|
||||
_NOSECLEVEL = 0; // dummy, added to satisfy proto3, not present in original
|
||||
SW_SECURE_CRYPTO = 1;
|
||||
SW_SECURE_DECODE = 2;
|
||||
HW_SECURE_CRYPTO = 3;
|
||||
HW_SECURE_DECODE = 4;
|
||||
HW_SECURE_ALL = 5;
|
||||
}
|
||||
message OutputProtection {
|
||||
enum CGMS {
|
||||
COPY_FREE = 0;
|
||||
COPY_ONCE = 2;
|
||||
COPY_NEVER = 3;
|
||||
CGMS_NONE = 0x2A; // PC default!
|
||||
}
|
||||
ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away
|
||||
CGMS CgmsFlags = 2;
|
||||
}
|
||||
message KeyControl {
|
||||
bytes KeyControlBlock = 1; // what is this?
|
||||
bytes Iv = 2;
|
||||
}
|
||||
message OperatorSessionKeyPermissions {
|
||||
uint32 AllowEncrypt = 1;
|
||||
uint32 AllowDecrypt = 2;
|
||||
uint32 AllowSign = 3;
|
||||
uint32 AllowSignatureVerify = 4;
|
||||
}
|
||||
message VideoResolutionConstraint {
|
||||
uint32 MinResolutionPixels = 1;
|
||||
uint32 MaxResolutionPixels = 2;
|
||||
OutputProtection RequiredProtection = 3;
|
||||
}
|
||||
bytes Id = 1;
|
||||
bytes Iv = 2;
|
||||
bytes Key = 3;
|
||||
KeyType Type = 4;
|
||||
SecurityLevel Level = 5;
|
||||
OutputProtection RequiredProtection = 6;
|
||||
OutputProtection RequestedProtection = 7;
|
||||
KeyControl _KeyControl = 8; // duped names, etc
|
||||
OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc
|
||||
repeated VideoResolutionConstraint VideoResolutionConstraints = 10;
|
||||
}
|
||||
LicenseIdentification Id = 1;
|
||||
Policy _Policy = 2; // duped names, etc
|
||||
repeated KeyContainer Key = 3;
|
||||
uint32 LicenseStartTime = 4;
|
||||
uint32 RemoteAttestationVerified = 5; // bool?
|
||||
bytes ProviderClientToken = 6;
|
||||
// there might be more, check with newer versions (I see field 7-8 in a lic)
|
||||
// this appeared in latest x86:
|
||||
uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc)
|
||||
bytes UnknownHdcpDataField = 8;
|
||||
}
|
||||
|
||||
message LicenseError {
|
||||
enum Error {
|
||||
DUMMY_NO_ERROR = 0; // dummy, added to satisfy proto3
|
||||
INVALID_DEVICE_CERTIFICATE = 1;
|
||||
REVOKED_DEVICE_CERTIFICATE = 2;
|
||||
SERVICE_UNAVAILABLE = 3;
|
||||
}
|
||||
//LicenseRequest.RequestType ErrorCode; // clang mismatch
|
||||
Error ErrorCode = 1;
|
||||
}
|
||||
|
||||
message LicenseRequest {
|
||||
message ContentIdentification {
|
||||
message CENC {
|
||||
// bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||
WidevineCencHeader Pssh = 1;
|
||||
LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1
|
||||
bytes RequestId = 3;
|
||||
}
|
||||
message WebM {
|
||||
bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||
LicenseType LicenseType = 2;
|
||||
bytes RequestId = 3;
|
||||
}
|
||||
message ExistingLicense {
|
||||
LicenseIdentification LicenseId = 1;
|
||||
uint32 SecondsSinceStarted = 2;
|
||||
uint32 SecondsSinceLastPlayed = 3;
|
||||
bytes SessionUsageTableEntry = 4;
|
||||
}
|
||||
CENC CencId = 1;
|
||||
WebM WebmId = 2;
|
||||
ExistingLicense License = 3;
|
||||
}
|
||||
enum RequestType {
|
||||
DUMMY_REQ_TYPE = 0; // dummy, added to satisfy proto3
|
||||
NEW = 1;
|
||||
RENEWAL = 2;
|
||||
RELEASE = 3;
|
||||
}
|
||||
ClientIdentification ClientId = 1;
|
||||
ContentIdentification ContentId = 2;
|
||||
RequestType Type = 3;
|
||||
uint32 RequestTime = 4;
|
||||
bytes KeyControlNonceDeprecated = 5;
|
||||
ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||
uint32 KeyControlNonce = 7;
|
||||
EncryptedClientIdentification EncryptedClientId = 8;
|
||||
}
|
||||
|
||||
message ProvisionedDeviceInfo {
|
||||
enum WvSecurityLevel {
|
||||
LEVEL_UNSPECIFIED = 0;
|
||||
LEVEL_1 = 1;
|
||||
LEVEL_2 = 2;
|
||||
LEVEL_3 = 3;
|
||||
}
|
||||
uint32 SystemId = 1;
|
||||
string Soc = 2;
|
||||
string Manufacturer = 3;
|
||||
string Model = 4;
|
||||
string DeviceType = 5;
|
||||
uint32 ModelYear = 6;
|
||||
WvSecurityLevel SecurityLevel = 7;
|
||||
uint32 TestDevice = 8; // bool?
|
||||
}
|
||||
|
||||
|
||||
// todo: fill
|
||||
message ProvisioningOptions {
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message ProvisioningRequest {
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message ProvisioningResponse {
|
||||
}
|
||||
|
||||
message RemoteAttestation {
|
||||
EncryptedClientIdentification Certificate = 1;
|
||||
string Salt = 2;
|
||||
string Signature = 3;
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message SessionInit {
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message SessionState {
|
||||
}
|
||||
|
||||
// todo: fill
|
||||
message SignedCertificateStatusList {
|
||||
}
|
||||
|
||||
message SignedDeviceCertificate {
|
||||
|
||||
//bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is:
|
||||
DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later
|
||||
bytes Signature = 2;
|
||||
SignedDeviceCertificate Signer = 3;
|
||||
}
|
||||
|
||||
|
||||
// todo: fill
|
||||
message SignedProvisioningMessage {
|
||||
}
|
||||
|
||||
// the root of all messages, from either server or client
|
||||
message SignedMessage {
|
||||
enum MessageType {
|
||||
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||
LICENSE_REQUEST = 1;
|
||||
LICENSE = 2;
|
||||
ERROR_RESPONSE = 3;
|
||||
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||
SERVICE_CERTIFICATE = 5;
|
||||
}
|
||||
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||
bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||
RemoteAttestation RemoteAttestation = 5;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// This message is copied from google's docs, not reversed:
|
||||
message WidevineCencHeader {
|
||||
enum Algorithm {
|
||||
UNENCRYPTED = 0;
|
||||
AESCTR = 1;
|
||||
};
|
||||
Algorithm algorithm = 1;
|
||||
repeated bytes key_id = 2;
|
||||
|
||||
// Content provider name.
|
||||
string provider = 3;
|
||||
|
||||
// A content identifier, specified by content provider.
|
||||
bytes content_id = 4;
|
||||
|
||||
// Track type. Acceptable values are SD, HD and AUDIO. Used to
|
||||
// differentiate content keys used by an asset.
|
||||
string track_type_deprecated = 5;
|
||||
|
||||
// The name of a registered policy to be used for this asset.
|
||||
string policy = 6;
|
||||
|
||||
// Crypto period index, for media using key rotation.
|
||||
uint32 crypto_period_index = 7;
|
||||
|
||||
// Optional protected context for group content. The grouped_license is a
|
||||
// serialized SignedMessage.
|
||||
bytes grouped_license = 8;
|
||||
|
||||
// Protection scheme identifying the encryption algorithm.
|
||||
// Represented as one of the following 4CC values:
|
||||
// 'cenc' (AESCTR), 'cbc1' (AESCBC),
|
||||
// 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample).
|
||||
uint32 protection_scheme = 9;
|
||||
|
||||
// Optional. For media using key rotation, this represents the duration
|
||||
// of each crypto period in seconds.
|
||||
uint32 crypto_period_seconds = 10;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically
|
||||
message SignedLicenseRequest {
|
||||
enum MessageType {
|
||||
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||
LICENSE_REQUEST = 1;
|
||||
LICENSE = 2;
|
||||
ERROR_RESPONSE = 3;
|
||||
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||
SERVICE_CERTIFICATE = 5;
|
||||
}
|
||||
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||
LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||
RemoteAttestation RemoteAttestation = 5;
|
||||
}
|
||||
|
||||
message SignedLicense {
|
||||
enum MessageType {
|
||||
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||
LICENSE_REQUEST = 1;
|
||||
LICENSE = 2;
|
||||
ERROR_RESPONSE = 3;
|
||||
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||
SERVICE_CERTIFICATE = 5;
|
||||
}
|
||||
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||
License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||
RemoteAttestation RemoteAttestation = 5;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
import binascii
|
||||
|
||||
class Key:
|
||||
def __init__(self, kid, type, key, permissions=[]):
|
||||
self.kid = kid
|
||||
self.type = type
|
||||
self.key = key
|
||||
self.permissions = permissions
|
||||
|
||||
def __repr__(self):
|
||||
if self.type == "OPERATOR_SESSION":
|
||||
return "key(kid={}, type={}, key={}, permissions={})".format(self.kid, self.type, binascii.hexlify(self.key), self.permissions)
|
||||
else:
|
||||
return "key(kid={}, type={}, key={})".format(self.kid, self.type, binascii.hexlify(self.key))
|
||||
@@ -0,0 +1,18 @@
|
||||
class Session:
|
||||
def __init__(self, session_id, init_data, device_config, offline):
|
||||
self.session_id = session_id
|
||||
self.init_data = init_data
|
||||
self.offline = offline
|
||||
self.device_config = device_config
|
||||
self.device_key = None
|
||||
self.session_key = None
|
||||
self.derived_keys = {
|
||||
'enc': None,
|
||||
'auth_1': None,
|
||||
'auth_2': None
|
||||
}
|
||||
self.license_request = None
|
||||
self.license = None
|
||||
self.service_certificate = None
|
||||
self.privacy_mode = False
|
||||
self.keys = []
|
||||
@@ -0,0 +1,102 @@
|
||||
try:
|
||||
from google.protobuf.internal.decoder import _DecodeVarint as _di # this was tested to work with protobuf 3, but it's an internal API (any varint decoder might work)
|
||||
except ImportError:
|
||||
# this is generic and does not depend on pb internals, however it will decode "larger" possible numbers than pb decoder which has them fixed
|
||||
def LEB128_decode(buffer, pos, limit = 64):
|
||||
result = 0
|
||||
shift = 0
|
||||
while True:
|
||||
b = buffer[pos]
|
||||
pos += 1
|
||||
result |= ((b & 0x7F) << shift)
|
||||
if not (b & 0x80):
|
||||
return (result, pos)
|
||||
shift += 7
|
||||
if shift > limit:
|
||||
raise Exception("integer too large, shift: {}".format(shift))
|
||||
_di = LEB128_decode
|
||||
|
||||
|
||||
class FromFileMixin:
|
||||
@classmethod
|
||||
def from_file(cls, filename):
|
||||
"""Load given a filename"""
|
||||
with open(filename,"rb") as f:
|
||||
return cls(f.read())
|
||||
|
||||
# the signatures use a format internally similar to
|
||||
# protobuf's encoding, but without wire types
|
||||
class VariableReader(FromFileMixin):
|
||||
"""Protobuf-like encoding reader"""
|
||||
|
||||
def __init__(self, buf):
|
||||
self.buf = buf
|
||||
self.pos = 0
|
||||
self.size = len(buf)
|
||||
|
||||
def read_int(self):
|
||||
"""Read a variable length integer"""
|
||||
# _DecodeVarint will take care of out of range errors
|
||||
(val, nextpos) = _di(self.buf, self.pos)
|
||||
self.pos = nextpos
|
||||
return val
|
||||
|
||||
def read_bytes_raw(self, size):
|
||||
"""Read size bytes"""
|
||||
b = self.buf[self.pos:self.pos+size]
|
||||
self.pos += size
|
||||
return b
|
||||
|
||||
def read_bytes(self):
|
||||
"""Read a bytes object"""
|
||||
size = self.read_int()
|
||||
return self.read_bytes_raw(size)
|
||||
|
||||
def is_end(self):
|
||||
return (self.size == self.pos)
|
||||
|
||||
|
||||
class TaggedReader(VariableReader):
|
||||
"""Tagged reader, needed for implementing a WideVine signature reader"""
|
||||
|
||||
def read_tag(self):
|
||||
"""Read a tagged buffer"""
|
||||
return (self.read_int(), self.read_bytes())
|
||||
|
||||
def read_all_tags(self, max_tag=3):
|
||||
tags = {}
|
||||
while (not self.is_end()):
|
||||
(tag, bytes) = self.read_tag()
|
||||
if (tag > max_tag):
|
||||
raise IndexError("tag out of bound: got {}, max {}".format(tag, max_tag))
|
||||
|
||||
tags[tag] = bytes
|
||||
return tags
|
||||
|
||||
class WideVineSignatureReader(FromFileMixin):
|
||||
"""Parses a widevine .sig signature file."""
|
||||
|
||||
SIGNER_TAG = 1
|
||||
SIGNATURE_TAG = 2
|
||||
ISMAINEXE_TAG = 3
|
||||
|
||||
def __init__(self, buf):
|
||||
reader = TaggedReader(buf)
|
||||
self.version = reader.read_int()
|
||||
if (self.version != 0):
|
||||
raise Exception("Unsupported signature format version {}".format(self.version))
|
||||
self.tags = reader.read_all_tags()
|
||||
|
||||
self.signer = self.tags[self.SIGNER_TAG]
|
||||
self.signature = self.tags[self.SIGNATURE_TAG]
|
||||
|
||||
extra = self.tags[self.ISMAINEXE_TAG]
|
||||
if (len(extra) != 1 or (extra[0] > 1)):
|
||||
raise Exception("Unexpected 'ismainexe' field value (not '\\x00' or '\\x01'), please check: {0}".format(extra))
|
||||
|
||||
self.mainexe = bool(extra[0])
|
||||
|
||||
@classmethod
|
||||
def get_tags(cls, filename):
|
||||
"""Return a dictionary of each tag in the signature file"""
|
||||
return cls.from_file(filename).tags
|
||||
@@ -0,0 +1,57 @@
|
||||
import base64
|
||||
import logging
|
||||
import subprocess
|
||||
from pywidevine.L3.cdm import cdm, deviceconfig
|
||||
|
||||
class WvDecrypt(object):
|
||||
|
||||
WV_SYSTEM_ID = [237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237]
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.wvdecrypt_process = None
|
||||
|
||||
self.logger.debug(self.log_message("wvdecrypt object created"))
|
||||
self.cdm = cdm.Cdm()
|
||||
|
||||
def check_pssh(pssh_b64):
|
||||
pssh = base64.b64decode(pssh_b64)
|
||||
if not pssh[12:28] == bytes(self.WV_SYSTEM_ID):
|
||||
new_pssh = bytearray([0,0,0])
|
||||
new_pssh.append(32+len(pssh))
|
||||
new_pssh[4:] = bytearray(b'pssh')
|
||||
new_pssh[8:] = [0,0,0,0]
|
||||
new_pssh[13:] = self.WV_SYSTEM_ID
|
||||
new_pssh[29:] = [0,0,0,0]
|
||||
new_pssh[31] = len(pssh)
|
||||
new_pssh[32:] = pssh
|
||||
return base64.b64encode(new_pssh)
|
||||
else:
|
||||
return pssh_b64
|
||||
|
||||
self.session = self.cdm.open_session(check_pssh(config.init_data_b64),
|
||||
deviceconfig.DeviceConfig(deviceconfig.device_android_generic))
|
||||
|
||||
self.logger.debug(self.log_message("widevine session opened"))
|
||||
|
||||
|
||||
def log_message(self, msg):
|
||||
return "{}_{} : {}".format('audio', '0', msg)
|
||||
|
||||
def start_process(self):
|
||||
decryption_keys = self.cdm.get_keys(self.session)
|
||||
self.logger.debug(self.log_message("starting process"))
|
||||
self.logger.debug(self.config.build_commandline_list(decryption_keys))
|
||||
self.wvdecrypt_process = subprocess.run(
|
||||
self.config.build_commandline_list(decryption_keys),
|
||||
check=True
|
||||
)
|
||||
self.logger.debug(self.log_message("decrypted successfully"))
|
||||
|
||||
def get_challenge(self):
|
||||
return self.cdm.get_license_request(self.session)
|
||||
|
||||
def update_license(self, license_b64):
|
||||
self.cdm.provide_license(self.session, license_b64)
|
||||
return True
|
||||
@@ -0,0 +1,17 @@
|
||||
class WvDecryptConfig(object):
|
||||
def __init__(self, decrypted_download_location, encrypted_download_location, init_data_b64):
|
||||
self.decrypted_download_location = str(decrypted_download_location)
|
||||
self.encrypted_download_location = str(encrypted_download_location)
|
||||
self.init_data_b64 = init_data_b64
|
||||
|
||||
def build_commandline_list(self, keys):
|
||||
commandline = ['mp4decrypt']
|
||||
commandline.append('--show-progress')
|
||||
for key in keys:
|
||||
if key.type == 'CONTENT':
|
||||
commandline.append('--key')
|
||||
default_KID = 1
|
||||
commandline.append('{}:{}'.format(str(default_KID), key.key.hex()))
|
||||
commandline.append(self.encrypted_download_location)
|
||||
commandline.append(self.decrypted_download_location)
|
||||
return commandline
|
||||
@@ -0,0 +1,59 @@
|
||||
# uncompyle6 version 3.7.3
|
||||
# Python bytecode 3.6 (3379)
|
||||
# Decompiled from: Python 3.7.8 (tags/v3.7.8:4b47a5b6ba, Jun 28 2020, 08:53:46) [MSC v.1916 64 bit (AMD64)]
|
||||
# Embedded file name: pywidevine\decrypt\wvdecryptcustom.py
|
||||
import logging, subprocess, re, base64
|
||||
from pywidevine.L1.cdm import cdm, deviceconfig
|
||||
|
||||
class WvDecrypt(object):
|
||||
WV_SYSTEM_ID = [
|
||||
237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237]
|
||||
|
||||
def __init__(self, init_data_b64, cert_data_b64, device):
|
||||
self.init_data_b64 = init_data_b64
|
||||
self.cert_data_b64 = cert_data_b64
|
||||
self.device = device
|
||||
self.cdm = cdm.Cdm()
|
||||
|
||||
def check_pssh(pssh_b64):
|
||||
pssh = base64.b64decode(pssh_b64)
|
||||
if not pssh[12:28] == bytes(self.WV_SYSTEM_ID):
|
||||
new_pssh = bytearray([0, 0, 0])
|
||||
new_pssh.append(32 + len(pssh))
|
||||
new_pssh[4:] = bytearray(b'pssh')
|
||||
new_pssh[8:] = [0, 0, 0, 0]
|
||||
new_pssh[13:] = self.WV_SYSTEM_ID
|
||||
new_pssh[29:] = [0, 0, 0, 0]
|
||||
new_pssh[31] = len(pssh)
|
||||
new_pssh[32:] = pssh
|
||||
return base64.b64encode(new_pssh)
|
||||
else:
|
||||
return pssh_b64
|
||||
|
||||
self.session = self.cdm.open_session(check_pssh(self.init_data_b64), deviceconfig.DeviceConfig(self.device))
|
||||
if self.cert_data_b64:
|
||||
self.cdm.set_service_certificate(self.session, self.cert_data_b64)
|
||||
|
||||
def log_message(self, msg):
|
||||
return '{}'.format(msg)
|
||||
|
||||
def start_process(self):
|
||||
keyswvdecrypt = []
|
||||
try:
|
||||
for key in self.cdm.get_keys(self.session):
|
||||
if key.type == 'CONTENT':
|
||||
keyswvdecrypt.append(self.log_message('{}:{}'.format(key.kid.hex(), key.key.hex())))
|
||||
|
||||
except Exception:
|
||||
return (
|
||||
False, keyswvdecrypt)
|
||||
else:
|
||||
return (
|
||||
True, keyswvdecrypt)
|
||||
|
||||
def get_challenge(self):
|
||||
return self.cdm.get_license_request(self.session)
|
||||
|
||||
def update_license(self, license_b64):
|
||||
self.cdm.provide_license(self.session, license_b64)
|
||||
return True
|
||||
@@ -0,0 +1,14 @@
|
||||
import requests, xmltodict, json
|
||||
|
||||
def get_pssh(mpd_url):
|
||||
r = requests.get(url=mpd_url)
|
||||
r.raise_for_status()
|
||||
xml = xmltodict.parse(r.text)
|
||||
mpd = json.loads(json.dumps(xml))
|
||||
tracks = mpd['MPD']['Period']['AdaptationSet']
|
||||
for video_tracks in tracks:
|
||||
if video_tracks['@mimeType'] == 'video/mp4':
|
||||
for t in video_tracks["ContentProtection"]:
|
||||
if t['@schemeIdUri'].lower() == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed":
|
||||
pssh = t["pssh"]
|
||||
return pssh
|
||||
@@ -0,0 +1,4 @@
|
||||
m3u8
|
||||
google-api-python-client
|
||||
yt-dlp
|
||||
requests
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
ID34 = "Music"
|
||||
ID1203 = "African"
|
||||
ID45 = "African Dancehall"
|
||||
ID46 = "African Reggae"
|
||||
ID1281 = "Afrikaans"
|
||||
ID100049 = "Afro House"
|
||||
ID100050 = "Afro Soul"
|
||||
ID1177 = "Afro-Beat"
|
||||
ID1178 = "Afro-Pop"
|
||||
ID43 = "Afro-folk"
|
||||
ID44 = "Afro-fusion"
|
||||
ID100051 = "Afrobeats"
|
||||
ID41 = "Alte"
|
||||
ID42 = "Amapiano"
|
||||
ID100052 = "Benga"
|
||||
ID100053 = "Bongo-Flava"
|
||||
ID100054 = "Coupé-Décalé"
|
||||
ID100055 = "Gqom"
|
||||
ID100056 = "Highlife"
|
||||
ID100058 = "Kizomba"
|
||||
ID100057 = "Kuduro"
|
||||
ID100059 = "Kwaito"
|
||||
ID100073 = "Maskandi"
|
||||
ID100060 = "Mbalax"
|
||||
ID100061 = "Ndombolo"
|
||||
ID100062 = "Shangaan Electro"
|
||||
ID100063 = "Soukous"
|
||||
ID100064 = "Taarab"
|
||||
ID100065 = "Zouglou"
|
||||
ID20 = "Alternative"
|
||||
ID1230 = "Chinese Alt"
|
||||
ID1001 = "College Rock"
|
||||
ID100018 = "EMO"
|
||||
ID1002 = "Goth Rock"
|
||||
ID1003 = "Grunge"
|
||||
ID100098 = "Indie Egyptian"
|
||||
ID100097 = "Indie Levant"
|
||||
ID100099 = "Indie Maghreb"
|
||||
ID100020 = "Indie Pop"
|
||||
ID1004 = "Indie Rock"
|
||||
ID1231 = "Korean Indie"
|
||||
ID1005 = "New Wave"
|
||||
ID100019 = "Pop Punk"
|
||||
ID1006 = "Punk"
|
||||
ID100071 = "Turkish Alternative"
|
||||
ID29 = "Anime"
|
||||
ID1197 = "Arabic"
|
||||
ID1286 = "Arabic Pop"
|
||||
ID1287 = "Islamic"
|
||||
ID1284 = "Khaleeji"
|
||||
ID100082 = "Khaleeji Jalsat"
|
||||
ID100083 = "Khaleeji Shailat"
|
||||
ID100079 = "Levant"
|
||||
ID100080 = "Dabke"
|
||||
ID100081 = "Maghreb Rai"
|
||||
ID1285 = "North African"
|
||||
ID2 = "Blues"
|
||||
ID1210 = "Acoustic Blues"
|
||||
ID1007 = "Chicago Blues"
|
||||
ID1009 = "Classic Blues"
|
||||
ID1010 = "Contemporary Blues"
|
||||
ID1011 = "Country Blues"
|
||||
ID1012 = "Delta Blues"
|
||||
ID1013 = "Electric Blues"
|
||||
ID1122 = "Brazilian"
|
||||
ID1220 = "Axé"
|
||||
ID1229 = "Baile Funk"
|
||||
ID1221 = "Bossa Nova"
|
||||
ID1222 = "Choro"
|
||||
ID1223 = "Forró"
|
||||
ID1224 = "Frevo"
|
||||
ID1225 = "MPB"
|
||||
ID1226 = "Pagode"
|
||||
ID1227 = "Samba"
|
||||
ID1228 = "Sertanejo"
|
||||
ID4 = "Children's Music"
|
||||
ID1014 = "Lullabies"
|
||||
ID1015 = "Sing-Along"
|
||||
ID1016 = "Stories"
|
||||
ID1232 = "Chinese"
|
||||
ID1233 = "Chinese Classical"
|
||||
ID1234 = "Chinese Flute"
|
||||
ID1235 = "Chinese Opera"
|
||||
ID1236 = "Chinese Orchestral"
|
||||
ID1237 = "Chinese Regional Folk"
|
||||
ID1238 = "Chinese Strings"
|
||||
ID1239 = "Taiwanese Folk"
|
||||
ID1240 = "Tibetan Native Music"
|
||||
ID22 = "Christian"
|
||||
ID1094 = "CCM"
|
||||
ID1095 = "Christian Metal"
|
||||
ID1096 = "Christian Pop"
|
||||
ID1097 = "Christian Rap"
|
||||
ID1098 = "Christian Rock"
|
||||
ID1099 = "Classic Christian"
|
||||
ID1100 = "Contemporary Gospel"
|
||||
ID1101 = "Gospel"
|
||||
ID1103 = "Praise & Worship"
|
||||
ID1104 = "Southern Gospel"
|
||||
ID1105 = "Traditional Gospel"
|
||||
ID5 = "Classical"
|
||||
ID100001 = "Art Song"
|
||||
ID1017 = "Avant-Garde"
|
||||
ID1018 = "Baroque Era"
|
||||
ID100002 = "Brass & Woodwinds"
|
||||
ID100006 = "Cantata"
|
||||
ID100012 = "Cello"
|
||||
ID1019 = "Chamber Music"
|
||||
ID1020 = "Chant"
|
||||
ID1021 = "Choral"
|
||||
ID1022 = "Classical Crossover"
|
||||
ID1211 = "High Classical"
|
||||
ID100004 = "Contemporary Era"
|
||||
ID1023 = "Early Music"
|
||||
ID100007 = "Electronic"
|
||||
ID100009 = "Guitar"
|
||||
ID1024 = "Impressionist"
|
||||
ID1025 = "Medieval Era"
|
||||
ID1026 = "Minimalism"
|
||||
ID1027 = "Modern Era"
|
||||
ID9 = "Opera"
|
||||
ID1028 = "Opera"
|
||||
ID100005 = "Oratorio"
|
||||
ID1029 = "Orchestral"
|
||||
ID100013 = "Percussion"
|
||||
ID100010 = "Piano"
|
||||
ID1030 = "Renaissance"
|
||||
ID1031 = "Romantic Era"
|
||||
ID100008 = "Sacred"
|
||||
ID100003 = "Solo Instrumental"
|
||||
ID100011 = "Violin"
|
||||
ID1032 = "Wedding Music"
|
||||
ID3 = "Comedy"
|
||||
ID1167 = "Novelty"
|
||||
ID1171 = "Standup Comedy"
|
||||
ID6 = "Country"
|
||||
ID1033 = "Alternative Country"
|
||||
ID1034 = "Americana"
|
||||
ID1035 = "Bluegrass"
|
||||
ID1036 = "Contemporary Bluegrass"
|
||||
ID1037 = "Contemporary Country"
|
||||
ID1038 = "Country Gospel"
|
||||
ID1039 = "Honky Tonk"
|
||||
ID1040 = "Outlaw Country"
|
||||
ID1294 = "Thai Country"
|
||||
ID1041 = "Traditional Bluegrass"
|
||||
ID1042 = "Traditional Country"
|
||||
ID1043 = "Urban Cowboy"
|
||||
ID100024 = "Cuban"
|
||||
ID100029 = "Bolero"
|
||||
ID100026 = "Chachacha"
|
||||
ID100027 = "Guajira"
|
||||
ID100030 = "Guaracha"
|
||||
ID100025 = "Mambo"
|
||||
ID100028 = "Son"
|
||||
ID100031 = "Timba"
|
||||
ID17 = "Dance"
|
||||
ID1044 = "Breakbeat"
|
||||
ID1045 = "Exercise"
|
||||
ID1046 = "Garage"
|
||||
ID1047 = "Hardcore"
|
||||
ID1048 = "House"
|
||||
ID1049 = "Jungle/Drum'n'bass"
|
||||
ID100105 = "Maghreb Dance"
|
||||
ID1050 = "Techno"
|
||||
ID1051 = "Trance"
|
||||
ID50000063 = "Disney"
|
||||
ID25 = "Easy Listening"
|
||||
ID1054 = "Lounge"
|
||||
ID1055 = "Swing"
|
||||
ID7 = "Electronic"
|
||||
ID1056 = "Ambient"
|
||||
ID100015 = "Bass"
|
||||
ID1057 = "Downtempo"
|
||||
ID100014 = "Dubstep"
|
||||
ID100101 = "Electro-Cha'abi"
|
||||
ID1058 = "Electronica"
|
||||
ID1060 = "IDM/Experimental"
|
||||
ID1061 = "Industrial"
|
||||
ID100100 = "Levant Electronic"
|
||||
ID100102 = "Maghreb Electronic"
|
||||
ID28 = "Enka"
|
||||
ID50 = "Fitness & Workout"
|
||||
ID1289 = "Folk"
|
||||
ID100103 = "Iraqi Folk"
|
||||
ID100104 = "Khaleeji Folk"
|
||||
ID50000064 = "French Pop"
|
||||
ID50000068 = "German Folk"
|
||||
ID50000066 = "German Pop"
|
||||
ID18 = "Hip-Hop/Rap"
|
||||
ID1068 = "Alternative Rap"
|
||||
ID1241 = "Chinese Hip-Hop"
|
||||
ID1069 = "Dirty South"
|
||||
ID1070 = "East Coast Rap"
|
||||
ID100094 = "Egyptian Hip-Hop"
|
||||
ID1071 = "Gangsta Rap"
|
||||
ID1072 = "Hardcore Rap"
|
||||
ID1073 = "Hip-Hop"
|
||||
ID100096 = "Khaleeji Hip-Hop"
|
||||
ID1242 = "Korean Hip-Hop"
|
||||
ID1074 = "Latin Rap"
|
||||
ID100093 = "Levant Hip-Hop"
|
||||
ID100095 = "Maghreb Hip-Hop"
|
||||
ID1075 = "Old School Rap"
|
||||
ID1076 = "Rap"
|
||||
ID100078 = "Hip-Hop in Russian"
|
||||
ID100072 = "Turkish Hip-Hop/Rap"
|
||||
ID100016 = "UK Hip-Hop"
|
||||
ID1077 = "Underground Rap"
|
||||
ID1078 = "West Coast Rap"
|
||||
ID8 = "Holiday"
|
||||
ID1079 = "Chanukah"
|
||||
ID1080 = "Christmas"
|
||||
ID1081 = "Christmas: Children's"
|
||||
ID1082 = "Christmas: Classic"
|
||||
ID1083 = "Christmas: Classical"
|
||||
ID100106 = "Christmas: Country"
|
||||
ID1084 = "Christmas: Jazz"
|
||||
ID1085 = "Christmas: Modern"
|
||||
ID1086 = "Christmas: Pop"
|
||||
ID1087 = "Christmas: R&B"
|
||||
ID1088 = "Christmas: Religious"
|
||||
ID1089 = "Christmas: Rock"
|
||||
ID1090 = "Easter"
|
||||
ID1091 = "Halloween"
|
||||
ID1092 = "Holiday: Other"
|
||||
ID1093 = "Thanksgiving"
|
||||
ID100107 = "Hörspiele"
|
||||
ID1262 = "Indian"
|
||||
ID1263 = "Bollywood"
|
||||
ID1267 = "Devotional & Spiritual"
|
||||
ID1278 = "Ghazals"
|
||||
ID1269 = "Indian Classical"
|
||||
ID100047 = "Carnatic Classical"
|
||||
ID100048 = "Hindustani Classical"
|
||||
ID1279 = "Indian Folk"
|
||||
ID1185 = "Indian Pop"
|
||||
ID1266 = "Regional Indian"
|
||||
ID100039 = "Assamese"
|
||||
ID100046 = "Bengali"
|
||||
ID100034 = "Rabindra Sangeet"
|
||||
ID100040 = "Bhojpuri"
|
||||
ID100038 = "Gujarati"
|
||||
ID100041 = "Haryanvi"
|
||||
ID100036 = "Kannada"
|
||||
ID100035 = "Malayalam"
|
||||
ID100037 = "Marathi"
|
||||
ID100042 = "Odia"
|
||||
ID100045 = "Punjabi"
|
||||
ID100033 = "Punjabi Pop"
|
||||
ID100043 = "Rajasthani"
|
||||
ID1264 = "Tamil"
|
||||
ID1265 = "Telugu"
|
||||
ID100044 = "Urdu"
|
||||
ID1268 = "Sufi"
|
||||
ID100000 = "Inspirational"
|
||||
ID53 = "Instrumental"
|
||||
ID27 = "J-Pop"
|
||||
ID11 = "Jazz"
|
||||
ID1106 = "Avant-Garde Jazz"
|
||||
ID1053 = "Bop"
|
||||
ID1052 = "Big Band"
|
||||
ID1107 = "Contemporary Jazz"
|
||||
ID1209 = "Cool Jazz"
|
||||
ID1108 = "Crossover Jazz"
|
||||
ID1109 = "Dixieland"
|
||||
ID1110 = "Fusion"
|
||||
ID1207 = "Hard Bop"
|
||||
ID1111 = "Latin Jazz"
|
||||
ID1112 = "Mainstream Jazz"
|
||||
ID1113 = "Ragtime"
|
||||
ID1114 = "Smooth Jazz"
|
||||
ID1208 = "Trad Jazz"
|
||||
ID1175 = "Vocal Jazz"
|
||||
ID52 = "Karaoke"
|
||||
ID30 = "Kayokyoku"
|
||||
ID1243 = "Korean"
|
||||
ID1246 = "Korean Trad Instrumental"
|
||||
ID1245 = "Korean Trad Song"
|
||||
ID1247 = "Korean Trad Theater"
|
||||
ID1244 = "Korean Classical"
|
||||
ID12 = "Latin"
|
||||
ID1121 = "Rock y Alternativo"
|
||||
ID1120 = "Baladas y Boleros"
|
||||
ID1116 = "Contemporary Latin"
|
||||
ID1115 = "Latin Jazz"
|
||||
ID1119 = "Urbano latino"
|
||||
ID1117 = "Pop Latino"
|
||||
ID1118 = "Raíces"
|
||||
ID1123 = "Música Mexicana"
|
||||
ID1124 = "Música tropical"
|
||||
ID1291 = "Marching"
|
||||
ID13 = "New Age"
|
||||
ID1125 = "Environmental"
|
||||
ID1126 = "Healing"
|
||||
ID1127 = "Meditation"
|
||||
ID1128 = "Nature"
|
||||
ID1129 = "Relaxation"
|
||||
ID1130 = "Travel"
|
||||
ID100021 = "Yoga"
|
||||
ID1290 = "Orchestral"
|
||||
ID14 = "Pop"
|
||||
ID1131 = "Adult Contemporary"
|
||||
ID1132 = "Britpop"
|
||||
ID1250 = "C-Pop"
|
||||
ID1251 = "Cantopop/HK-Pop"
|
||||
ID100090 = "Egyptian Pop"
|
||||
ID1259 = "Indo Pop"
|
||||
ID100089 = "Iraqi Pop"
|
||||
ID51 = "K-Pop"
|
||||
ID100092 = "Khaleeji Pop"
|
||||
ID1252 = "Korean Folk-Pop"
|
||||
ID100088 = "Levant Pop"
|
||||
ID100091 = "Maghreb Pop"
|
||||
ID1255 = "Malaysian Pop"
|
||||
ID1253 = "Mandopop"
|
||||
ID1258 = "Manilla Sound"
|
||||
ID1293 = "Oldies"
|
||||
ID1257 = "Original Pilipino Music"
|
||||
ID1256 = "Pinoy Pop"
|
||||
ID1133 = "Pop/Rock"
|
||||
ID100076 = "Pop in Russian"
|
||||
ID100023 = "Shows"
|
||||
ID1134 = "Soft Rock"
|
||||
ID1254 = "Tai-Pop"
|
||||
ID1135 = "Teen Pop"
|
||||
ID1260 = "Thai Pop"
|
||||
ID100022 = "Tribute"
|
||||
ID100069 = "Turkish Pop"
|
||||
ID15 = "R&B/Soul"
|
||||
ID1136 = "Contemporary R&B"
|
||||
ID1137 = "Disco"
|
||||
ID1138 = "Doo Wop"
|
||||
ID1139 = "Funk"
|
||||
ID1140 = "Motown"
|
||||
ID1141 = "Neo-Soul"
|
||||
ID1142 = "Quiet Storm"
|
||||
ID1143 = "Soul"
|
||||
ID24 = "Reggae"
|
||||
ID1193 = "Dub"
|
||||
ID100017 = "Lovers Rock"
|
||||
ID1183 = "Modern Dancehall"
|
||||
ID1192 = "Roots Reggae"
|
||||
ID1194 = "Ska"
|
||||
ID21 = "Rock"
|
||||
ID1144 = "Adult Alternative"
|
||||
ID1145 = "American Trad Rock"
|
||||
ID1146 = "Arena Rock"
|
||||
ID1147 = "Blues-Rock"
|
||||
ID1148 = "British Invasion"
|
||||
ID1248 = "Chinese Rock"
|
||||
ID1149 = "Death Metal/Black Metal"
|
||||
ID1150 = "Glam Rock"
|
||||
ID1151 = "Hair Metal"
|
||||
ID1152 = "Hard Rock"
|
||||
ID1153 = "Metal"
|
||||
ID1154 = "Jam Bands"
|
||||
ID1249 = "Korean Rock"
|
||||
ID1155 = "Prog-Rock/Art Rock"
|
||||
ID1156 = "Psychedelic"
|
||||
ID1157 = "Rock & Roll"
|
||||
ID1158 = "Rockabilly"
|
||||
ID1159 = "Roots Rock"
|
||||
ID100077 = "Rock in Russian"
|
||||
ID1160 = "Singer/Songwriter"
|
||||
ID1161 = "Southern Rock"
|
||||
ID1162 = "Surf"
|
||||
ID1163 = "Tex-Mex"
|
||||
ID100070 = "Turkish Rock"
|
||||
ID1299 = "Russian"
|
||||
ID100075 = "Bard in Russian"
|
||||
ID1270 = "Chanson in Russian"
|
||||
ID100074 = "Romance in Russian"
|
||||
ID10 = "Singer/Songwriter"
|
||||
ID1062 = "Alternative Folk"
|
||||
ID1063 = "Contemporary Folk"
|
||||
ID1064 = "Contemporary Singer/Songwriter"
|
||||
ID1065 = "Folk-Rock"
|
||||
ID1066 = "New Acoustic"
|
||||
ID1067 = "Traditional Folk"
|
||||
ID16 = "Soundtrack"
|
||||
ID1165 = "Foreign Cinema"
|
||||
ID1166 = "Musicals"
|
||||
ID1168 = "Original Score"
|
||||
ID1288 = "Sound Effects"
|
||||
ID1169 = "Soundtrack"
|
||||
ID1172 = "TV Soundtrack"
|
||||
ID100032 = "Video Game"
|
||||
ID50000061 = "Spoken Word"
|
||||
ID100084 = "Tarab"
|
||||
ID100086 = "Egyptian Tarab"
|
||||
ID100085 = "Iraqi Tarab"
|
||||
ID100087 = "Khaleeji Tarab"
|
||||
ID1300 = "Turkish"
|
||||
ID1280 = "Arabesque"
|
||||
ID100067 = "Fantezi"
|
||||
ID1272 = "Halk"
|
||||
ID100068 = "Religious"
|
||||
ID1273 = "Sanat"
|
||||
ID100066 = "Özgün"
|
||||
ID23 = "Vocal"
|
||||
ID1173 = "Standards"
|
||||
ID1174 = "Traditional Pop"
|
||||
ID1261 = "Trot"
|
||||
ID1176 = "Vocal Pop"
|
||||
ID19 = "Worldwide"
|
||||
ID1204 = "Asia"
|
||||
ID1200 = "Australia"
|
||||
ID1179 = "Cajun"
|
||||
ID1276 = "Calypso"
|
||||
ID1195 = "Caribbean"
|
||||
ID1180 = "Celtic"
|
||||
ID1181 = "Celtic Folk"
|
||||
ID1182 = "Contemporary Celtic"
|
||||
ID1274 = "Dangdut"
|
||||
ID1271 = "Dini"
|
||||
ID1184 = "Drinking Songs"
|
||||
ID1205 = "Europe"
|
||||
ID1297 = "Fado"
|
||||
ID1282 = "Farsi"
|
||||
ID1295 = "Flamenco"
|
||||
ID1202 = "France"
|
||||
ID1199 = "Hawaii"
|
||||
ID1298 = "Iberia"
|
||||
ID1275 = "Indonesian Religious"
|
||||
ID1283 = "Israeli"
|
||||
ID1201 = "Japan"
|
||||
ID1186 = "Japanese Pop"
|
||||
ID1187 = "Klezmer"
|
||||
ID1198 = "North America"
|
||||
ID1188 = "Polka"
|
||||
ID1277 = "Soca"
|
||||
ID1206 = "South Africa"
|
||||
ID1196 = "South America"
|
||||
ID1296 = "Tango"
|
||||
ID1189 = "Traditional Celtic"
|
||||
ID1190 = "Worldbeat"
|
||||
ID1191 = "Zydeco"
|
||||
@@ -0,0 +1,155 @@
|
||||
AE = "143481-2,32"
|
||||
AG = "143540-2,32"
|
||||
AI = "143538-2,32"
|
||||
AL = "143575-2,32"
|
||||
AM = "143524-2,32"
|
||||
AO = "143564-2,32"
|
||||
AR = "143505-28,32"
|
||||
AT = "143445-4,32"
|
||||
AU = "143460-27,32"
|
||||
AZ = "143568-2,32"
|
||||
BB = "143541-2,32"
|
||||
BE = "143446-2,32"
|
||||
BF = "143578-2,32"
|
||||
BG = "143526-2,32"
|
||||
BH = "143559-2,32"
|
||||
BJ = "143576-2,32"
|
||||
BM = "143542-2,32"
|
||||
BN = "143560-2,32"
|
||||
BO = "143556-28,32"
|
||||
BR = "143503-15,32"
|
||||
BS = "143539-2,32"
|
||||
BT = "143577-2,32"
|
||||
BW = "143525-2,32"
|
||||
BY = "143565-2,32"
|
||||
BZ = "143555-2,32"
|
||||
CA = "143455-6,32"
|
||||
CG = "143582-2,32"
|
||||
CH = "143459-57,32"
|
||||
CL = "143483-28,32"
|
||||
CN = "143465-19,32"
|
||||
CO = "143501-28,32"
|
||||
CR = "143495-28,32"
|
||||
CV = "143580-2,32"
|
||||
CY = "143557-2,32"
|
||||
CZ = "143489-2,32"
|
||||
DE = "143443-4,32"
|
||||
DK = "143458-2,32"
|
||||
DM = "143545-2,32"
|
||||
DO = "143508-28,32"
|
||||
DZ = "143563-2,32"
|
||||
EC = "143509-28,32"
|
||||
EE = "143518-2,32"
|
||||
EG = "143516-2,32"
|
||||
ES = "143454-8,32"
|
||||
FI = "143447-2,32"
|
||||
FJ = "143583-2,32"
|
||||
FM = "143591-2,32"
|
||||
FR = "143442-3,32"
|
||||
GB = "143444-2,32"
|
||||
GD = "143546-2,32"
|
||||
GH = "143573-2,32"
|
||||
GM = "143584-2,32"
|
||||
GR = "143448-2,32"
|
||||
GT = "143504-28,32"
|
||||
GW = "143585-2,32"
|
||||
GY = "143553-2,32"
|
||||
HK = "143463-45,32"
|
||||
HN = "143510-28,32"
|
||||
HR = "143494-2,32"
|
||||
HU = "143482-2,32"
|
||||
ID = "143476-2,32"
|
||||
IE = "143449-2,32"
|
||||
IL = "143491-2,32"
|
||||
IN = "143467-2,32"
|
||||
IS = "143558-2,32"
|
||||
IT = "143450-7,32"
|
||||
JM = "143511-2,32"
|
||||
JO = "143528-2,32"
|
||||
JP = "143462-9,32"
|
||||
KE = "143529-2,32"
|
||||
KG = "143586-2,32"
|
||||
KH = "143579-2,32"
|
||||
KN = "143548-2,32"
|
||||
KR = "143466-13,32"
|
||||
KW = "143493-2,32"
|
||||
KY = "143544-2,32"
|
||||
KZ = "143517-2,32"
|
||||
LA = "143587-2,32"
|
||||
LB = "143497-2,32"
|
||||
LC = "143549-2,32"
|
||||
LK = "143486-2,32"
|
||||
LR = "143588-2,32"
|
||||
LT = "143520-2,32"
|
||||
LU = "143451-2,32"
|
||||
LV = "143519-2,32"
|
||||
MD = "143523-2,32"
|
||||
MG = "143531-2,32"
|
||||
MK = "143530-2,32"
|
||||
ML = "143532-2,32"
|
||||
MN = "143592-2,32"
|
||||
MO = "143515-45,32"
|
||||
MR = "143590-2,32"
|
||||
MS = "143547-2,32"
|
||||
MT = "143521-2,32"
|
||||
MU = "143533-2,32"
|
||||
MW = "143589-2,32"
|
||||
MX = "143468-28,32"
|
||||
MY = "143473-2,32"
|
||||
MZ = "143593-2,32"
|
||||
NA = "143594-2,32"
|
||||
NE = "143534-2,32"
|
||||
NG = "143561-2,32"
|
||||
NI = "143512-28,32"
|
||||
NL = "143452-10,32"
|
||||
NO = "143457-2,32"
|
||||
NP = "143484-2,32"
|
||||
NZ = "143461-27,32"
|
||||
OM = "143562-2,32"
|
||||
PA = "143485-28,32"
|
||||
PE = "143507-28,32"
|
||||
PG = "143597-2,32"
|
||||
PH = "143474-2,32"
|
||||
PK = "143477-2,32"
|
||||
PL = "143478-2,32"
|
||||
PT = "143453-24,32"
|
||||
PW = "143595-2,32"
|
||||
PY = "143513-28,32"
|
||||
QA = "143498-2,32"
|
||||
RO = "143487-2,32"
|
||||
RU = "143469-16,32"
|
||||
SA = "143479-2,32"
|
||||
SB = "143601-2,32"
|
||||
SC = "143599-2,32"
|
||||
SE = "143456-17,32"
|
||||
SG = "143464-19,32"
|
||||
SI = "143499-2,32"
|
||||
SK = "143496-2,32"
|
||||
SL = "143600-2,32"
|
||||
SN = "143535-2,32"
|
||||
SR = "143554-2,32"
|
||||
ST = "143598-2,32"
|
||||
SV = "143506-28,32"
|
||||
SZ = "143602-2,32"
|
||||
TC = "143552-2,32"
|
||||
TD = "143581-2,32"
|
||||
TH = "143475-2,32"
|
||||
TJ = "143603-2,32"
|
||||
TM = "143604-2,32"
|
||||
TN = "143536-2,32"
|
||||
TR = "143480-2,32"
|
||||
TT = "143551-2,32"
|
||||
TW = "143470-18,32"
|
||||
TZ = "143572-2,32"
|
||||
UA = "143492-2,32"
|
||||
UG = "143537-2,32"
|
||||
US = "143441-1,32"
|
||||
UY = "143514-2,32"
|
||||
UZ = "143566-2,32"
|
||||
VC = "143550-2,32"
|
||||
VE = "143502-28,32"
|
||||
VG = "143543-2,32"
|
||||
VN = "143471-2,32"
|
||||
YE = "143571-2,32"
|
||||
ZA = "143472-2,32"
|
||||
ZW = "143605-2,32"
|
||||
Reference in New Issue
Block a user