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.
- musicdl/__init__.py +5 -5
- musicdl/modules/__init__.py +10 -3
- musicdl/modules/common/__init__.py +2 -0
- musicdl/modules/common/gdstudio.py +204 -0
- musicdl/modules/js/__init__.py +1 -0
- musicdl/modules/js/youtube/__init__.py +2 -0
- musicdl/modules/js/youtube/botguard.js +1 -0
- musicdl/modules/js/youtube/jsinterp.py +902 -0
- musicdl/modules/js/youtube/runner.js +2 -0
- musicdl/modules/sources/__init__.py +41 -10
- musicdl/modules/sources/apple.py +207 -0
- musicdl/modules/sources/base.py +256 -28
- musicdl/modules/sources/bilibili.py +118 -0
- musicdl/modules/sources/buguyy.py +148 -0
- musicdl/modules/sources/fangpi.py +153 -0
- musicdl/modules/sources/fivesing.py +108 -0
- musicdl/modules/sources/gequbao.py +148 -0
- musicdl/modules/sources/jamendo.py +108 -0
- musicdl/modules/sources/joox.py +104 -68
- musicdl/modules/sources/kugou.py +129 -76
- musicdl/modules/sources/kuwo.py +188 -68
- musicdl/modules/sources/lizhi.py +107 -0
- musicdl/modules/sources/migu.py +172 -66
- musicdl/modules/sources/mitu.py +140 -0
- musicdl/modules/sources/mp3juice.py +264 -0
- musicdl/modules/sources/netease.py +163 -115
- musicdl/modules/sources/qianqian.py +125 -77
- musicdl/modules/sources/qq.py +232 -94
- musicdl/modules/sources/tidal.py +342 -0
- musicdl/modules/sources/ximalaya.py +256 -0
- musicdl/modules/sources/yinyuedao.py +144 -0
- musicdl/modules/sources/youtube.py +238 -0
- musicdl/modules/utils/__init__.py +12 -4
- musicdl/modules/utils/appleutils.py +563 -0
- musicdl/modules/utils/data.py +107 -0
- musicdl/modules/utils/logger.py +211 -58
- musicdl/modules/utils/lyric.py +73 -0
- musicdl/modules/utils/misc.py +335 -23
- musicdl/modules/utils/modulebuilder.py +75 -0
- musicdl/modules/utils/neteaseutils.py +81 -0
- musicdl/modules/utils/qqutils.py +184 -0
- musicdl/modules/utils/quarkparser.py +105 -0
- musicdl/modules/utils/songinfoutils.py +54 -0
- musicdl/modules/utils/tidalutils.py +738 -0
- musicdl/modules/utils/youtubeutils.py +3606 -0
- musicdl/musicdl.py +184 -86
- musicdl-2.7.3.dist-info/LICENSE +203 -0
- musicdl-2.7.3.dist-info/METADATA +704 -0
- musicdl-2.7.3.dist-info/RECORD +53 -0
- {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/WHEEL +5 -5
- musicdl-2.7.3.dist-info/entry_points.txt +2 -0
- musicdl/modules/sources/baiduFlac.py +0 -69
- musicdl/modules/sources/xiami.py +0 -104
- musicdl/modules/utils/downloader.py +0 -80
- musicdl-2.1.11.dist-info/LICENSE +0 -22
- musicdl-2.1.11.dist-info/METADATA +0 -82
- musicdl-2.1.11.dist-info/RECORD +0 -24
- {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/top_level.txt +0 -0
- {musicdl-2.1.11.dist-info → musicdl-2.7.3.dist-info}/zip-safe +0 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
'''
|
|
2
|
+
Function:
|
|
3
|
+
Implementation of TIDALMusicClient utils
|
|
4
|
+
Author:
|
|
5
|
+
Zhenchao Jin
|
|
6
|
+
WeChat Official Account (微信公众号):
|
|
7
|
+
Charles的皮卡丘
|
|
8
|
+
'''
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import json
|
|
13
|
+
import aigpy
|
|
14
|
+
import base64
|
|
15
|
+
import shutil
|
|
16
|
+
import requests
|
|
17
|
+
import webbrowser
|
|
18
|
+
import subprocess
|
|
19
|
+
from .misc import resp2json
|
|
20
|
+
from .logger import colorize
|
|
21
|
+
from Crypto.Cipher import AES
|
|
22
|
+
from mutagen.flac import FLAC
|
|
23
|
+
from Crypto.Util import Counter
|
|
24
|
+
from urllib.parse import urljoin
|
|
25
|
+
from typing import List, Optional, Any
|
|
26
|
+
from cryptography.fernet import Fernet
|
|
27
|
+
from datetime import datetime, timedelta
|
|
28
|
+
from dataclasses import dataclass, field, asdict
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
'''AV'''
|
|
32
|
+
try: import av
|
|
33
|
+
except: av = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
'''MediaMetadata'''
|
|
37
|
+
class MediaMetadata(aigpy.model.ModelBase):
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.tags = []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
'''StreamUrl'''
|
|
44
|
+
class StreamUrl(aigpy.model.ModelBase):
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.trackid = None
|
|
48
|
+
self.url = None
|
|
49
|
+
self.urls = None
|
|
50
|
+
self.codec = None
|
|
51
|
+
self.encryptionKey = None
|
|
52
|
+
self.soundQuality = None
|
|
53
|
+
self.sampleRate = None
|
|
54
|
+
self.bitDepth = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
'''VideoStreamUrl'''
|
|
58
|
+
class VideoStreamUrl(aigpy.model.ModelBase):
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
self.codec = None
|
|
62
|
+
self.resolution = None
|
|
63
|
+
self.resolutions = None
|
|
64
|
+
self.m3u8Url = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
'''Artist'''
|
|
68
|
+
class Artist(aigpy.model.ModelBase):
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
self.id = None
|
|
72
|
+
self.name = None
|
|
73
|
+
self.type = None
|
|
74
|
+
self.picture = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
'''Album'''
|
|
78
|
+
class Album(aigpy.model.ModelBase):
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.id = None
|
|
82
|
+
self.title = None
|
|
83
|
+
self.duration = 0
|
|
84
|
+
self.numberOfTracks = 0
|
|
85
|
+
self.numberOfVideos = 0
|
|
86
|
+
self.numberOfVolumes = 0
|
|
87
|
+
self.releaseDate = None
|
|
88
|
+
self.type = None
|
|
89
|
+
self.version = None
|
|
90
|
+
self.cover = None
|
|
91
|
+
self.videoCover = None
|
|
92
|
+
self.explicit = False
|
|
93
|
+
self.audioQuality = None
|
|
94
|
+
self.audioModes = None
|
|
95
|
+
self.upc = None
|
|
96
|
+
self.popularity = None
|
|
97
|
+
self.copyright = None
|
|
98
|
+
self.streamStartDate = None
|
|
99
|
+
self.mediaMetadata = MediaMetadata()
|
|
100
|
+
self.artist = Artist()
|
|
101
|
+
self.artists = Artist()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
'''Playlist'''
|
|
105
|
+
class Playlist(aigpy.model.ModelBase):
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.uuid = None
|
|
109
|
+
self.title = None
|
|
110
|
+
self.numberOfTracks = 0
|
|
111
|
+
self.numberOfVideos = 0
|
|
112
|
+
self.description = None
|
|
113
|
+
self.duration = 0
|
|
114
|
+
self.image = None
|
|
115
|
+
self.squareImage = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
'''Track'''
|
|
119
|
+
class Track(aigpy.model.ModelBase):
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
super().__init__()
|
|
122
|
+
self.id = None
|
|
123
|
+
self.title = None
|
|
124
|
+
self.duration = 0
|
|
125
|
+
self.trackNumber = 0
|
|
126
|
+
self.volumeNumber = 0
|
|
127
|
+
self.trackNumberOnPlaylist = 0
|
|
128
|
+
self.version = None
|
|
129
|
+
self.isrc = None
|
|
130
|
+
self.explicit = False
|
|
131
|
+
self.audioQuality = None
|
|
132
|
+
self.audioModes = None
|
|
133
|
+
self.copyRight = None
|
|
134
|
+
self.replayGain = None
|
|
135
|
+
self.peak = None
|
|
136
|
+
self.popularity = None
|
|
137
|
+
self.streamStartDate = None
|
|
138
|
+
self.mediaMetadata = MediaMetadata()
|
|
139
|
+
self.artist = Artist()
|
|
140
|
+
self.artists = Artist()
|
|
141
|
+
self.album = Album()
|
|
142
|
+
self.allowStreaming = False
|
|
143
|
+
self.playlist = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
'''Video'''
|
|
147
|
+
class Video(aigpy.model.ModelBase):
|
|
148
|
+
def __init__(self) -> None:
|
|
149
|
+
super().__init__()
|
|
150
|
+
self.id = None
|
|
151
|
+
self.title = None
|
|
152
|
+
self.duration = 0
|
|
153
|
+
self.imageID = None
|
|
154
|
+
self.trackNumber = 0
|
|
155
|
+
self.releaseDate = None
|
|
156
|
+
self.version = None
|
|
157
|
+
self.quality = None
|
|
158
|
+
self.explicit = False
|
|
159
|
+
self.artist = Artist()
|
|
160
|
+
self.artists = Artist()
|
|
161
|
+
self.album = Album()
|
|
162
|
+
self.allowStreaming = False
|
|
163
|
+
self.playlist = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
'''Mix'''
|
|
167
|
+
class Mix(aigpy.model.ModelBase):
|
|
168
|
+
def __init__(self) -> None:
|
|
169
|
+
super().__init__()
|
|
170
|
+
self.id = None
|
|
171
|
+
self.tracks = Track()
|
|
172
|
+
self.videos = Video()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
'''Lyrics'''
|
|
176
|
+
class Lyrics(aigpy.model.ModelBase):
|
|
177
|
+
def __init__(self) -> None:
|
|
178
|
+
super().__init__()
|
|
179
|
+
self.trackId = None
|
|
180
|
+
self.lyricsProvider = None
|
|
181
|
+
self.providerCommontrackId = None
|
|
182
|
+
self.providerLyricsId = None
|
|
183
|
+
self.lyrics = None
|
|
184
|
+
self.subtitles = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
'''SearchDataBase'''
|
|
188
|
+
class SearchDataBase(aigpy.model.ModelBase):
|
|
189
|
+
def __init__(self) -> None:
|
|
190
|
+
super().__init__()
|
|
191
|
+
self.limit = 0
|
|
192
|
+
self.offset = 0
|
|
193
|
+
self.totalNumberOfItems = 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
'''SearchAlbums'''
|
|
197
|
+
class SearchAlbums(SearchDataBase):
|
|
198
|
+
def __init__(self) -> None:
|
|
199
|
+
super().__init__()
|
|
200
|
+
self.items = Album()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
'''SearchArtists'''
|
|
204
|
+
class SearchArtists(SearchDataBase):
|
|
205
|
+
def __init__(self) -> None:
|
|
206
|
+
super().__init__()
|
|
207
|
+
self.items = Artist()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
'''SearchTracks'''
|
|
211
|
+
class SearchTracks(SearchDataBase):
|
|
212
|
+
def __init__(self) -> None:
|
|
213
|
+
super().__init__()
|
|
214
|
+
self.items = Track()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
'''SearchVideos'''
|
|
218
|
+
class SearchVideos(SearchDataBase):
|
|
219
|
+
def __init__(self) -> None:
|
|
220
|
+
super().__init__()
|
|
221
|
+
self.items = Video()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
'''SearchPlaylists'''
|
|
225
|
+
class SearchPlaylists(SearchDataBase):
|
|
226
|
+
def __init__(self) -> None:
|
|
227
|
+
super().__init__()
|
|
228
|
+
self.items = Playlist()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
'''SearchResult'''
|
|
232
|
+
class SearchResult(aigpy.model.ModelBase):
|
|
233
|
+
def __init__(self) -> None:
|
|
234
|
+
super().__init__()
|
|
235
|
+
self.artists = SearchArtists()
|
|
236
|
+
self.albums = SearchAlbums()
|
|
237
|
+
self.tracks = SearchTracks()
|
|
238
|
+
self.videos = SearchVideos()
|
|
239
|
+
self.playlists = SearchPlaylists()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
'''StreamRespond'''
|
|
243
|
+
class StreamRespond(aigpy.model.ModelBase):
|
|
244
|
+
def __init__(self) -> None:
|
|
245
|
+
super().__init__()
|
|
246
|
+
self.trackid = None
|
|
247
|
+
self.videoid = None
|
|
248
|
+
self.streamType = None
|
|
249
|
+
self.assetPresentation = None
|
|
250
|
+
self.audioMode = None
|
|
251
|
+
self.audioQuality = None
|
|
252
|
+
self.videoQuality = None
|
|
253
|
+
self.manifestMimeType = None
|
|
254
|
+
self.manifest = None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
'''SegmentTimelineEntry'''
|
|
258
|
+
@dataclass
|
|
259
|
+
class SegmentTimelineEntry:
|
|
260
|
+
start_time: Optional[int]
|
|
261
|
+
duration: int
|
|
262
|
+
repeat: int = 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
'''SegmentTemplate'''
|
|
266
|
+
@dataclass
|
|
267
|
+
class SegmentTemplate:
|
|
268
|
+
media: Optional[str]
|
|
269
|
+
initialization: Optional[str]
|
|
270
|
+
start_number: int = 1
|
|
271
|
+
timescale: int = 1
|
|
272
|
+
presentation_time_offset: int = 0
|
|
273
|
+
timeline: List[SegmentTimelineEntry] = field(default_factory=list)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
'''SegmentList'''
|
|
277
|
+
@dataclass
|
|
278
|
+
class SegmentList:
|
|
279
|
+
initialization: Optional[str]
|
|
280
|
+
media_segments: List[str] = field(default_factory=list)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
'''Representation'''
|
|
284
|
+
@dataclass
|
|
285
|
+
class Representation:
|
|
286
|
+
id: Optional[str]
|
|
287
|
+
bandwidth: Optional[str]
|
|
288
|
+
codec: Optional[str]
|
|
289
|
+
base_url: str
|
|
290
|
+
segment_template: Optional[SegmentTemplate]
|
|
291
|
+
segment_list: Optional[SegmentList]
|
|
292
|
+
'''segments'''
|
|
293
|
+
@property
|
|
294
|
+
def segments(self) -> List[str]:
|
|
295
|
+
if self.segment_list is not None:
|
|
296
|
+
return buildsegmentlist(self.segment_list, self.base_url)
|
|
297
|
+
if self.segment_template is not None:
|
|
298
|
+
return buildsegmenttemplate(self.segment_template, self.base_url, self)
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
'''AdaptationSet'''
|
|
303
|
+
@dataclass
|
|
304
|
+
class AdaptationSet:
|
|
305
|
+
content_type: Optional[str]
|
|
306
|
+
base_url: str
|
|
307
|
+
representations: List[Representation] = field(default_factory=list)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
'''Period'''
|
|
311
|
+
@dataclass
|
|
312
|
+
class Period:
|
|
313
|
+
base_url: str
|
|
314
|
+
adaptation_sets: List[AdaptationSet] = field(default_factory=list)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
'''Manifest'''
|
|
318
|
+
@dataclass
|
|
319
|
+
class Manifest:
|
|
320
|
+
base_url: str
|
|
321
|
+
periods: List[Period] = field(default_factory=list)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
'''SessionStorage'''
|
|
325
|
+
@dataclass
|
|
326
|
+
class SessionStorage:
|
|
327
|
+
access_token: str = None
|
|
328
|
+
refresh_token: str = None
|
|
329
|
+
expires: datetime = None
|
|
330
|
+
user_id: str = None
|
|
331
|
+
country_code: str = None
|
|
332
|
+
device_code: str = None
|
|
333
|
+
user_code: str = None
|
|
334
|
+
'''tojsonbytes'''
|
|
335
|
+
def tojsonbytes(self):
|
|
336
|
+
data = asdict(self)
|
|
337
|
+
if self.expires is not None:
|
|
338
|
+
data["expires"] = self.expires.isoformat()
|
|
339
|
+
else:
|
|
340
|
+
data["expires"] = None
|
|
341
|
+
return json.dumps(data).encode("utf-8")
|
|
342
|
+
'''fromjsonbytes'''
|
|
343
|
+
@classmethod
|
|
344
|
+
def fromjsonbytes(cls, b: bytes):
|
|
345
|
+
data: dict = json.loads(b.decode("utf-8"))
|
|
346
|
+
if data.get("expires"):
|
|
347
|
+
data["expires"] = datetime.fromisoformat(data["expires"])
|
|
348
|
+
else:
|
|
349
|
+
data["expires"] = None
|
|
350
|
+
return cls(**data)
|
|
351
|
+
'''saveencrypted'''
|
|
352
|
+
def saveencrypted(self, path: str, key: bytes = b'3BxQiWxi32p7SCr9SEjGH2Yzj90lxf0EfQ6bi8Vr0dM='):
|
|
353
|
+
f = Fernet(key)
|
|
354
|
+
encrypted = f.encrypt(self.tojsonbytes())
|
|
355
|
+
with open(path, "wb") as fw:
|
|
356
|
+
fw.write(encrypted)
|
|
357
|
+
'''loadencrypted'''
|
|
358
|
+
@classmethod
|
|
359
|
+
def loadencrypted(cls, path: str, key: bytes = b'3BxQiWxi32p7SCr9SEjGH2Yzj90lxf0EfQ6bi8Vr0dM='):
|
|
360
|
+
f = Fernet(key)
|
|
361
|
+
with open(path, "rb") as fr:
|
|
362
|
+
encrypted = fr.read()
|
|
363
|
+
decrypted = f.decrypt(encrypted)
|
|
364
|
+
return cls.fromjsonbytes(decrypted)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
'''TIDALTvSession'''
|
|
368
|
+
class TIDALTvSession():
|
|
369
|
+
CANDIDATED_CLIENT_ID_SECRETS = [
|
|
370
|
+
{'client_id': '7m7Ap0JC9j1cOM3n', 'client_secret': 'vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY='},
|
|
371
|
+
{'client_id': '8SEZWa4J1NVC5U5Y', 'client_secret': 'owUYDkxddz+9FpvGX24DlxECNtFEMBxipU0lBfrbq60='},
|
|
372
|
+
{'client_id': 'zU4XHVVkc2tDPo4t', 'client_secret': 'VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4='},
|
|
373
|
+
]
|
|
374
|
+
def __init__(self, client_id: str = '7m7Ap0JC9j1cOM3n', client_secret: str = 'vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=',
|
|
375
|
+
headers: dict = None, cookies: dict = None):
|
|
376
|
+
self.session = requests.Session()
|
|
377
|
+
self.client_id = client_id
|
|
378
|
+
self.client_secret = client_secret
|
|
379
|
+
self.headers = {'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9'}
|
|
380
|
+
self.headers.update(headers or {})
|
|
381
|
+
self.cookies = cookies or {}
|
|
382
|
+
self.storage = SessionStorage()
|
|
383
|
+
'''auth'''
|
|
384
|
+
def auth(self, request_overrides: dict = None):
|
|
385
|
+
# init
|
|
386
|
+
request_overrides = request_overrides or {}
|
|
387
|
+
outputs = dict(
|
|
388
|
+
ok=False, client_id=self.client_id, client_secret=self.client_secret, reason="",
|
|
389
|
+
device_authorization=dict(device_code=None, user_code=None, verification_url=None, auth_check_timeout=None, auth_check_interval=None),
|
|
390
|
+
token=dict(access_token=None, refresh_token=None, expires_in=None), sessions=dict(user_id=None, country_code=None),
|
|
391
|
+
)
|
|
392
|
+
base_url = 'https://auth.tidal.com/v1'
|
|
393
|
+
if 'headers' not in request_overrides: request_overrides['headers'] = self.headers
|
|
394
|
+
if 'cookies' not in request_overrides: request_overrides['cookies'] = self.cookies
|
|
395
|
+
# device authorization
|
|
396
|
+
try:
|
|
397
|
+
resp = self.session.post(f'{base_url}/oauth2/device_authorization', data={'client_id': self.client_id, 'scope': 'r_usr+w_usr+w_sub'}, **request_overrides)
|
|
398
|
+
resp.raise_for_status()
|
|
399
|
+
device_authorization_results = resp2json(resp=resp)
|
|
400
|
+
outputs['device_authorization'] = dict(
|
|
401
|
+
device_code=device_authorization_results['deviceCode'], user_code=device_authorization_results['userCode'],
|
|
402
|
+
verification_url=device_authorization_results['verificationUri'], auth_check_timeout=device_authorization_results['expiresIn'],
|
|
403
|
+
auth_check_interval=device_authorization_results['interval']
|
|
404
|
+
)
|
|
405
|
+
self.storage.user_code = device_authorization_results['userCode']
|
|
406
|
+
self.storage.device_code = device_authorization_results['deviceCode']
|
|
407
|
+
except Exception as err:
|
|
408
|
+
outputs['reason'] = f'Device authorization error: {err}'
|
|
409
|
+
return outputs
|
|
410
|
+
# token
|
|
411
|
+
data = {
|
|
412
|
+
'client_id': self.client_id, 'device_code': device_authorization_results['deviceCode'], 'client_secret': self.client_secret,
|
|
413
|
+
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', 'scope': 'r_usr+w_usr+w_sub',
|
|
414
|
+
}
|
|
415
|
+
user_login_url = 'https://link.tidal.com/' + device_authorization_results['userCode']
|
|
416
|
+
# --if not ssh to server to use musicdl, auto open user_login_url with webbrowser
|
|
417
|
+
is_remote = bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"))
|
|
418
|
+
if not is_remote:
|
|
419
|
+
try:
|
|
420
|
+
webbrowser.open(user_login_url, new=2)
|
|
421
|
+
except:
|
|
422
|
+
pass
|
|
423
|
+
# --print tips in terminal
|
|
424
|
+
msg = f'Opening {user_login_url} in the browser, log in or sign up to TIDAL manually to continue (in 300 seconds please).'
|
|
425
|
+
print(colorize("TIDAL LOGIN REQUIRED:", 'highlight'))
|
|
426
|
+
print(colorize(msg, 'highlight'))
|
|
427
|
+
# --use tkinter to show tips
|
|
428
|
+
has_display = (
|
|
429
|
+
sys.platform.startswith("win") or sys.platform == "darwin" or bool(os.environ.get("DISPLAY"))
|
|
430
|
+
)
|
|
431
|
+
if has_display:
|
|
432
|
+
import tkinter as tk
|
|
433
|
+
from tkinter import messagebox
|
|
434
|
+
root = tk.Tk()
|
|
435
|
+
root.withdraw()
|
|
436
|
+
root.attributes('-topmost', True)
|
|
437
|
+
messagebox.showinfo("TIDAL Login Required", msg, parent=root)
|
|
438
|
+
root.destroy()
|
|
439
|
+
# --checking user log in or sign up status
|
|
440
|
+
while True:
|
|
441
|
+
resp = self.session.post(f'{base_url}/oauth2/token', data=data, **request_overrides)
|
|
442
|
+
if resp.status_code not in [400]: break
|
|
443
|
+
time.sleep(0.2)
|
|
444
|
+
# --extract required information
|
|
445
|
+
try:
|
|
446
|
+
resp.raise_for_status()
|
|
447
|
+
token_results = resp2json(resp=resp)
|
|
448
|
+
outputs['token'] = dict(
|
|
449
|
+
access_token=token_results['access_token'], refresh_token=token_results['refresh_token'], expires_in=token_results['expires_in']
|
|
450
|
+
)
|
|
451
|
+
self.storage.access_token = token_results['access_token']
|
|
452
|
+
self.storage.refresh_token = token_results['refresh_token']
|
|
453
|
+
self.storage.expires = datetime.now() + timedelta(seconds=token_results['expires_in'])
|
|
454
|
+
except Exception as err:
|
|
455
|
+
outputs['reason'] = f'Token error: {err}'
|
|
456
|
+
return outputs
|
|
457
|
+
# sessions
|
|
458
|
+
request_overrides.pop('headers', {})
|
|
459
|
+
try:
|
|
460
|
+
resp = self.session.get('https://api.tidal.com/v1/sessions', headers=self.auth_headers, **request_overrides)
|
|
461
|
+
resp.raise_for_status()
|
|
462
|
+
sessions_results = resp2json(resp=resp)
|
|
463
|
+
user_id, country_code = sessions_results['userId'], sessions_results['countryCode']
|
|
464
|
+
outputs['sessions'] = dict(user_id=user_id, country_code=country_code)
|
|
465
|
+
self.storage.user_id = user_id
|
|
466
|
+
self.storage.country_code = country_code
|
|
467
|
+
except Exception as err:
|
|
468
|
+
outputs['reason'] = f'Sessions error: {err}'
|
|
469
|
+
return outputs
|
|
470
|
+
# users
|
|
471
|
+
try:
|
|
472
|
+
resp = self.session.get(f'https://api.tidal.com/v1/users/{user_id}?countryCode={country_code}', headers=self.auth_headers, **request_overrides)
|
|
473
|
+
resp.raise_for_status()
|
|
474
|
+
except Exception as err:
|
|
475
|
+
outputs['reason'] = f'Users error: {err}'
|
|
476
|
+
return outputs
|
|
477
|
+
# return
|
|
478
|
+
outputs.update(dict(
|
|
479
|
+
ok=True, reason=f'Successful Routing: {base_url}/oauth2/device_authorization >>> {base_url}/oauth2/token >>> https://api.tidal.com/v1/sessions >>> https://api.tidal.com/v1/users/{user_id}?countryCode={country_code}'
|
|
480
|
+
))
|
|
481
|
+
return outputs
|
|
482
|
+
'''refresh'''
|
|
483
|
+
def refresh(self, request_overrides: dict = None):
|
|
484
|
+
# init
|
|
485
|
+
request_overrides = request_overrides or {}
|
|
486
|
+
# assert
|
|
487
|
+
assert self.storage.access_token is not None
|
|
488
|
+
# refresh
|
|
489
|
+
base_url = 'https://auth.tidal.com/v1'
|
|
490
|
+
resp = self.session.post(
|
|
491
|
+
f'{base_url}/oauth2/token',
|
|
492
|
+
data={'refresh_token': self.storage.refresh_token, 'client_id': self.client_id, 'client_secret': self.client_secret, 'grant_type': 'refresh_token'},
|
|
493
|
+
**request_overrides
|
|
494
|
+
)
|
|
495
|
+
resp.raise_for_status()
|
|
496
|
+
token_results = resp2json(resp=resp)
|
|
497
|
+
results = dict(
|
|
498
|
+
refresh_token=token_results.get('refresh_token'), expires=datetime.now()+timedelta(seconds=token_results['expires_in']),
|
|
499
|
+
access_token=token_results['access_token']
|
|
500
|
+
)
|
|
501
|
+
self.storage.access_token = results['access_token']
|
|
502
|
+
self.storage.expires = results['expires']
|
|
503
|
+
self.storage.refresh_token = results['refresh_token'] if results['refresh_token'] else self.storage.refresh_token
|
|
504
|
+
# return
|
|
505
|
+
return results
|
|
506
|
+
'''auth_headers'''
|
|
507
|
+
@property
|
|
508
|
+
def auth_headers(self):
|
|
509
|
+
return {
|
|
510
|
+
'X-Tidal-Token': self.client_id, 'Authorization': 'Bearer {}'.format(self.storage.access_token), 'Connection': 'Keep-Alive',
|
|
511
|
+
'Accept-Encoding': 'gzip', 'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9'
|
|
512
|
+
}
|
|
513
|
+
'''cache'''
|
|
514
|
+
def cache(self, cache_file_path: str = ''):
|
|
515
|
+
if not cache_file_path:
|
|
516
|
+
cache_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tidal_tv_session.enc')
|
|
517
|
+
self.storage.saveencrypted(path=cache_file_path)
|
|
518
|
+
'''loadfromcache'''
|
|
519
|
+
def loadfromcache(self, cache_file_path: str = ''):
|
|
520
|
+
if not cache_file_path:
|
|
521
|
+
cache_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tidal_tv_session.enc')
|
|
522
|
+
if os.path.exists(cache_file_path):
|
|
523
|
+
self.storage = self.storage.loadencrypted(path=cache_file_path)
|
|
524
|
+
return True
|
|
525
|
+
else:
|
|
526
|
+
return False
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
'''buildsegmentlist'''
|
|
530
|
+
def buildsegmentlist(segment_list: SegmentList, base_url: str) -> List[str]:
|
|
531
|
+
segments: List[str] = []
|
|
532
|
+
if segment_list.initialization:
|
|
533
|
+
segments.append(urljoin(base_url, segment_list.initialization))
|
|
534
|
+
for media in segment_list.media_segments:
|
|
535
|
+
segments.append(urljoin(base_url, media))
|
|
536
|
+
return segments
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
'''completeurl'''
|
|
540
|
+
def completeurl(template: str, base_url: str, representation: Representation, *, number: Optional[int] = None, time: Optional[int] = None) -> str:
|
|
541
|
+
mapping = {
|
|
542
|
+
'$RepresentationID$': representation.id, '$Bandwidth$': representation.bandwidth, '$Number$': None if number is None else str(number),
|
|
543
|
+
'$Time$': None if time is None else str(time),
|
|
544
|
+
}
|
|
545
|
+
result = template
|
|
546
|
+
for placeholder, value in mapping.items():
|
|
547
|
+
if value is not None:
|
|
548
|
+
result = result.replace(placeholder, value)
|
|
549
|
+
result = result.replace('$$', '$')
|
|
550
|
+
return urljoin(base_url, result)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
'''buildsegmenttemplate'''
|
|
554
|
+
def buildsegmenttemplate(template: SegmentTemplate, base_url: str, representation: Representation) -> List[str]:
|
|
555
|
+
segments: List[str] = []
|
|
556
|
+
if template.initialization:
|
|
557
|
+
segments.append(completeurl(template.initialization, base_url, representation))
|
|
558
|
+
number = template.start_number
|
|
559
|
+
current_time: Optional[int] = None
|
|
560
|
+
for entry in template.timeline:
|
|
561
|
+
if entry.start_time is not None:
|
|
562
|
+
current_time = entry.start_time
|
|
563
|
+
elif current_time is None:
|
|
564
|
+
current_time = template.presentation_time_offset
|
|
565
|
+
for _ in range(entry.repeat + 1):
|
|
566
|
+
media = template.media
|
|
567
|
+
if media:
|
|
568
|
+
segments.append(completeurl(media, base_url, representation, number=number, time=current_time))
|
|
569
|
+
number += 1
|
|
570
|
+
if current_time is not None:
|
|
571
|
+
current_time += entry.duration
|
|
572
|
+
return segments
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
'''decryptsecuritytoken'''
|
|
576
|
+
def decryptsecuritytoken(security_token):
|
|
577
|
+
master_key = 'UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754='
|
|
578
|
+
master_key = base64.b64decode(master_key)
|
|
579
|
+
security_token = base64.b64decode(security_token)
|
|
580
|
+
iv = security_token[:16]
|
|
581
|
+
encrypted_st = security_token[16:]
|
|
582
|
+
decryptor = AES.new(master_key, AES.MODE_CBC, iv)
|
|
583
|
+
decrypted_st = decryptor.decrypt(encrypted_st)
|
|
584
|
+
key = decrypted_st[:16]
|
|
585
|
+
nonce = decrypted_st[16:24]
|
|
586
|
+
return key, nonce
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
'''decryptfile'''
|
|
590
|
+
def decryptfile(efile, dfile, key, nonce):
|
|
591
|
+
counter = Counter.new(64, prefix=nonce, initial_value=0)
|
|
592
|
+
decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
|
|
593
|
+
with open(efile, 'rb') as eflac:
|
|
594
|
+
flac = decryptor.decrypt(eflac.read())
|
|
595
|
+
with open(dfile, 'wb') as dflac:
|
|
596
|
+
dflac.write(flac)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
'''ffmpegready'''
|
|
600
|
+
def ffmpegready():
|
|
601
|
+
ffmpeg_available = shutil.which("ffmpeg") is not None
|
|
602
|
+
return ffmpeg_available
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
'''pyavready'''
|
|
606
|
+
def pyavready():
|
|
607
|
+
av_available = av is not None
|
|
608
|
+
return av_available
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
'''remuxwithpyav'''
|
|
612
|
+
def remuxwithpyav(src_path: str, dest_path: str):
|
|
613
|
+
if not pyavready(): return False, "PyAV backend unavailable"
|
|
614
|
+
assert av is not None
|
|
615
|
+
try:
|
|
616
|
+
with av.open(src_path) as container:
|
|
617
|
+
audio_stream = next((s for s in container.streams if s.type == "audio"), None)
|
|
618
|
+
if audio_stream is None:
|
|
619
|
+
return False, "PyAV could not locate an audio stream"
|
|
620
|
+
with av.open(dest_path, mode="w", format="flac") as output:
|
|
621
|
+
out_stream = output.add_stream(template=audio_stream)
|
|
622
|
+
for packet in container.demux(audio_stream):
|
|
623
|
+
if packet.dts is None:
|
|
624
|
+
continue
|
|
625
|
+
packet.stream = out_stream
|
|
626
|
+
output.mux(packet)
|
|
627
|
+
except Exception as exc:
|
|
628
|
+
return False, f"PyAV error: {exc}"
|
|
629
|
+
return os.path.exists(dest_path) and os.path.getsize(dest_path) > 0, "PyAV"
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
'''remuxwithffmpeg'''
|
|
633
|
+
def remuxwithffmpeg(src_path: str, dest_path: str):
|
|
634
|
+
if not ffmpegready():
|
|
635
|
+
return False, "ffmpeg backend unavailable"
|
|
636
|
+
cmd = ["ffmpeg", "-y", "-v", "error", "-i", src_path, "-map", "0:a:0", "-c:a", "copy", dest_path]
|
|
637
|
+
try:
|
|
638
|
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
639
|
+
except subprocess.CalledProcessError as exc:
|
|
640
|
+
return False, f"ffmpeg exited with code {exc.returncode}"
|
|
641
|
+
return os.path.exists(dest_path) and os.path.getsize(dest_path) > 0, "ffmpeg"
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
'''remuxflacstream'''
|
|
645
|
+
def remuxflacstream(src_path: str, dest_path: str):
|
|
646
|
+
if os.path.exists(dest_path): os.remove(dest_path)
|
|
647
|
+
last_reason: Optional[str] = None
|
|
648
|
+
for backend in (remuxwithpyav, remuxwithffmpeg):
|
|
649
|
+
ok, reason = backend(src_path, dest_path)
|
|
650
|
+
if ok: return dest_path, reason
|
|
651
|
+
last_reason = reason
|
|
652
|
+
if os.path.exists(dest_path): os.remove(dest_path)
|
|
653
|
+
return src_path, last_reason
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
'''formatgain'''
|
|
657
|
+
def formatgain(value: Optional[Any]) -> Optional[str]:
|
|
658
|
+
if value is None: return None
|
|
659
|
+
try: return f"{float(value):.2f} dB"
|
|
660
|
+
except (TypeError, ValueError): return str(value)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
'''extractmediatags'''
|
|
664
|
+
def extractmediatags(track: Track, album: Optional[Album]) -> list[str]:
|
|
665
|
+
tags: list[str] = []
|
|
666
|
+
for source in (getattr(track, "mediaMetadata", None), getattr(album, "mediaMetadata", None) if album else None):
|
|
667
|
+
if source and getattr(source, "tags", None):
|
|
668
|
+
tags = [tag for tag in source.tags if tag]
|
|
669
|
+
if tags: break
|
|
670
|
+
return tags
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
'''formatpeak'''
|
|
674
|
+
def formatpeak(value: Optional[Any]) -> Optional[str]:
|
|
675
|
+
if value is None: return None
|
|
676
|
+
try: return f"{float(value):.6f}"
|
|
677
|
+
except (TypeError, ValueError): return str(value)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
'''updateflacmetadata'''
|
|
681
|
+
def updateflacmetadata(filepath: str, track: Track, stream: Optional[StreamUrl]):
|
|
682
|
+
# instance
|
|
683
|
+
audio = FLAC(filepath)
|
|
684
|
+
# set tag
|
|
685
|
+
def _settag(key: str, value: Any) -> None:
|
|
686
|
+
if value is None: return
|
|
687
|
+
if isinstance(value, bool):
|
|
688
|
+
text = "1" if value else "0"
|
|
689
|
+
audio[key] = [text]
|
|
690
|
+
return
|
|
691
|
+
if isinstance(value, (list, tuple, set)):
|
|
692
|
+
values = []
|
|
693
|
+
for item in value:
|
|
694
|
+
if item is None: continue
|
|
695
|
+
if isinstance(item, bool): item = "1" if item else "0"
|
|
696
|
+
item_text = str(item).strip()
|
|
697
|
+
if item_text: values.append(item_text)
|
|
698
|
+
if values: audio[key] = values
|
|
699
|
+
return
|
|
700
|
+
text = str(value).strip()
|
|
701
|
+
if text: audio[key] = [text]
|
|
702
|
+
# set tags from track
|
|
703
|
+
_settag("TIDAL_TRACK_ID", track.id)
|
|
704
|
+
_settag("TIDAL_TRACK_VERSION", track.version)
|
|
705
|
+
_settag("TIDAL_TRACK_POPULARITY", track.popularity)
|
|
706
|
+
_settag("TIDAL_STREAM_START_DATE", track.streamStartDate)
|
|
707
|
+
_settag("TIDAL_EXPLICIT", track.explicit)
|
|
708
|
+
_settag("TIDAL_AUDIO_QUALITY", getattr(track, "audioQuality", None))
|
|
709
|
+
_settag("TIDAL_AUDIO_MODES", getattr(track, "audioModes", None) or [])
|
|
710
|
+
_settag("REPLAYGAIN_TRACK_GAIN", formatgain(getattr(track, "replayGain", None)))
|
|
711
|
+
_settag("REPLAYGAIN_TRACK_PEAK", formatpeak(getattr(track, "peak", None)))
|
|
712
|
+
# set tags from stream
|
|
713
|
+
if stream is not None:
|
|
714
|
+
_settag("CODEC", stream.codec)
|
|
715
|
+
_settag("TIDAL_STREAM_SOUND_QUALITY", stream.soundQuality)
|
|
716
|
+
_settag("BITS_PER_SAMPLE", stream.bitDepth)
|
|
717
|
+
_settag("SAMPLERATE", stream.sampleRate)
|
|
718
|
+
# misc
|
|
719
|
+
if track.trackNumberOnPlaylist: _settag("TIDAL_PLAYLIST_TRACK_NUMBER", track.trackNumberOnPlaylist)
|
|
720
|
+
_settag("URL", f"https://listen.tidal.com/track/{track.id}")
|
|
721
|
+
# save
|
|
722
|
+
audio.save()
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
'''setmetadata'''
|
|
726
|
+
def setmetadata(track: Track, filepath: str, stream: Optional[StreamUrl]):
|
|
727
|
+
is_flac_file = filepath.lower().endswith(".flac")
|
|
728
|
+
obj = aigpy.tag.TagTool(filepath)
|
|
729
|
+
obj.album = track.album.title
|
|
730
|
+
obj.title = track.title
|
|
731
|
+
if not aigpy.string.isNull(track.version): obj.title += ' (' + track.version + ')'
|
|
732
|
+
obj.artist = list(map(lambda artist: artist.name, track.artists)) if track.artists else list()
|
|
733
|
+
obj.copyright = track.copyRight
|
|
734
|
+
obj.tracknumber = track.trackNumber
|
|
735
|
+
obj.discnumber = track.volumeNumber
|
|
736
|
+
obj.isrc = track.isrc
|
|
737
|
+
if is_flac_file:
|
|
738
|
+
updateflacmetadata(filepath, track, stream)
|