musicdl 2.1.11__py3-none-any.whl → 2.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. musicdl/__init__.py +5 -5
  2. musicdl/modules/__init__.py +10 -3
  3. musicdl/modules/common/__init__.py +2 -0
  4. musicdl/modules/common/gdstudio.py +204 -0
  5. musicdl/modules/js/__init__.py +1 -0
  6. musicdl/modules/js/youtube/__init__.py +2 -0
  7. musicdl/modules/js/youtube/botguard.js +1 -0
  8. musicdl/modules/js/youtube/jsinterp.py +902 -0
  9. musicdl/modules/js/youtube/runner.js +2 -0
  10. musicdl/modules/sources/__init__.py +41 -10
  11. musicdl/modules/sources/apple.py +207 -0
  12. musicdl/modules/sources/base.py +256 -28
  13. musicdl/modules/sources/bilibili.py +118 -0
  14. musicdl/modules/sources/buguyy.py +148 -0
  15. musicdl/modules/sources/fangpi.py +153 -0
  16. musicdl/modules/sources/fivesing.py +108 -0
  17. musicdl/modules/sources/gequbao.py +148 -0
  18. musicdl/modules/sources/jamendo.py +108 -0
  19. musicdl/modules/sources/joox.py +104 -68
  20. musicdl/modules/sources/kugou.py +129 -76
  21. musicdl/modules/sources/kuwo.py +188 -68
  22. musicdl/modules/sources/lizhi.py +107 -0
  23. musicdl/modules/sources/migu.py +172 -66
  24. musicdl/modules/sources/mitu.py +140 -0
  25. musicdl/modules/sources/mp3juice.py +264 -0
  26. musicdl/modules/sources/netease.py +163 -115
  27. musicdl/modules/sources/qianqian.py +125 -77
  28. musicdl/modules/sources/qq.py +232 -94
  29. musicdl/modules/sources/tidal.py +342 -0
  30. musicdl/modules/sources/ximalaya.py +256 -0
  31. musicdl/modules/sources/yinyuedao.py +144 -0
  32. musicdl/modules/sources/youtube.py +238 -0
  33. musicdl/modules/utils/__init__.py +12 -4
  34. musicdl/modules/utils/appleutils.py +563 -0
  35. musicdl/modules/utils/data.py +107 -0
  36. musicdl/modules/utils/logger.py +211 -58
  37. musicdl/modules/utils/lyric.py +73 -0
  38. musicdl/modules/utils/misc.py +335 -23
  39. musicdl/modules/utils/modulebuilder.py +75 -0
  40. musicdl/modules/utils/neteaseutils.py +81 -0
  41. musicdl/modules/utils/qqutils.py +184 -0
  42. musicdl/modules/utils/quarkparser.py +105 -0
  43. musicdl/modules/utils/songinfoutils.py +54 -0
  44. musicdl/modules/utils/tidalutils.py +738 -0
  45. musicdl/modules/utils/youtubeutils.py +3606 -0
  46. musicdl/musicdl.py +184 -86
  47. musicdl-2.7.3.dist-info/LICENSE +203 -0
  48. musicdl-2.7.3.dist-info/METADATA +704 -0
  49. musicdl-2.7.3.dist-info/RECORD +53 -0
  50. {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/WHEEL +5 -5
  51. musicdl-2.7.3.dist-info/entry_points.txt +2 -0
  52. musicdl/modules/sources/baiduFlac.py +0 -69
  53. musicdl/modules/sources/xiami.py +0 -104
  54. musicdl/modules/utils/downloader.py +0 -80
  55. musicdl-2.1.11.dist-info/LICENSE +0 -22
  56. musicdl-2.1.11.dist-info/METADATA +0 -82
  57. musicdl-2.1.11.dist-info/RECORD +0 -24
  58. {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/top_level.txt +0 -0
  59. {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/zip-safe +0 -0
@@ -0,0 +1,563 @@
1
+ '''
2
+ Function:
3
+ Implementation of AppleMusicClient utils, refer to
4
+ Author:
5
+ Zhenchao Jin
6
+ WeChat Official Account (微信公众号):
7
+ Charles的皮卡丘
8
+ '''
9
+ import re
10
+ import os
11
+ import m3u8
12
+ import uuid
13
+ import json
14
+ import base64
15
+ import shutil
16
+ import datetime
17
+ import requests
18
+ import subprocess
19
+ from enum import Enum
20
+ from typing import Any
21
+ from pathlib import Path
22
+ from xml.dom import minidom
23
+ from xml.etree import ElementTree
24
+ from dataclasses import dataclass
25
+ from platformdirs import user_log_dir
26
+ from pywidevine import PSSH, Cdm, Device
27
+ from pywidevine.license_protocol_pb2 import WidevinePsshData
28
+
29
+
30
+ '''CONSTANTS'''
31
+ MEDIA_TYPE_STR_MAP = {1: "Song", 6: "Music Video"}
32
+ MEDIA_RATING_STR_MAP = {0: "None", 1: "Explicit", 2: "Clean"}
33
+ LEGACY_SONG_CODECS = {"aac-legacy", "aac-he-legacy"}
34
+ DRM_DEFAULT_KEY_MAPPING = {
35
+ "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": ("data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAAAAAAAczEvZTEgICBI88aJmwY="),
36
+ "com.microsoft.playready": ("data:text/plain;charset=UTF-16;base64,vgEAAAEAAQC0ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADMALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAFMAPgA8AEsASQBEACAAQQBMAEcASQBEAD0AIgBBAEUAUwBDAEIAQwAiACAAVgBBAEwAVQBFAD0AIgBBAEEAQQBBAEEAQQBBAEEAQQBBAEIAegBNAFMAOQBsAE0AUwBBAGcASQBBAD0APQAiAD4APAAvAEsASQBEAD4APAAvAEsASQBEAFMAPgA8AC8AUABSAE8AVABFAEMAVABJAE4ARgBPAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA="),
37
+ "com.apple.streamingkeydelivery": "skd://itunes.apple.com/P000000000/s1/e1",
38
+ }
39
+ MP4_FORMAT_CODECS = ["ec-3", "hvc1", "audio-atmos", "audio-ec3"]
40
+ SONG_CODEC_REGEX_MAP = {
41
+ "aac": r"audio-stereo-\d+", "aac-he": r"audio-HE-stereo-\d+", "aac-binaural": r"audio-stereo-\d+-binaural", "aac-downmix": r"audio-stereo-\d+-downmix", "aac-he-binaural": r"audio-HE-stereo-\d+-binaural",
42
+ "aac-he-downmix": r"audio-HE-stereo-\d+-downmix", "atmos": r"audio-atmos-.*", "ac3": r"audio-ac3-.*", "alac": r"audio-alac-.*",
43
+ }
44
+ FOURCC_MAP = {"h264": "avc1", "h265": "hvc1"}
45
+ UPLOADED_VIDEO_QUALITY_RANK = ["1080pHdVideo", "720pHdVideo", "sdVideoWithPlusAudio", "sdVideo", "sd480pVideo", "provisionalUploadVideo"]
46
+ HARDCODED_WVD = """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QrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
47
+ DEFAULT_SONG_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
48
+
49
+
50
+ '''CoverFormat'''
51
+ class CoverFormat(Enum):
52
+ JPG = "jpg"
53
+ PNG = "png"
54
+ RAW = "raw"
55
+
56
+
57
+ '''RemuxFormatMusicVideo'''
58
+ class RemuxFormatMusicVideo(Enum):
59
+ M4V = "m4v"
60
+ MP4 = "mp4"
61
+
62
+
63
+ '''SyncedLyricsFormat'''
64
+ class SyncedLyricsFormat(Enum):
65
+ LRC = "lrc"
66
+ SRT = "srt"
67
+ TTML = "ttml"
68
+
69
+
70
+ '''MediaType'''
71
+ class MediaType(Enum):
72
+ SONG = 1
73
+ MUSIC_VIDEO = 6
74
+ def __str__(self): return MEDIA_TYPE_STR_MAP[self.value]
75
+ def __int__(self): return self.value
76
+
77
+
78
+ '''MediaRating'''
79
+ class MediaRating(Enum):
80
+ NONE = 0
81
+ EXPLICIT = 1
82
+ CLEAN = 2
83
+ def __str__(self): return MEDIA_RATING_STR_MAP[self.value]
84
+ def __int__(self): return self.value
85
+
86
+
87
+ '''MediaFileFormat'''
88
+ class MediaFileFormat(Enum):
89
+ MP4 = "mp4"
90
+ M4V = "m4v"
91
+ M4A = "m4a"
92
+
93
+
94
+ '''SongCodec'''
95
+ class SongCodec(Enum):
96
+ AAC_LEGACY = "aac-legacy"
97
+ AAC_HE_LEGACY = "aac-he-legacy"
98
+ AAC = "aac"
99
+ AAC_HE = "aac-he"
100
+ AAC_BINAURAL = "aac-binaural"
101
+ AAC_DOWNMIX = "aac-downmix"
102
+ AAC_HE_BINAURAL = "aac-he-binaural"
103
+ AAC_HE_DOWNMIX = "aac-he-downmix"
104
+ ATMOS = "atmos"
105
+ AC3 = "ac3"
106
+ ALAC = "alac"
107
+ def islegacy(self): return self.value in LEGACY_SONG_CODECS
108
+
109
+
110
+ '''MusicVideoCodec'''
111
+ class MusicVideoCodec(Enum):
112
+ H264 = "h264"
113
+ H265 = "h265"
114
+ def fourcc(self): return FOURCC_MAP[self.value]
115
+
116
+
117
+ '''MusicVideoResolution'''
118
+ class MusicVideoResolution(Enum):
119
+ R240P = "240p"
120
+ R360P = "360p"
121
+ R480P = "480p"
122
+ R540P = "540p"
123
+ R720P = "720p"
124
+ R1080P = "1080p"
125
+ R1440P = "1440p"
126
+ R2160P = "2160p"
127
+ def __int__(self): return int(self.value[:-1])
128
+
129
+
130
+ '''Lyrics'''
131
+ @dataclass
132
+ class Lyrics:
133
+ synced: str = None
134
+ unsynced: str = None
135
+
136
+
137
+ '''MediaTags'''
138
+ @dataclass
139
+ class MediaTags:
140
+ album: str = None
141
+ album_artist: str = None
142
+ album_id: int = None
143
+ album_sort: str = None
144
+ artist: str = None
145
+ artist_id: int = None
146
+ artist_sort: str = None
147
+ comment: str = None
148
+ compilation: bool = None
149
+ composer: str = None
150
+ composer_id: int = None
151
+ composer_sort: str = None
152
+ copyright: str = None
153
+ date: datetime.date | str = None
154
+ disc: int = None
155
+ disc_total: int = None
156
+ gapless: bool = None
157
+ genre: str = None
158
+ genre_id: int = None
159
+ lyrics: str = None
160
+ media_type: MediaType = None
161
+ rating: MediaRating = None
162
+ storefront: str = None
163
+ title: str = None
164
+ title_id: int = None
165
+ title_sort: str = None
166
+ track: int = None
167
+ track_total: int = None
168
+ xid: str = None
169
+ '''asmp4tags'''
170
+ def asmp4tags(self, date_format: str = None):
171
+ disc_mp4 = [self.disc if self.disc is not None else 0, self.disc_total if self.disc_total is not None else 0]
172
+ if disc_mp4[0] == 0 and disc_mp4[1] == 0: disc_mp4 = None
173
+ track_mp4 = [self.track if self.track is not None else 0, self.track_total if self.track_total is not None else 0]
174
+ if track_mp4[0] == 0 and track_mp4[1] == 0: track_mp4 = None
175
+ if isinstance(self.date, datetime.date):
176
+ if date_format is None: date_mp4 = self.date.isoformat()
177
+ else: date_mp4 = self.date.strftime(date_format)
178
+ elif isinstance(self.date, str):
179
+ date_mp4 = self.date
180
+ else:
181
+ date_mp4 = None
182
+ mp4_tags = {
183
+ "\xa9alb": self.album, "aART": self.album_artist, "plID": self.album_id, "soal": self.album_sort, "\xa9ART": self.artist, "atID": self.artist_id,
184
+ "soar": self.artist_sort, "\xa9cmt": self.comment, "cpil": bool(self.compilation) if self.compilation is not None else None, "\xa9wrt": self.composer,
185
+ "cmID": self.composer_id, "soco": self.composer_sort, "cprt": self.copyright, "\xa9day": date_mp4, "disk": disc_mp4, "pgap": bool(self.gapless) if self.gapless is not None else None,
186
+ "\xa9gen": self.genre, "\xa9lyr": self.lyrics, "geID": self.genre_id, "stik": int(self.media_type) if self.media_type is not None else None, "rtng": int(self.rating) if self.rating is not None else None,
187
+ "sfID": self.storefront, "\xa9nam": self.title, "cnID": self.title_id, "sonm": self.title_sort, "trkn": track_mp4, "xid ": self.xid,
188
+ }
189
+ return {k: ([v] if not isinstance(v, bool) else v) for k, v in mp4_tags.items() if v is not None}
190
+
191
+
192
+ '''PlaylistTags'''
193
+ @dataclass
194
+ class PlaylistTags:
195
+ playlist_artist: str = None
196
+ playlist_id: int = None
197
+ playlist_title: str = None
198
+ playlist_track: int = None
199
+
200
+
201
+ '''StreamInfo'''
202
+ @dataclass
203
+ class StreamInfo:
204
+ stream_url: str = None
205
+ widevine_pssh: str = None
206
+ playready_pssh: str = None
207
+ fairplay_key: str = None
208
+ codec: str = None
209
+ width: int = None
210
+ height: int = None
211
+
212
+
213
+ '''StreamInfoAv'''
214
+ @dataclass
215
+ class StreamInfoAv:
216
+ media_id: str = None
217
+ video_track: StreamInfo = None
218
+ audio_track: StreamInfo = None
219
+ file_format: MediaFileFormat = None
220
+
221
+
222
+ '''DecryptionKey'''
223
+ @dataclass
224
+ class DecryptionKey:
225
+ kid: str = None
226
+ key: str = None
227
+
228
+
229
+ '''DecryptionKeyAv'''
230
+ @dataclass
231
+ class DecryptionKeyAv:
232
+ video_track: DecryptionKey = None
233
+ audio_track: DecryptionKey = None
234
+
235
+
236
+ '''DownloadItem'''
237
+ @dataclass
238
+ class DownloadItem:
239
+ media_metadata: dict = None
240
+ playlist_metadata: dict = None
241
+ random_uuid: str = None
242
+ lyrics: Lyrics = None
243
+ lyrics_results: dict = None
244
+ media_tags: MediaTags = None
245
+ playlist_tags: PlaylistTags = None
246
+ stream_info: StreamInfoAv = None
247
+ decryption_key: DecryptionKeyAv = None
248
+ cover_url_template: str = None
249
+ staged_path: str = None
250
+ final_path: str = None
251
+ playlist_file_path: str = None
252
+ synced_lyrics_path: str = None
253
+ cover_path: str = None
254
+ flat_filter_result: Any = None
255
+ error: Exception = None
256
+
257
+
258
+ '''AppleMusicClientUtils'''
259
+ class AppleMusicClientUtils:
260
+ '''_parsedate'''
261
+ @staticmethod
262
+ def _parsedate(date: str):
263
+ return datetime.datetime.fromisoformat(date.split("Z")[0])
264
+ '''getsonglyrics'''
265
+ @staticmethod
266
+ def getsonglyrics(song_metadata: dict, synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC):
267
+ # no lyrics
268
+ if not song_metadata["attributes"]["hasLyrics"]: return None
269
+ # lyrics parser functions definition
270
+ def _parsettmltimestamp(timestamp_ttml: str):
271
+ mins_secs_ms = re.findall(r"\d+", timestamp_ttml)
272
+ ms, secs, mins = 0, 0, 0
273
+ if len(mins_secs_ms) == 2 and ":" in timestamp_ttml:
274
+ secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2])
275
+ elif len(mins_secs_ms) == 1:
276
+ ms = int(mins_secs_ms[-1])
277
+ else:
278
+ secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
279
+ if len(mins_secs_ms) > 2: mins = int(mins_secs_ms[-3])
280
+ return datetime.datetime.fromtimestamp((mins * 60) + secs + (ms / 1000), tz=datetime.timezone.utc)
281
+ def _getlyricslinelrc(element: ElementTree.Element):
282
+ timestamp_ttml, text = element.attrib.get("begin"), element.text
283
+ timestamp = _parsettmltimestamp(timestamp_ttml)
284
+ ms_new = timestamp.strftime("%f")[:-3]
285
+ if int(ms_new[-1]) >= 5:
286
+ ms = int(f"{int(ms_new[:2]) + 1}") * 10
287
+ timestamp += datetime.timedelta(milliseconds=ms) - datetime.timedelta(microseconds=timestamp.microsecond)
288
+ return f"[{timestamp.strftime('%M:%S.%f')[:-4]}]{text}"
289
+ def _getlyricslinesrt(index: int, element: ElementTree.Element):
290
+ timestamp_begin_ttml, timestamp_end_ttml, text = element.attrib.get("begin"), element.attrib.get("end"), element.text
291
+ timestamp_begin = _parsettmltimestamp(timestamp_begin_ttml)
292
+ timestamp_end = _parsettmltimestamp(timestamp_end_ttml)
293
+ return (
294
+ f"{index}\n"
295
+ f"{timestamp_begin.strftime('%H:%M:%S,%f')[:-3]} --> "
296
+ f"{timestamp_end.strftime('%H:%M:%S,%f')[:-3]}\n"
297
+ f"{text}\n"
298
+ )
299
+ # fetch lyrics
300
+ try:
301
+ lyrics_result = song_metadata["relationships"]["lyrics"]
302
+ lyrics_ttml = lyrics_result["data"][0]["attributes"]["ttml"]
303
+ lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
304
+ unsynced_lyrics, synced_lyrics, index = [], [], 1
305
+ for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
306
+ stanza = []
307
+ unsynced_lyrics.append(stanza)
308
+ for p in div.iter("{http://www.w3.org/ns/ttml}p"):
309
+ if p.text is not None: stanza.append(p.text)
310
+ if p.attrib.get("begin"):
311
+ if synced_lyrics_format == SyncedLyricsFormat.LRC:
312
+ synced_lyrics.append(_getlyricslinelrc(p))
313
+ if synced_lyrics_format == SyncedLyricsFormat.SRT:
314
+ synced_lyrics.append(_getlyricslinesrt(index, p))
315
+ if synced_lyrics_format == SyncedLyricsFormat.TTML:
316
+ if not synced_lyrics: synced_lyrics.append(minidom.parseString(lyrics_ttml).toprettyxml())
317
+ index += 1
318
+ lyrics = Lyrics(synced="\n".join(synced_lyrics + ["\n"]) if synced_lyrics else None, unsynced=("\n\n".join(["\n".join(lyric_group) for lyric_group in unsynced_lyrics]) if unsynced_lyrics else None))
319
+ except:
320
+ lyrics_result, lyrics = {}, None
321
+ # return
322
+ return lyrics, lyrics_result
323
+ '''getsongtags'''
324
+ @staticmethod
325
+ def getsongtags(webplayback: dict, lyrics: str | None = None):
326
+ webplayback_metadata = webplayback["songList"][0]["assets"][0]["metadata"]
327
+ tags = MediaTags(
328
+ album=webplayback_metadata["playlistName"], album_artist=webplayback_metadata["playlistArtistName"], album_id=int(webplayback_metadata["playlistId"]),
329
+ album_sort=webplayback_metadata["sort-album"], artist=webplayback_metadata["artistName"], artist_id=int(webplayback_metadata["artistId"]),
330
+ artist_sort=webplayback_metadata["sort-artist"], comment=webplayback_metadata.get("comments"), compilation=webplayback_metadata["compilation"],
331
+ composer=webplayback_metadata.get("composerName"), composer_id=(int(webplayback_metadata.get("composerId")) if webplayback_metadata.get("composerId") else None),
332
+ composer_sort=webplayback_metadata.get("sort-composer"), copyright=webplayback_metadata.get("copyright"),
333
+ date=(AppleMusicClientUtils._parsedate(webplayback_metadata["releaseDate"]) if webplayback_metadata.get("releaseDate") else None),
334
+ disc=webplayback_metadata["discNumber"], disc_total=webplayback_metadata["discCount"], gapless=webplayback_metadata["gapless"],
335
+ genre=webplayback_metadata.get("genre"), genre_id=int(webplayback_metadata["genreId"]), lyrics=lyrics if lyrics else None,
336
+ media_type=MediaType.SONG, rating=MediaRating(webplayback_metadata["explicit"]), storefront=webplayback_metadata["s"],
337
+ title=webplayback_metadata["itemName"], title_id=int(webplayback_metadata["itemId"]), title_sort=webplayback_metadata["sort-name"],
338
+ track=webplayback_metadata["trackNumber"], track_total=webplayback_metadata["trackCount"], xid=webplayback_metadata.get("xid"),
339
+ )
340
+ return tags
341
+ '''getsongstreaminfolegacy'''
342
+ @staticmethod
343
+ def getsongstreaminfolegacy(webplayback: dict, codec: SongCodec):
344
+ flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
345
+ stream_info = StreamInfo()
346
+ stream_info.stream_url = next(i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor)["URL"]
347
+ m3u8_obj = m3u8.loads(requests.get(stream_info.stream_url).text)
348
+ stream_info.widevine_pssh = m3u8_obj.keys[0].uri
349
+ stream_info_av = StreamInfoAv(media_id=webplayback["songList"][0]["songId"], audio_track=stream_info, file_format=MediaFileFormat.M4A)
350
+ return stream_info_av
351
+ '''getsongdecryptionkeylegacy'''
352
+ @staticmethod
353
+ def getsongdecryptionkeylegacy(stream_info: StreamInfoAv, cdm: Cdm, get_license_exchange_func = None, request_overrides: dict = None):
354
+ request_overrides = request_overrides or {}
355
+ stream_info_audio = stream_info.audio_track
356
+ try:
357
+ cdm_session = cdm.open()
358
+ widevine_pssh_data = WidevinePsshData()
359
+ widevine_pssh_data.algorithm = 1
360
+ widevine_pssh_data.key_ids.append(base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1]))
361
+ pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
362
+ challenge = base64.b64encode(cdm.get_license_challenge(cdm_session, pssh_obj)).decode()
363
+ license_resp = get_license_exchange_func(stream_info.media_id, stream_info.audio_track.widevine_pssh, challenge, request_overrides=request_overrides)
364
+ cdm.parse_license(cdm_session, license_resp["license"])
365
+ decryption_key = next(i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT")
366
+ finally:
367
+ cdm.close(cdm_session)
368
+ decryption_key = DecryptionKeyAv(audio_track=DecryptionKey(kid=decryption_key.kid.hex, key=decryption_key.key.hex()))
369
+ return decryption_key
370
+ '''getsongstreaminfo'''
371
+ @staticmethod
372
+ def getsongstreaminfo(song_metadata: dict, codec: SongCodec):
373
+ m3u8_master_url: str = song_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
374
+ if not m3u8_master_url: return None
375
+ m3u8_master_obj = m3u8.loads(requests.get(m3u8_master_url).text)
376
+ m3u8_master_data = m3u8_master_obj.data
377
+ playlist = AppleMusicClientUtils._getsongplaylistfromcodec(m3u8_master_data, codec)
378
+ if playlist is None: return None
379
+ stream_info = StreamInfo()
380
+ stream_info.stream_url = (f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}")
381
+ stream_info.codec = playlist["stream_info"]["codecs"]
382
+ is_mp4 = any(stream_info.codec.startswith(codec) for codec in MP4_FORMAT_CODECS)
383
+ session_key_metadata = AppleMusicClientUtils._getaudiosessionkeymetadata(m3u8_master_data)
384
+ if session_key_metadata:
385
+ asset_metadata = AppleMusicClientUtils._getassetmetadata(m3u8_master_data)
386
+ variant_id = playlist["stream_info"]["stable_variant_id"]
387
+ drm_ids = asset_metadata[variant_id]["AUDIO-SESSION-KEY-IDS"]
388
+ stream_info.widevine_pssh = AppleMusicClientUtils._getdrmurifromsessionkey(session_key_metadata, drm_ids, "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed")
389
+ stream_info.playready_pssh = AppleMusicClientUtils._getdrmurifromsessionkey(session_key_metadata, drm_ids, "com.microsoft.playready")
390
+ stream_info.fairplay_key = AppleMusicClientUtils._getdrmurifromsessionkey(session_key_metadata, drm_ids, "com.apple.streamingkeydelivery")
391
+ else:
392
+ m3u8_obj = m3u8.loads(requests.get(stream_info.stream_url).text)
393
+ stream_info.widevine_pssh = AppleMusicClientUtils._getdrmurifromm3u8keys(m3u8_obj, "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed")
394
+ stream_info.playready_pssh = AppleMusicClientUtils._getdrmurifromm3u8keys(m3u8_obj, "com.microsoft.playready")
395
+ stream_info.fairplay_key = AppleMusicClientUtils._getdrmurifromm3u8keys(m3u8_obj, "com.apple.streamingkeydelivery")
396
+ stream_info_av = StreamInfoAv(audio_track=stream_info, file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A)
397
+ return stream_info_av
398
+ '''getsongdecryptionkey'''
399
+ @staticmethod
400
+ def getsongdecryptionkey(stream_info: StreamInfoAv, cdm: Cdm, get_license_exchange_func = None, request_overrides: dict = None):
401
+ request_overrides = request_overrides or {}
402
+ def _getsongdecryptionkey(track_uri: str, track_id: str, cdm: Cdm, get_license_exchange_func = None, request_overrides: dict = None):
403
+ try:
404
+ cdm_session = cdm.open()
405
+ pssh_obj = PSSH(track_uri.split(",")[-1])
406
+ challenge = base64.b64encode(cdm.get_license_challenge(cdm_session, pssh_obj)).decode()
407
+ license = get_license_exchange_func(track_id, track_uri, challenge, request_overrides=request_overrides)
408
+ cdm.parse_license(cdm_session, license["license"])
409
+ decryption_key_info = next(i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT")
410
+ finally:
411
+ cdm.close(cdm_session)
412
+ decryption_key = DecryptionKey(key=decryption_key_info.key.hex(), kid=decryption_key_info.kid.hex)
413
+ return decryption_key
414
+ decryption_key = DecryptionKeyAv(audio_track=_getsongdecryptionkey(stream_info.audio_track.widevine_pssh, stream_info.media_id, cdm, get_license_exchange_func, request_overrides))
415
+ return decryption_key
416
+ '''_getm3u8metadata'''
417
+ @staticmethod
418
+ def _getm3u8metadata(m3u8_data: dict, data_id: str):
419
+ for session_data in m3u8_data.get("session_data", []):
420
+ if session_data["data_id"] == data_id:
421
+ return json.loads(base64.b64decode(session_data["value"]).decode("utf-8"))
422
+ return None
423
+ '''_getaudiosessionkeymetadata'''
424
+ @staticmethod
425
+ def _getaudiosessionkeymetadata(m3u8_data: dict):
426
+ return AppleMusicClientUtils._getm3u8metadata(m3u8_data, "com.apple.hls.AudioSessionKeyInfo")
427
+ '''_getassetmetadata'''
428
+ @staticmethod
429
+ def _getassetmetadata(m3u8_data: dict):
430
+ return AppleMusicClientUtils._getm3u8metadata(m3u8_data, "com.apple.hls.audioAssetMetadata")
431
+ '''_getsongplaylistfromcodec'''
432
+ @staticmethod
433
+ def _getsongplaylistfromcodec(m3u8_data: dict, codec: SongCodec):
434
+ matching_playlists = [playlist for playlist in m3u8_data["playlists"] if re.fullmatch(SONG_CODEC_REGEX_MAP[codec.value], playlist["stream_info"]["audio"])]
435
+ if not matching_playlists: return None
436
+ return max(matching_playlists, key=lambda x: x["stream_info"]["average_bandwidth"])
437
+ '''_getdrmurifromsessionkey'''
438
+ @staticmethod
439
+ def _getdrmurifromsessionkey(drm_infos: dict, drm_ids: list, drm_key: str):
440
+ for drm_id in drm_ids:
441
+ if drm_id != "1" and drm_key in drm_infos.get(drm_id, {}):
442
+ return drm_infos[drm_id][drm_key]["URI"]
443
+ return None
444
+ '''_getdrmurifromm3u8keys'''
445
+ @staticmethod
446
+ def _getdrmurifromm3u8keys(m3u8_obj: m3u8.M3U8, drm_key: str):
447
+ default_uri = DRM_DEFAULT_KEY_MAPPING[drm_key]
448
+ for key in m3u8_obj.keys:
449
+ if key.keyformat == drm_key and key.uri != default_uri: return key.uri
450
+ return None
451
+ @staticmethod
452
+ def _getrandomuuid():
453
+ return uuid.uuid4().hex[:8]
454
+ '''getsongdownloaditem'''
455
+ @staticmethod
456
+ def getsongdownloaditem(song_metadata: dict, webplayback: dict, synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC, codec: SongCodec = SongCodec.AAC_LEGACY,
457
+ get_license_exchange_func = None, request_overrides: dict = None):
458
+ # init
459
+ download_item = DownloadItem()
460
+ download_item.media_metadata = song_metadata
461
+ request_overrides = request_overrides or {}
462
+ # lyrics
463
+ download_item.lyrics, download_item.lyrics_results = AppleMusicClientUtils.getsonglyrics(song_metadata, synced_lyrics_format=synced_lyrics_format)
464
+ # get webplayback
465
+ download_item.media_tags = AppleMusicClientUtils.getsongtags(webplayback, download_item.lyrics.unsynced if download_item.lyrics else None)
466
+ # auto set after searching
467
+ download_item.final_path = None
468
+ download_item.synced_lyrics_path = None
469
+ download_item.staged_path = None
470
+ # stream info and decryption key
471
+ cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
472
+ if codec.islegacy():
473
+ download_item.stream_info = AppleMusicClientUtils.getsongstreaminfolegacy(webplayback, codec)
474
+ download_item.decryption_key = AppleMusicClientUtils.getsongdecryptionkeylegacy(download_item.stream_info, cdm, get_license_exchange_func, request_overrides=request_overrides)
475
+ else:
476
+ download_item.stream_info = AppleMusicClientUtils.getsongstreaminfo(song_metadata, codec)
477
+ if (download_item.stream_info and download_item.stream_info.audio_track.widevine_pssh):
478
+ download_item.decryption_key = AppleMusicClientUtils.getsongdecryptionkey(download_item.stream_info, cdm, get_license_exchange_func, request_overrides=request_overrides)
479
+ else:
480
+ download_item.decryption_key = None
481
+ # uuid for tmp results saving
482
+ download_item.random_uuid = AppleMusicClientUtils._getrandomuuid()
483
+ # return
484
+ return download_item
485
+ '''download'''
486
+ @staticmethod
487
+ def download(download_item: DownloadItem, work_dir: str = './', silent: bool = False, codec: SongCodec = SongCodec.AAC_LEGACY, wrapper_decrypt_ip: str = "127.0.0.1:10020"):
488
+ ext = download_item.stream_info.file_format.value
489
+ encrypted_path = os.path.join(work_dir, f"{download_item.random_uuid}_encrypted.{ext}")
490
+ is_success = AppleMusicClientUtils.downloadstream(download_item.stream_info.audio_track.stream_url, encrypted_path, silent=silent, random_uuid=download_item.random_uuid)
491
+ decrypted_path = os.path.join(work_dir, f"{download_item.random_uuid}_decrypted.{ext}")
492
+ download_item.staged_path = os.path.join(work_dir, f"{download_item.random_uuid}_staged.{ext}")
493
+ is_success = AppleMusicClientUtils.decrypt(
494
+ encrypted_path=encrypted_path, decrypted_path=decrypted_path, final_path=download_item.final_path, decryption_key=download_item.decryption_key,
495
+ codec=codec, media_id=download_item.media_metadata["id"], fairplay_key=download_item.stream_info.audio_track.fairplay_key, silent=silent,
496
+ artist=download_item.media_tags.artist, wrapper_decrypt_ip=wrapper_decrypt_ip,
497
+ )
498
+ assert is_success
499
+ '''_fixkeyid'''
500
+ @staticmethod
501
+ def _fixkeyid(input_path: str):
502
+ count = 0
503
+ with open(input_path, "rb+") as file:
504
+ while data := file.read(4096):
505
+ pos, i = file.tell(), 0
506
+ while tenc := max(0, data.find(b"tenc", i)):
507
+ kid = tenc + 12
508
+ file.seek(max(0, pos - 4096) + kid, 0)
509
+ file.write(bytes.fromhex(f"{count:032}"))
510
+ count += 1
511
+ i = kid + 1
512
+ file.seek(pos, 0)
513
+ '''_remuxmp4box'''
514
+ @staticmethod
515
+ def _remuxmp4box(input_path: str, output_path: str, silent: bool = False, artist: str = ''):
516
+ cmd = ["MP4Box", "-quiet", "-add", input_path, "-itags", f"artist={artist}", "-keep-utc", "-new", output_path]
517
+ capture_output = True if silent else False
518
+ ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
519
+ return (ret.returncode == 0)
520
+ '''_decryptmp4decrypt'''
521
+ @staticmethod
522
+ def _decryptmp4decrypt(input_path: str, output_path: str, decryption_key: str, legacy: bool, silent: bool = False):
523
+ if legacy:
524
+ keys = ["--key", f"1:{decryption_key}"]
525
+ else:
526
+ AppleMusicClientUtils._fixkeyid(input_path)
527
+ keys = ["--key", "0" * 31 + "1" + f":{decryption_key}", "--key", "0" * 32 + f":{DEFAULT_SONG_DECRYPTION_KEY}"]
528
+ cmd = ["mp4decrypt", *keys, input_path, output_path]
529
+ capture_output = True if silent else False
530
+ ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
531
+ return (ret.returncode == 0)
532
+ '''_decryptamdecrypt'''
533
+ @staticmethod
534
+ def _decryptamdecrypt(input_path: str, output_path: str, media_id: str, fairplay_key: str, wrapper_decrypt_ip: str = "127.0.0.1:10020", silent: bool = False):
535
+ cmd = ['amdecrypt', wrapper_decrypt_ip, shutil.which('mp4decrypt'), media_id, fairplay_key, input_path, output_path]
536
+ capture_output = True if silent else False
537
+ ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
538
+ return (ret.returncode == 0)
539
+ '''decrypt'''
540
+ @staticmethod
541
+ def decrypt(encrypted_path: str, decrypted_path: str, final_path: str, decryption_key: DecryptionKeyAv, codec: SongCodec, media_id: str, fairplay_key: str, silent: bool = False, wrapper_decrypt_ip: str = "127.0.0.1:10020", artist: str = ""):
542
+ try:
543
+ is_success = AppleMusicClientUtils._decryptmp4decrypt(encrypted_path, decrypted_path, decryption_key.audio_track.key, codec.islegacy(), silent=silent)
544
+ is_success = AppleMusicClientUtils._remuxmp4box(decrypted_path, final_path, silent=silent, artist=artist)
545
+ except:
546
+ assert fairplay_key
547
+ is_success = AppleMusicClientUtils._decryptamdecrypt(encrypted_path, final_path, media_id, fairplay_key, wrapper_decrypt_ip=wrapper_decrypt_ip, silent=silent)
548
+ return is_success
549
+ '''downloadstream'''
550
+ @staticmethod
551
+ def downloadstream(stream_url: str, download_path: str, silent: bool = False, random_uuid: str = ''):
552
+ download_path_obj = Path(download_path)
553
+ download_path_obj.parent.mkdir(parents=True, exist_ok=True)
554
+ log_dir = user_log_dir(appname='musicdl', appauthor='zcjin')
555
+ log_file_path = os.path.join(log_dir, f"musicdl_{random_uuid}.log")
556
+ cmd = [
557
+ "N_m3u8DL-RE", stream_url, "--binary-merge", "--ffmpeg-binary-path", shutil.which('ffmpeg'),
558
+ "--save-name", download_path_obj.stem, "--save-dir", download_path_obj.parent, "--tmp-dir", download_path_obj.parent,
559
+ '--log-file-path', log_file_path,
560
+ ]
561
+ capture_output = True if silent else False
562
+ ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
563
+ return (ret.returncode == 0)