New structure

pypi
This commit is contained in:
R. M
2023-02-04 20:40:07 -03:00
parent 6f90e10ca1
commit 92f4ed0a00
25 changed files with 341 additions and 5508 deletions
+3 -9
View File
@@ -1,11 +1,5 @@
/*
__pycache__/
!gamdl.py
!song_genres.py
!music_video_genres.py
!storefront_ids.py
!pywidevine
__pycache__
!gamdl
!requirements.txt
!.gitignore
device_client_id_blob
device_private_key
!pyproject.toml
+56 -27
View File
@@ -1,54 +1,83 @@
# Glomatico's ✨ Apple Music ✨ Downloader
A Python script to download Apple Music songs/music videos/albums/playlists.
![Windows CMD usage example](https://i.imgur.com/byjqmGF.png)
![Windows CMD usage example](https://i.imgur.com/6WeUCFh.png)
This is a rework of https://github.com/loveyoursupport/AppleMusic-Downloader/tree/661a274d62586b521feec5a7de6bee0e230fdb7d.
Some new features that I added:
- MP4Box for muxing
- Tags for music videos
- Multiple URLs input
- iTunes folder structure
- Embedded lyrics and .lrc file
- Auto set region
- Playlist support
- And much more!
* MP4Box for muxing
* Tags for music videos
* Multiple URLs input
* iTunes folder structure
* Embedded lyrics and .lrc file
* Auto set region
* Playlist support
* And much more!
## Setup
1. Install Python 3.8 or higher
2. Install the required packages using pip:
1. Install Python 3.7 or newer
2. Install gamdl with pip
```
pip install -r requirements.txt
pip install gamdl
```
3. Add MP4Box and mp4decrypt to your PATH
* You can get them from here:
* MP4Box: https://gpac.wp.imt.fr/downloads/
* mp4decrypt: https://www.bento4.com/downloads/
4. Export your Apple Music cookies as `cookies.txt` and put it in the same folder as the script
4. Export your Apple Music cookies as `cookies.txt` and put it on the same folder that you will run the script
* You can export your cookies by using this Google Chrome extension on Apple Music website: https://chrome.google.com/webstore/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif. Make sure to be logged in.
5. Put your L3 Widevine Keys (`device_client_id_blob` and `device_private_key` files) on `./pywidevine/L3/cdm/devices/android_generic` folder
* You can get your L3 Widevine Keys by using Dumper: https://github.com/Diazole/dumper
5. Put your L3 Widevine Device Keys (`device_client_id_blob` and `device_private_key` files) on the same folder that you will run the script
* You can get your L3 Widevine Device Keys by using Dumper: https://github.com/Diazole/dumper
* The generated `private_key.pem` and `client_id.bin` files should be renamed to `device_private_key` and `device_client_id_blob` respectively.
6. (optional) Add aria2c to your PATH for faster downloads
* You can get it from here: https://aria2.github.io/.
* You can get it from here: https://github.com/aria2/aria2/releases.
## Usage
```
python gamdl.py [OPTIONS] [URLS]
```
Tracks are saved in `./Apple Music` by default, but the directory can be changed using `--final-path` argument.
usage: gamdl [-h] [-u [URLS_TXT]] [-d DEVICE_PATH] [-f FINAL_PATH] [-t TEMP_PATH] [-c COOKIES_LOCATION] [-m]
[-p] [-n] [-s] [-e] [-y] [-v]
[url ...]
Use `--help` argument to see all available options.
Download Apple Music songs/music videos/albums/playlists
positional arguments:
url Apple Music song/music video/album/playlist URL(s) (default: None)
options:
-h, --help show this help message and exit
-u [URLS_TXT], --urls-txt [URLS_TXT]
Read URLs from a text file (default: None)
-d DEVICE_PATH, --device-path DEVICE_PATH
Widevine L3 device keys path (default: .)
-f FINAL_PATH, --final-path FINAL_PATH
Final Path (default: Apple Music)
-t TEMP_PATH, --temp-path TEMP_PATH
Temp Path (default: temp)
-c COOKIES_LOCATION, --cookies-location COOKIES_LOCATION
Cookies location (default: cookies.txt)
-m, --disable-music-video-skip
Disable music video skip on playlists/albums (default: False)
-p, --prefer-hevc Prefer HEVC over AVC (default: False)
-n, --no-lrc Don't create .lrc file (default: False)
-s, --skip-cleanup Skip cleanup (default: False)
-e, --print-exceptions
Print execeptions (default: False)
-y, --print-video-playlist
Print Video M3U8 Playlist (default: False)
-v, --version show program's version number and exit
```
## Songs/Music Videos quality
* Songs:
* AAC 256kbps M4A
* 256kbps AAC
* Music Videos (varies depending on the video):
* HEVC 4K 12~20mbps M4V / AAC 256kbps (achieved by using `--prefer-hevc` argument)
* AVC 1080p 6.5~10mbps M4V / AAC 256kbps
* AVC 720p 4mbps M4V / AAC 256kbps
* AVC 480p 1.5mbps M4V / AAC 256kbps
* AVC 360p 1mbps M4V / AAC 256kbps
* 4K HEVC 20mbps / AAC 256kbps
* 4K HEVC 12mbps / AAC 256kbps
* 1080p AVC 10mbps / AAC 256kbps
* 1080p AVC 6.5bps / AAC 256kbps
* 720p AVC 4mbps / AAC 256kbps
* 480p AVC 1.5mbps / AAC 256kbps
* 360p AVC 1mbps / AAC 256kbps
Some videos may include EIA-608 captions.
Some videos may include EIA-608 closed captions.
+172
View File
@@ -0,0 +1,172 @@
import shutil
import argparse
import traceback
from .gamdl import Gamdl
__version__ = '1.0'
def main():
if not shutil.which('mp4decrypt'):
raise Exception('mp4decrypt is not on PATH')
if not shutil.which('MP4Box'):
raise Exception('MP4Box is not on PATH')
parser = argparse.ArgumentParser(
description = 'Download Apple Music songs/music videos/albums/playlists',
formatter_class = argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
'url',
help = 'Apple Music song/music video/album/playlist URL(s)',
nargs = '*'
)
parser.add_argument(
'-u',
'--urls-txt',
help = 'Read URLs from a text file',
nargs = '?'
)
parser.add_argument(
'-d',
'--device-path',
default = '.',
help = 'Widevine L3 device keys path'
)
parser.add_argument(
'-f',
'--final-path',
default = 'Apple Music',
help = 'Final Path'
)
parser.add_argument(
'-t',
'--temp-path',
default = 'temp',
help = 'Temp Path'
)
parser.add_argument(
'-c',
'--cookies-location',
default = 'cookies.txt',
help = 'Cookies location'
)
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(
'-s',
'--skip-cleanup',
action = 'store_true',
help = 'Skip cleanup'
)
parser.add_argument(
'-e',
'--print-exceptions',
action = 'store_true',
help = 'Print execeptions'
)
parser.add_argument(
'-y',
'--print-video-playlist',
action = 'store_true',
help = 'Print Video M3U8 Playlist'
)
parser.add_argument(
'-v',
'--version',
action = 'version',
version = f'%(prog)s {__version__}'
)
args = parser.parse_args()
if not args.url and not args.urls_txt:
parser.error('you must specify an url or a text file using -u/--urls-txt')
if args.urls_txt:
with open(args.urls_txt, 'r', encoding = 'utf8') as f:
args.url = f.read().splitlines()
dl = Gamdl(
args.device_path,
args.cookies_location,
args.disable_music_video_skip,
args.prefer_hevc,
args.temp_path,
args.final_path,
args.no_lrc,
args.skip_cleanup
)
error_count = 0
download_queue = []
for i, url in enumerate(args.url):
try:
download_queue.append(dl.get_download_queue(url.strip()))
except KeyboardInterrupt:
exit(1)
except:
error_count += 1
print(f'* Failed to check URL {i + 1}.')
if args.print_exceptions:
traceback.print_exc()
for i, url in enumerate(download_queue):
for j, track in enumerate(url):
print(f'Downloading "{track["attributes"]["name"]}" (track {j + 1} from URL {i + 1})...')
track_id = track['id']
try:
webplayback = dl.get_webplayback(track_id)
if track['type'] == 'music-videos':
playlist = dl.get_playlist_music_video(webplayback)
if args.print_video_playlist:
print(playlist.dumps())
stream_url_audio = dl.get_stream_url_music_video_audio(playlist)
decryption_keys_audio = dl.get_decryption_keys_music_video(stream_url_audio, track_id)
encrypted_location_audio = dl.get_encrypted_location_audio(track_id)
dl.download(encrypted_location_audio, stream_url_audio)
decrypted_location_audio = dl.get_decrypted_location_audio(track_id)
dl.decrypt(encrypted_location_audio, decrypted_location_audio, decryption_keys_audio)
stream_url_video = dl.get_stream_url_music_video_video(playlist)
decryption_keys_video = dl.get_decryption_keys_music_video(stream_url_video, track_id)
encrypted_location_video = dl.get_encrypted_location_video(track_id)
dl.download(encrypted_location_video, stream_url_video)
decrypted_location_video = dl.get_decrypted_location_video(track_id)
dl.decrypt(encrypted_location_video, decrypted_location_video, decryption_keys_video)
tags = dl.get_tags_music_video(track['attributes']['url'].split('/')[-1])
fixed_location = dl.get_fixed_location(track_id, '.m4v')
final_location = dl.get_final_location('.m4v', tags)
dl.fixup_music_video(decrypted_location_audio, decrypted_location_video, fixed_location)
dl.make_final(final_location, fixed_location, tags)
else:
stream_url = dl.get_stream_url_song(webplayback)
decryption_keys = dl.get_decryption_keys_song(stream_url, track_id)
encrypted_location = dl.get_encrypted_location_audio(track_id)
dl.download(encrypted_location, stream_url)
decrypted_location = dl.get_decrypted_location_audio(track_id)
dl.decrypt(encrypted_location, decrypted_location, decryption_keys)
unsynced_lyrics, synced_lyrics = dl.get_lyrics(track_id)
tags = dl.get_tags_song(webplayback, unsynced_lyrics, track['attributes']['genreNames'][0])
fixed_location = dl.get_fixed_location(track_id, '.m4a')
final_location = dl.get_final_location('.m4a', tags)
dl.fixup_song(decrypted_location, fixed_location)
dl.make_final(final_location, fixed_location, tags)
dl.make_lrc(final_location, synced_lyrics)
except KeyboardInterrupt:
exit(1)
except:
error_count += 1
print(f'* Failed to download "{track["attributes"]["name"]}" (track {j + 1} from URL {i + 1}).')
if args.print_exceptions:
traceback.print_exc()
dl.cleanup()
print(f'Done ({error_count} error(s)).')
+4
View File
@@ -0,0 +1,4 @@
import gamdl
if __name__ == "__main__":
gamdl.main()
+79 -256
View File
@@ -1,56 +1,59 @@
from pathlib import Path
import datetime
import argparse
import shutil
import traceback
import subprocess
from http.cookiejar import MozillaCookieJar
import re
from xml.etree import ElementTree
import base64
import datetime
from xml.etree import ElementTree
import functools
import song_genres
import music_video_genres
import storefront_ids
from pywidevine.L3.cdm.cdm import Cdm
from pywidevine.L3.cdm import deviceconfig
from pywidevine.L3.cdm.formats.widevine_pssh_data_pb2 import WidevinePsshData
import subprocess
import shutil
import gamdl.storefront_ids
from pywidevine import Cdm
from pywidevine import Device
import requests
import m3u8
from yt_dlp import YoutubeDL
from mutagen.mp4 import MP4Cover, MP4
from pywidevine.pssh import PSSH
from pywidevine.license_protocol_pb2 import WidevinePsshData
from mutagen.mp4 import MP4, MP4Cover
class Gamdl:
def __init__(self, disable_music_video_skip, cookies_location, temp_path, prefer_hevc, final_path, skip_cleanup, print_video_playlist, no_lrc):
self.cdm = Cdm()
def __init__(self, device_path, cookies_location, disable_music_video_skip, prefer_hevc, temp_path, final_path, no_lrc, skip_cleanup):
self.disable_music_video_skip = disable_music_video_skip
self.temp_path = Path(temp_path)
self.prefer_hevc = prefer_hevc
self.temp_path = Path(temp_path)
self.final_path = Path(final_path)
self.skip_cleanup = skip_cleanup
self.print_video_playlist = print_video_playlist
self.no_lrc = no_lrc
cookies = {}
with open(Path(cookies_location), '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]
self.skip_cleanup = skip_cleanup
with open(Path(device_path) / 'device_client_id_blob', 'rb') as client_id, open(Path(device_path) / 'device_private_key', 'rb') as private_key:
self.cdm = Cdm.from_device(
Device(
type_ = 'ANDROID',
security_level = 3,
flags = None,
private_key = private_key.read(),
client_id = client_id.read()
)
)
self.cdm_session = self.cdm.open()
cookies = MozillaCookieJar(Path(cookies_location))
cookies.load(ignore_discard = True, ignore_expires = True)
self.session = requests.Session()
self.session.cookies.update(cookies)
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"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",
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'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'
})
web_page = self.session.get('https://beta.music.apple.com').text
@@ -58,14 +61,14 @@ class Gamdl:
index_js_page = self.session.get(f'https://beta.music.apple.com/assets/index.{index_js_uri}.js').text
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f'Bearer {token}'})
self.country = cookies['itua'].lower()
self.storefront = getattr(storefront_ids, self.country.upper())
self.country = self.session.cookies.get_dict()['itua']
self.storefront = getattr(gamdl.storefront_ids, self.country.upper())
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://amp-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]
response = self.session.get(f'https://amp-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}&l=en').json()['data'][0]
if response['type'] in ('songs', 'music-videos') and 'playParams' in response['attributes']:
download_queue.append(response)
if response['type'] == 'albums' or response['type'] == 'playlists':
@@ -78,7 +81,7 @@ class Gamdl:
if not download_queue:
raise Exception('Criteria not met')
return download_queue
def get_webplayback(self, track_id):
response = self.session.post(
@@ -91,10 +94,7 @@ class Gamdl:
def get_playlist_music_video(self, webplayback):
playlist = m3u8.load(webplayback['hls-playlist-url'])
if self.print_video_playlist:
print(playlist.dumps())
return playlist
return m3u8.load(webplayback['hls-playlist-url'])
def get_stream_url_song(self, webplayback):
@@ -111,17 +111,25 @@ class Gamdl:
else:
return [i for i in playlist.playlists if 'avc' in i.stream_info.codecs][-1].uri
def get_encrypted_location(self, extension, track_id):
return self.temp_path / f'{track_id}_encrypted{extension}'
def get_encrypted_location_video(self, track_id):
return self.temp_path / f'{track_id}_encrypted_video.mp4'
def get_decrypted_location(self, extension, track_id):
return self.temp_path / f'{track_id}_decrypted{extension}'
def get_encrypted_location_audio(self, track_id):
return self.temp_path / f'{track_id}_encrypted_audio.mp4'
def get_fixed_location(self, extension, track_id):
return self.temp_path / f'{track_id}_fixed{extension}'
def get_decrypted_location_video(self, track_id):
return self.temp_path / f'{track_id}_decrypted_video.mp4'
def get_decrypted_location_audio(self, track_id):
return self.temp_path / f'{track_id}_decrypted_audio.mp4'
def get_fixed_location(self, track_id, file_extension):
return self.temp_path / f'{track_id}_fixed{file_extension}'
def download(self, encrypted_location, stream_url):
@@ -135,8 +143,8 @@ class Gamdl:
'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',
@@ -149,60 +157,29 @@ class Gamdl:
'user-initiated': True
}
).json()['license']
def check_pssh(self, pssh_b64):
WV_SYSTEM_ID = [237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237]
pssh = base64.b64decode(pssh_b64)
if not pssh[12:28] == bytes(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:] = 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
def get_decryption_keys_music_video(self, stream_url, track_id):
playlist = m3u8.load(stream_url)
track_uri = next(i for i in playlist.keys if i.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed").uri
session = self.cdm.open_session(
self.check_pssh(track_uri.split(',')[1]),
deviceconfig.DeviceConfig(deviceconfig.device_android_generic)
)
challenge = base64.b64encode(self.cdm.get_license_request(session)).decode('utf8')
pssh = PSSH(track_uri.split(',')[1])
challenge = base64.b64encode(self.cdm.get_license_challenge(self.cdm_session, pssh)).decode('utf-8')
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
self.cdm.provide_license(session, license_b64)
decryption_keys = []
for key in self.cdm.get_keys(session):
if key.type == 'CONTENT':
decryption_keys.append(f'1:{key.key.hex()}')
return decryption_keys[0]
self.cdm.parse_license(self.cdm_session, license_b64)
return f'1:{next(i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT").key.hex()}'
def get_decryption_keys_song(self, stream_url, track_id):
track_uri = m3u8.load(stream_url).keys[0].uri
wvpsshdata = WidevinePsshData()
wvpsshdata.algorithm = 1
wvpsshdata.key_id.append(base64.b64decode(track_uri.split(",")[1]))
session = self.cdm.open_session(
self.check_pssh(base64.b64encode(wvpsshdata.SerializeToString()).decode("utf8")),
deviceconfig.DeviceConfig(deviceconfig.device_android_generic)
)
challenge = base64.b64encode(self.cdm.get_license_request(session)).decode('utf8')
wvpsshdata.key_ids.append(base64.b64decode(track_uri.split(",")[1]))
pssh = PSSH(base64.b64encode(wvpsshdata.SerializeToString()).decode('utf-8'))
challenge = base64.b64encode(self.cdm.get_license_challenge(self.cdm_session, pssh)).decode('utf-8')
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
self.cdm.provide_license(session, license_b64)
decryption_keys = []
for key in self.cdm.get_keys(session):
if key.type == 'CONTENT':
decryption_keys.append(f'{key.kid.hex()}:{key.key.hex()}')
return decryption_keys[0]
self.cdm.parse_license(self.cdm_session, license_b64)
return f'1:{next(i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT").key.hex()}'
def decrypt(self, encrypted_location, decrypted_location, decryption_keys):
subprocess.run(
@@ -258,12 +235,12 @@ class Gamdl:
return requests.get(url).content
def get_tags_song(self, webplayback, unsynced_lyrics):
def get_tags_song(self, webplayback, unsynced_lyrics, genre):
metadata = next(i for i in webplayback["assets"] if i["flavor"] == "28:ctrp256")['metadata']
cover_url = next(i for i in webplayback["assets"] if i["flavor"] == "28:ctrp256")['artworkURL']
tags = {
'\xa9nam': [metadata['itemName']],
'\xa9gen': [getattr(song_genres, f'ID{metadata["genreId"]}')],
'\xa9gen': [genre],
'aART': [metadata['playlistArtistName']],
'\xa9alb': [metadata['playlistName']],
'soar': [metadata['sort-artist']],
@@ -298,17 +275,17 @@ class Gamdl:
if unsynced_lyrics:
tags['\xa9lyr'] = [unsynced_lyrics]
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']
metadata = requests.get(f'https://itunes.apple.com/lookup?id={track_id}&entity=album&limit=200&country={self.country}&lang=en_US').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"]}')],
'\xa9gen': [metadata[0]['primaryGenreName']],
'stik': [6],
'atID': [metadata[0]['artistId']],
'cnID': [metadata[0]["trackId"]],
@@ -329,7 +306,7 @@ class Gamdl:
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):
for character in ['\\', '/', ':', '*', '?', '"', '<', '>', '|', ';']:
@@ -386,7 +363,6 @@ class Gamdl:
decrypted_location_video,
'-itags',
'artist=placeholder',
'-new',
fixed_location
],
check = True
@@ -401,8 +377,7 @@ class Gamdl:
'-add',
decrypted_location,
'-itags',
'album=placeholder',
'-new',
'artist=placeholder',
fixed_location
],
check = True
@@ -419,163 +394,11 @@ class Gamdl:
final_location.parent.mkdir(parents = True, exist_ok = True)
shutil.copy(fixed_location, final_location)
file = MP4(final_location).tags
for key, value in tags.items():
file[key] = value
file.update(tags)
file.save(final_location)
def cleanup(self):
if self.temp_path.exists() and not self.skip_cleanup:
shutil.rmtree(self.temp_path)
if __name__ == '__main__':
if not shutil.which('mp4decrypt'):
raise Exception('mp4decrypt is not on PATH')
if not shutil.which('MP4Box'):
raise Exception('MP4Box is not on PATH')
parser = argparse.ArgumentParser(
description = 'A Python script to download Apple Music songs/music videos/albums/playlists.',
formatter_class = argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
'url',
help = 'Apple Music song/music video/album/playlist URL(s).',
nargs = '*'
)
parser.add_argument(
'-u',
'--urls-txt',
help = 'Read URLs from a text file.',
nargs = '?'
)
parser.add_argument(
'-f',
'--final-path',
default = 'Apple Music',
help = 'Final Path.'
)
parser.add_argument(
'-t',
'--temp-path',
default = 'temp',
help = 'Temp Path.'
)
parser.add_argument(
'-c',
'--cookies-location',
default = 'cookies.txt',
help = 'Cookies location.'
)
parser.add_argument(
'-d',
'--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(
'-s',
'--skip-cleanup',
action = 'store_true',
help = 'Skip cleanup.'
)
parser.add_argument(
'-e',
'--print-exceptions',
action = 'store_true',
help = 'Print execeptions.'
)
parser.add_argument(
'-v',
'--print-video-playlist',
action = 'store_true',
help = 'Print Video M3U8 Playlist.'
)
args = parser.parse_args()
if not args.url and not args.urls_txt:
parser.error('you must specify an url or a text file using -u/--urls-txt.')
if args.urls_txt:
with open(args.urls_txt, 'r', encoding = 'utf8') as f:
args.url = f.read().splitlines()
dl = Gamdl(
args.disable_music_video_skip,
args.cookies_location,
args.temp_path,
args.prefer_hevc,
args.final_path,
args.skip_cleanup,
args.print_video_playlist,
args.no_lrc
)
error_count = 0
download_queue = []
for i, url in enumerate(args.url):
try:
download_queue.append(dl.get_download_queue(url.strip()))
except KeyboardInterrupt:
exit(1)
except:
error_count += 1
print(f'* Failed to check URL {i + 1}.')
if args.print_exceptions:
traceback.print_exc()
for i, url in enumerate(download_queue):
for j, track in enumerate(url):
print(f'Downloading "{track["attributes"]["name"]}" (track {j + 1} from URL {i + 1})...')
track_id = track['id']
try:
webplayback = dl.get_webplayback(track_id)
if track['type'] == 'music-videos':
playlist = dl.get_playlist_music_video(webplayback)
stream_url_audio = dl.get_stream_url_music_video_audio(playlist)
decryption_keys_audio = dl.get_decryption_keys_music_video(stream_url_audio, track_id)
encrypted_location_audio = dl.get_encrypted_location('.m4a', track_id)
dl.download(encrypted_location_audio, stream_url_audio)
decrypted_location_audio = dl.get_decrypted_location('.m4a', track_id)
dl.decrypt(encrypted_location_audio, decrypted_location_audio, decryption_keys_audio)
stream_url_video = dl.get_stream_url_music_video_video(playlist)
decryption_keys_video = dl.get_decryption_keys_music_video(stream_url_video, track_id)
encrypted_location_video = dl.get_encrypted_location('.m4v', track_id)
dl.download(encrypted_location_video, stream_url_video)
decrypted_location_video = dl.get_decrypted_location('.m4v', track_id)
dl.decrypt(encrypted_location_video, decrypted_location_video, decryption_keys_video)
tags = dl.get_tags_music_video(track['attributes']['url'].split('/')[-1])
fixed_location = dl.get_fixed_location('.m4v', track_id)
final_location = dl.get_final_location('.m4v', tags)
dl.fixup_music_video(decrypted_location_audio, decrypted_location_video, fixed_location)
dl.make_final(final_location, fixed_location, tags)
else:
stream_url = dl.get_stream_url_song(webplayback)
decryption_keys = dl.get_decryption_keys_song(stream_url, track_id)
encrypted_location = dl.get_encrypted_location('.m4a', track_id)
dl.download(encrypted_location, stream_url)
decrypted_location = dl.get_decrypted_location('.m4a', track_id)
dl.decrypt(encrypted_location, decrypted_location, decryption_keys)
unsynced_lyrics, synced_lyrics = dl.get_lyrics(track_id)
tags = dl.get_tags_song(webplayback, unsynced_lyrics)
fixed_location = dl.get_fixed_location('.m4a', track_id)
final_location = dl.get_final_location('.m4a', tags)
dl.fixup_song(decrypted_location, fixed_location)
dl.make_final(final_location, fixed_location, tags)
dl.make_lrc(final_location, synced_lyrics)
except KeyboardInterrupt:
exit(1)
except:
error_count += 1
print(f'* Failed to download "{track["attributes"]["name"]}" (track {j + 1} from URL {i + 1}).')
if args.print_exceptions:
traceback.print_exc()
dl.cleanup()
print(f'Done ({error_count} error(s)).')
-433
View File
@@ -1,433 +0,0 @@
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"
+24
View File
@@ -0,0 +1,24 @@
[project]
name = "gamdl"
description = "Download Apple Music songs/music videos/albums/playlists"
requires-python = ">=3.7"
authors = [{name = "glomatico"}]
dependencies = [
"pywidevine",
"pyyaml",
"m3u8",
"yt-dlp"
]
readme = "README.md"
dynamic = ["version"]
[project.urls]
homepage = "https://github.com/glomatico/gamdl"
repository = "https://github.com/glomatico/gamdl"
[build-system]
requires = ["flit_core"]
build-backend = "flit_core.buildapi"
[project.scripts]
gamdl = "gamdl:main"
View File
View File
-362
View File
@@ -1,362 +0,0 @@
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
-53
View File
@@ -1,53 +0,0 @@
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)
@@ -1,56 +0,0 @@
// 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;
}
@@ -1,47 +0,0 @@
# -*- 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)
-466
View File
@@ -1,466 +0,0 @@
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
-389
View File
@@ -1,389 +0,0 @@
// 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
-14
View File
@@ -1,14 +0,0 @@
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))
-18
View File
@@ -1,18 +0,0 @@
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 = []
-102
View File
@@ -1,102 +0,0 @@
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
+3 -3
View File
@@ -1,4 +1,4 @@
yt-dlp
protobuf==3.20.*
requests
pywidevine
pyyaml
m3u8
yt-dlp
-438
View File
@@ -1,438 +0,0 @@
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"