mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 04:05:14 +03:00
New structure
pypi
This commit is contained in:
+3
-9
@@ -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
|
||||
|
||||
@@ -1,54 +1,83 @@
|
||||
# Glomatico's ✨ Apple Music ✨ Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -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)).')
|
||||
@@ -0,0 +1,4 @@
|
||||
import gamdl
|
||||
|
||||
if __name__ == "__main__":
|
||||
gamdl.main()
|
||||
+79
-256
@@ -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)).')
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
@@ -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 = []
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
yt-dlp
|
||||
protobuf==3.20.*
|
||||
requests
|
||||
pywidevine
|
||||
pyyaml
|
||||
m3u8
|
||||
yt-dlp
|
||||
|
||||
-438
@@ -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"
|
||||
Reference in New Issue
Block a user