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,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)