Compare commits

...

2 Commits

Author SHA1 Message Date
R. M 8285c41617 English tags 2023-02-15 18:21:47 -03:00
R. M a531bb4898 .wvd file 2023-02-09 20:20:09 -03:00
3 changed files with 73 additions and 54 deletions
+20 -12
View File
@@ -1,7 +1,7 @@
# Glomatico's ✨ Apple Music ✨ Downloader
A Python script to download Apple Music songs/music videos/albums/playlists.
![Windows CMD usage example](https://i.imgur.com/6WeUCFh.png)
![Windows CMD usage example](https://i.imgur.com/18Azlg4.png)
This is a rework of https://github.com/loveyoursupport/AppleMusic-Downloader/tree/661a274d62586b521feec5a7de6bee0e230fdb7d.
@@ -25,19 +25,26 @@ Some new features that I added:
* 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 on the same folder that you will run the script
4. Export your Apple Music cookies as `cookies.txt` to 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 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.
5. Put your Widevine Device file (.wvd) in the same folder that you will run the script
* You can use Dumper to dump your phone's L3 CDM: https://github.com/Diazole/dumper. Once you have the L3 CDM, you can use pywidevine to create the .wvd file from it.
1. Install pywidevine with pip
```
pip install pywidevine pyyaml
```
2. Create the .wvd file
```
pywidevine create-device -t ANDROID -l 3 -k private_key.pem -c client_id.bin -o .
```
6. (optional) Add aria2c to your PATH for faster downloads
* You can get it from here: https://github.com/aria2/aria2/releases.
## Usage
```
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 ...]
usage: gamdl [-h] [-u [URLS_TXT]] [-w WVD_LOCATION] [-f FINAL_PATH] [-t TEMP_PATH] [-c COOKIES_LOCATION] [-m] [-p]
[-o] [-n] [-s] [-e] [-i] [-v]
[url ...]
Download Apple Music songs/music videos/albums/playlists
@@ -48,8 +55,8 @@ 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: .)
-w WVD_LOCATION, --wvd-location WVD_LOCATION
.wvd file location (default: *.wvd)
-f FINAL_PATH, --final-path FINAL_PATH
Final Path (default: Apple Music)
-t TEMP_PATH, --temp-path TEMP_PATH
@@ -59,12 +66,13 @@ options:
-m, --disable-music-video-skip
Disable music video skip on playlists/albums (default: False)
-p, --prefer-hevc Prefer HEVC over AVC (default: False)
-o, --overwrite Overwrite existing files (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)
-i, --print-video-m3u8-url
Print Video M3U8 URL (default: False)
-v, --version show program's version number and exit
```
+30 -19
View File
@@ -3,7 +3,7 @@ import argparse
import traceback
from .gamdl import Gamdl
__version__ = '1.0'
__version__ = '1.2'
def main():
@@ -27,10 +27,10 @@ def main():
nargs = '?'
)
parser.add_argument(
'-d',
'--device-path',
default = '.',
help = 'Widevine L3 device keys path'
'-w',
'--wvd-location',
default = '*.wvd',
help = '.wvd file location'
)
parser.add_argument(
'-f',
@@ -62,6 +62,12 @@ def main():
action = 'store_true',
help = 'Prefer HEVC over AVC'
)
parser.add_argument(
'-o',
'--overwrite',
action = 'store_true',
help = 'Overwrite existing files'
)
parser.add_argument(
'-n',
'--no-lrc',
@@ -81,10 +87,10 @@ def main():
help = 'Print execeptions'
)
parser.add_argument(
'-y',
'--print-video-playlist',
'-i',
'--print-video-m3u8-url',
action = 'store_true',
help = 'Print Video M3U8 Playlist'
help = 'Print Video M3U8 URL'
)
parser.add_argument(
'-v',
@@ -99,13 +105,14 @@ def main():
with open(args.urls_txt, 'r', encoding = 'utf8') as f:
args.url = f.read().splitlines()
dl = Gamdl(
args.device_path,
args.wvd_location,
args.cookies_location,
args.disable_music_video_skip,
args.prefer_hevc,
args.temp_path,
args.final_path,
args.no_lrc,
args.overwrite,
args.skip_cleanup
)
error_count = 0
@@ -122,14 +129,18 @@ def main():
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})...')
print(f'Downloading "{track["attributes"]["name"]}" (track {j + 1}/{len(url)} from URL {i + 1}/{len(download_queue)})')
track_id = track['id']
try:
webplayback = dl.get_webplayback(track_id)
if track['type'] == 'music-videos':
if args.print_video_m3u8_url:
print(webplayback['hls-playlist-url'])
tags = dl.get_tags_music_video(track['attributes']['url'].split('/')[-1].split('?')[0])
final_location = dl.get_final_location('.m4v', tags)
if dl.check_exists(final_location) and not args.overwrite:
continue
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)
@@ -142,22 +153,22 @@ def main():
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:
unsynced_lyrics, synced_lyrics = dl.get_lyrics(track_id)
tags = dl.get_tags_song(webplayback, unsynced_lyrics)
final_location = dl.get_final_location('.m4a', tags)
if dl.check_exists(final_location) and not args.overwrite:
continue
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)
@@ -165,8 +176,8 @@ def main():
exit(1)
except:
error_count += 1
print(f'* Failed to download "{track["attributes"]["name"]}" (track {j + 1} from URL {i + 1}).')
print(f'Failed to download "{track["attributes"]["name"]}" (track {j + 1}/{len(url)} from URL {i + 1}/{len(download_queue)})')
if args.print_exceptions:
traceback.print_exc()
dl.cleanup()
print(f'Done ({error_count} error(s)).')
print(f'Done ({error_count} error(s))')
+23 -23
View File
@@ -1,4 +1,5 @@
from pathlib import Path
import glob
from http.cookiejar import MozillaCookieJar
import re
import base64
@@ -19,23 +20,18 @@ from mutagen.mp4 import MP4, MP4Cover
class Gamdl:
def __init__(self, device_path, cookies_location, disable_music_video_skip, prefer_hevc, temp_path, final_path, no_lrc, skip_cleanup):
def __init__(self, wvd_location, cookies_location, disable_music_video_skip, prefer_hevc, temp_path, final_path, no_lrc, overwrite, skip_cleanup):
self.disable_music_video_skip = disable_music_video_skip
self.prefer_hevc = prefer_hevc
self.temp_path = Path(temp_path)
self.final_path = Path(final_path)
self.no_lrc = no_lrc
self.overwrite = overwrite
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()
)
)
wvd_location = glob.glob(wvd_location)
if not wvd_location:
raise Exception('.wvd file not found')
self.cdm = Cdm.from_device(Device.load(Path(wvd_location[0])))
self.cdm_session = self.cdm.open()
cookies = MozillaCookieJar(Path(cookies_location))
cookies.load(ignore_discard = True, ignore_expires = True)
@@ -68,7 +64,7 @@ class Gamdl:
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}&l=en').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}').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':
@@ -87,7 +83,8 @@ class Gamdl:
response = self.session.post(
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback',
json = {
'salableAdamId': track_id
'salableAdamId': track_id,
'language': 'en-US'
}
).json()["songList"][0]
return response
@@ -112,6 +109,10 @@ class Gamdl:
return [i for i in playlist.playlists if 'avc' in i.stream_info.codecs][-1].uri
def check_exists(self, final_location):
return Path(final_location).exists()
def get_encrypted_location_video(self, track_id):
return self.temp_path / f'{track_id}_encrypted_video.mp4'
@@ -139,7 +140,7 @@ class Gamdl:
'outtmpl': str(encrypted_location),
'allow_unplayable_formats': True,
'fixup': 'never',
'overwrites': True,
'overwrites': self.overwrite,
'external_downloader': 'aria2c'
}) as ydl:
ydl.download(stream_url)
@@ -171,10 +172,10 @@ class Gamdl:
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_ids.append(base64.b64decode(track_uri.split(",")[1]))
pssh = PSSH(base64.b64encode(wvpsshdata.SerializeToString()).decode('utf-8'))
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(base64.b64decode(track_uri.split(",")[1]))
pssh = PSSH(base64.b64encode(widevine_pssh_data.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.parse_license(self.cdm_session, license_b64)
@@ -235,12 +236,12 @@ class Gamdl:
return requests.get(url).content
def get_tags_song(self, webplayback, unsynced_lyrics, genre):
def get_tags_song(self, webplayback, unsynced_lyrics):
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': [genre],
'\xa9gen': [metadata['genre']],
'aART': [metadata['playlistArtistName']],
'\xa9alb': [metadata['playlistName']],
'soar': [metadata['sort-artist']],
@@ -393,12 +394,11 @@ class Gamdl:
def make_final(self, final_location, fixed_location, tags):
final_location.parent.mkdir(parents = True, exist_ok = True)
shutil.copy(fixed_location, final_location)
file = MP4(final_location).tags
file = MP4(final_location)
file.update(tags)
file.save(final_location)
file.save()
def cleanup(self):
if self.temp_path.exists() and not self.skip_cleanup:
shutil.rmtree(self.temp_path)