├── .gitignore ├── README.md ├── __init__.py ├── applemusic_api.py ├── fingerprint.py └── interface.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | venv 3 | .vscode 4 | .DS_Store 5 | .idea/ 6 | .python-version 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | OrpheusDL - Apple Music (Basic Support) 4 | ======================================= 5 | 6 | An Apple Music module for the OrpheusDL modular archival music program, 7 | for playlists, lyrics and covers only. 8 | 9 | [Report Bug](https://github.com/yarrm80s/orpheusdl-applemusic-basic/issues) 10 | · 11 | [Request Feature](https://github.com/yarrm80s/orpheusdl-applemusic-basic/issues) 12 | 13 | 14 | ## Table of content 15 | 16 | - [About OrpheusDL - Apple Music](#about-orpheusdl-applemusic) 17 | - [Getting Started](#getting-started) 18 | - [Prerequisites](#prerequisites) 19 | - [Installation](#installation) 20 | - [Usage](#usage) 21 | - [Configuration](#configuration) 22 | - [Global](#global) 23 | - [Apple Music](#applemusic) 24 | - [Contact](#contact) 25 | - [Credits](#credits) 26 | 27 | 28 | 29 | ## About OrpheusDL - Apple Music 30 | 31 | OrpheusDL - Apple Music is a module written in Python which allows getting playlists, lyrics and covers from **Apple Music** for the modular music archival program. 32 | 33 | 34 | 35 | ## Getting Started 36 | 37 | Follow these steps to get a local copy of this module up and running: 38 | 39 | ### Prerequisites 40 | 41 | * Already have [OrpheusDL](https://github.com/yarrm80s/orpheusdl) installed 42 | 43 | ### Installation 44 | 45 | 1. Clone the repo inside the folder `orpheusdl/modules/` 46 | ```sh 47 | git clone https://github.com/yarrm80s/orpheusdl-applemusic-basic.git applemusic 48 | ``` 49 | 2. Execute: 50 | ```sh 51 | python orpheus.py 52 | ``` 53 | 3. Now the `config/settings.json` file should be updated with the Apple Music settings 54 | 55 | 56 | ## Usage 57 | 58 | Just call `orpheus.py` with any playlist you want to archive, along with specifying a separate download module: 59 | 60 | ```sh 61 | python orpheus.py https://music.apple.com/us/playlist/beat-saber-x-monstercat/pl.0ccb67a275dc416c9dadd6fe1f80d518 -sd qobuz 62 | ``` 63 | 64 | 65 | ## Configuration 66 | 67 | You can customize every module from Orpheus individually and also set general/global settings which are active in every 68 | loaded module. You'll find the configuration file here: `config/settings.json` 69 | 70 | ### Global 71 | 72 | TODO 73 | 74 | ### Apple Music 75 | ```json5 76 | "applemusic": { 77 | "force_region": "us", 78 | "selected_language": "en", 79 | "get_original_cover": true, 80 | "print_original_cover_url": true, 81 | "lyrics_type": "custom", 82 | "lyrics_custom_ms_sync": false, 83 | "lyrics_language_override": "en", 84 | "lyrics_syllable_sync": true, 85 | "user_token": "base64 encoded token" 86 | }, 87 | ``` 88 | `force_region`: Select a region to get everything except lyrics data from 89 | 90 | `selected_language`: In the region selected, get the language specified 91 | 92 | `get_original_cover`: Download the original cover file Apple recieved from the label 93 | 94 | `print_original_cover_url`: Prints a link to the original cover file 95 | 96 | `lyrics_type`: Can be chosen between standard and custom, standard is highly recommended for compatibility although custom saves all available data 97 | 98 | `lyrics_custom_ms_sync`: Lets you save milliseconds instead of seconds to preserve all data, although players usually only accept the default 10ms synced data, leave this disabled unless you know what you're doing 99 | 100 | `lyrics_language_override`: Since lyrics require you to request in the region of your account, choose a language from that region to use with lyrics 101 | 102 | `lyrics_syllable_sync`: Enable downloading lyrics data with word or even syllable sync, multiple vocalists, overlapping vocals, etc, will need my custom format to work 103 | 104 | `user_token`: Most important, you must input your user token from the web player 105 | 106 | 107 | ## Contact 108 | 109 | Yarrm80s - [@yarrm80s](https://github.com/yarrm80s) 110 | 111 | Dniel97 - [@Dniel97](https://github.com/Dniel97) 112 | 113 | Project Link: [OrpheusDL Apple Music Public GitHub Repository](https://github.com/yarrm80s/orpheusdl-applemusic-basic) 114 | 115 | 116 | 117 | ## Credits 118 | 119 | R3AP3 - [@R3AP3](https://github.com/R3AP3) for helping out with in-depth research related to covers 120 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OrfiDev/orpheusdl-applemusic-basic/0bfec77d87012a91dd2a6f5de5dc065449ff80b5/__init__.py -------------------------------------------------------------------------------- /applemusic_api.py: -------------------------------------------------------------------------------- 1 | import re 2 | import base64 3 | import pbkdf2 4 | import hashlib 5 | 6 | from Cryptodome.Hash import SHA256 7 | 8 | from uuid import uuid4 9 | from utils.utils import create_requests_session 10 | 11 | from .fingerprint import Fingerprint 12 | 13 | 14 | import srp._pysrp as srp 15 | srp.rfc5054_enable() 16 | srp.no_username_in_x() 17 | 18 | def b64enc(data): 19 | return base64.b64encode(data).decode() 20 | 21 | def b64dec(data): 22 | return base64.b64decode(data) 23 | 24 | 25 | class AppleMusicApi(object): 26 | def __init__(self, exception, storefront='US', language='en-US', lyrics_resource='lyrics'): 27 | self.s = create_requests_session() 28 | self.api_base = 'https://amp-api.music.apple.com/v1/' 29 | 30 | self.storefront = storefront 31 | self.language = language 32 | self.lyrics_storefront = storefront 33 | self.lyrics_language = language 34 | self.lyrics_resource = lyrics_resource 35 | 36 | self.access_token = '' 37 | self.user_token = '' 38 | 39 | self.exception = exception 40 | 41 | self.user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36' 42 | 43 | def headers(self): 44 | return { 45 | 'authorization': 'Bearer ' + self.access_token, 46 | 'Connection': 'Keep-Alive', 47 | 'Content-Type': 'application/json', 48 | 'Origin': 'https://music.apple.com', 49 | 'Referer': 'https://music.apple.com/', 50 | 'Accept-Encoding': 'gzip, deflate', 51 | 'Accept-Language': f'{self.language},en;q=0.9', 52 | 'User-Agent': self.user_agent, 53 | 'Media-User-Token': self.user_token, 54 | 'x-apple-renewal': 'true' 55 | } 56 | 57 | def get_access_token(self): 58 | s = create_requests_session() 59 | r = s.get('https://music.apple.com/us/search', headers=self.headers()) 60 | if r.status_code != 200: raise self.exception(r.text) 61 | 62 | index_js = re.search('(?<=index\-)(.*?)(?=\.js")', r.text).group(1) 63 | r = s.get(f'https://music.apple.com/assets/index-{index_js}.js', headers=self.headers()) 64 | if r.status_code != 200: raise self.exception(r.text) 65 | 66 | self.access_token = re.search('(?=eyJh)(.*?)(?=")', r.text).group(1) 67 | return self.access_token 68 | 69 | def auth(self, email: str, password: str): 70 | auth_url = 'https://idmsa.apple.com/appleauth/' 71 | client_id = '06f8d74b71c73757a2f82158d5e948ae7bae11ec45fda9a58690f55e35945c51' 72 | frame_id = 'auth-' + str(uuid4()).lower() 73 | 74 | # get "dslang", "site" and "aasp" cookies 75 | r = self.s.get(auth_url + 'auth/authorize/signin', headers=self.headers(), params={ 76 | 'frame_id': frame_id, 77 | 'language': 'en_us', 78 | 'skVersion': '7', 79 | 'iframeId': frame_id, 80 | 'client_id': client_id, 81 | 'redirect_uri': 'https://music.apple.com', 82 | 'response_type': 'code', 83 | 'response_mode': 'web_message', 84 | 'account_ind': '1', 85 | 'state': frame_id, 86 | 'authVersion': 'latest' 87 | }) 88 | if r.status_code != 200: raise self.exception(r.text) 89 | auth_attributes = r.headers['X-Apple-Auth-Attributes'] 90 | 91 | # get "aa" cookie 92 | r = self.s.post(auth_url + 'jslog', headers=self.headers(), json={ 93 | 'type': 'INFO', 94 | 'title': 'AppleAuthPerf-s-y', 95 | 'message': '''APPLE ID : TTI {"data":{"initApp":{"startTime":1154.2000000001863},"loadAuthComponent":{"startTime":1500.7000000001863},"startAppToTTI":{"duration":346.70000000018626}},"order":["initApp","loadAuthComponent","startAppToTTI"]}''', 96 | 'iframeId': frame_id, 97 | 'details': '''{"pageVisibilityState":"visible"}''' 98 | }) 99 | assert (r.status_code == 200) 100 | 101 | # actual login 102 | headers = { 103 | 'Accept': 'application/json', 104 | 'Referer': 'https://idmsa.apple.com/', 105 | 'Content-Type': 'application/json', 106 | 'X-Apple-Widget-Key': client_id, 107 | 'X-Apple-Frame-Id': frame_id, 108 | 'X-Apple-Domain-Id': '3', 109 | 'X-Apple-Locale': 'en_us', 110 | 'X-Requested-With': 'XMLHttpRequest', 111 | 'Origin': 'https://idmsa.apple.com', 112 | 'X-Apple-I-Require-UE': 'true', 113 | 'X-Apple-I-FD-Client-Info': '{' + f'"U":"{self.user_agent}","L":"{self.language}","Z":"GMT-8:00","V":"1.1","F":"{Fingerprint().create_fingerprint()}"' + '}', 114 | 'X-Apple-Auth-Attributes': auth_attributes, 115 | 'User-Agent': self.user_agent, 116 | 'X-Apple-Mandate-Security-Upgrade': '0' 117 | } 118 | 119 | json_ = {'accountName': email, 'rememberMe': 'false'} 120 | params_ = {'isRememberMeEnabled': 'false'} 121 | 122 | r = self.s.post(auth_url + 'auth/federate', headers=headers, params=params_, json=json_) 123 | if 'federated' not in r.json(): raise self.exception(r.text) 124 | 125 | # finally begin login 126 | user = srp.User(email, bytes(), hash_alg=srp.SHA256, ng_type=srp.NG_2048) 127 | _, A = user.start_authentication() 128 | 129 | json_ = {'a': b64enc(A), 'accountName': email, 'protocols': ['s2k', 's2k_fo']} 130 | 131 | r = self.s.post(auth_url + 'auth/signin/init', headers=headers, json=json_) 132 | out_json = r.json() 133 | if r.status_code != 200: raise self.exception(out_json['serviceErrors'][0]['message']) 134 | if 'b' not in out_json: raise self.exception(r.text) 135 | if out_json.get('protocol') != 's2k': raise self.exception('Protocol not supported') 136 | 137 | salt = b64dec(out_json['salt']) 138 | iterations = out_json['iteration'] 139 | B = b64dec(out_json['b']) 140 | c = out_json['c'] 141 | 142 | pass_hash = hashlib.sha256(password.encode("utf-8")).digest() 143 | enc_pass = pbkdf2.PBKDF2(pass_hash, salt, iterations, SHA256).read(32) 144 | 145 | user.p = enc_pass 146 | M1 = user.process_challenge(salt, B) 147 | if M1 is None: raise self.exception("Failed to process challenge") 148 | 149 | M2 = user.K 150 | 151 | # real version uses m2 as well... hmmm 152 | json_ = {'accountName': email, 'c': c, 'm1': b64enc(M1), 'm2': b64enc(M2), 'rememberMe': 'false'} 153 | 154 | r = self.s.post(auth_url + 'auth/signin/complete', headers=headers, params=params_, json=json_) 155 | if r.status_code != 200: raise self.exception(r.json()['serviceErrors'][0]['message']) 156 | 157 | # exchange the "myacinfo" cookie with the "media-user-token" 158 | r = self.s.post('https://buy.music.apple.com/account/web/auth', headers=self.headers(), json={'webAuthorizationFlowContext': 'music'}) 159 | if r.status_code != 200: raise self.exception(r.text) 160 | 161 | self.user_token = self.s.cookies['media-user-token'] 162 | return self.user_token 163 | 164 | def get_account_details(self, force_region, selected_language, lyrics_language): 165 | r = self.s.get(self.api_base + 'me/account', headers=self.headers(), params={'meta': 'subscription'}) 166 | if r.status_code != 200: raise self.exception(r.text) 167 | 168 | self.lyrics_storefront = r.json()['meta']['subscription']['storefront'] 169 | if force_region.lower() == self.lyrics_storefront: force_region = None 170 | if force_region: print(f"Apple Music: WARNING: Selected region {force_region} is not the same as your Apple Music region {self.lyrics_storefront}, lyrics will use the region {self.lyrics_storefront}. Only lyrics available in both regions will be used, maybe use a copy of the module with the folder name (which determines the name of the module) and the netlocation_constant changed for lyrics only if you want credits or playlists from other regions.") 171 | 172 | self.storefront = force_region.lower() if force_region else self.lyrics_storefront 173 | account_active = r.json()['meta']['subscription']['active'] 174 | 175 | storefront_endpoint = f'storefronts/{force_region.lower()}' if force_region else 'me/storefront' 176 | endpoint_data = self.s.get(self.api_base + storefront_endpoint, headers=self.headers()) 177 | if endpoint_data.status_code != 200: raise self.exception(f'Region {force_region} is not supported') 178 | 179 | supported_languages = endpoint_data.json()['data'][0]['attributes']['supportedLanguageTags'] 180 | if selected_language: 181 | for i in supported_languages: 182 | if selected_language in i: 183 | self.language = i 184 | break 185 | else: 186 | print(f"Apple Music: WARNING: Selected language {selected_language} in region {force_region if force_region else self.lyrics_storefront} is unsupported, force a different region or use one of these: {', '.join(supported_languages)}") 187 | self.language = supported_languages[0] 188 | else: 189 | self.language = supported_languages[0] 190 | 191 | if not lyrics_language: lyrics_language = selected_language 192 | 193 | if force_region: 194 | supported_languages = self.s.get(f'{self.api_base}me/storefront', headers=self.headers()).json()['data'][0]['attributes']['supportedLanguageTags'] 195 | if lyrics_language: 196 | for i in supported_languages: 197 | if selected_language in i: 198 | self.lyrics_language = i 199 | break 200 | else: 201 | print(f"Apple Music: WARNING: Selected language {selected_language} in lyrics region {self.lyrics_storefront} is unsupported, force a different region or use one of these: {', '.join(supported_languages)}") 202 | self.lyrics_language = supported_languages[0] 203 | else: 204 | self.lyrics_language = supported_languages[0] 205 | 206 | return self.storefront, account_active, self.language, self.lyrics_language, self.lyrics_storefront 207 | 208 | def check_active_subscription(self): 209 | url = f'{self.api_base}me/account' 210 | params = {'meta': 'subscription', 'challenge[subscriptionCapabilities]': 'voice,premium'} 211 | 212 | response = self.s.get(url, headers=self.headers(), params=params) 213 | if response.status_code != 200: raise self.exception(response.text) 214 | response_data = response.json() 215 | 216 | if 'meta' in response_data and 'subscription' in response_data['meta']: 217 | return response_data['meta']['subscription'].get('active', False) 218 | 219 | return False 220 | 221 | def _get(self, url: str, params=None, storefront=None, language=None): 222 | if not params: params = {} 223 | if not storefront: storefront = self.storefront 224 | params['l'] = language if language else self.language 225 | 226 | r = self.s.get(f'{self.api_base}catalog/{storefront}/{url}', params=params, headers=self.headers()) 227 | if r.status_code not in [200, 201, 202]: raise self.exception(r.text) 228 | 229 | return r.json() 230 | 231 | def search(self, query_type: str, query: str, limit: int = 10): 232 | if limit > 25: limit = 25 233 | 234 | params = { 235 | 'term': query, 236 | 'types': query_type, 237 | 'limit': limit 238 | } 239 | 240 | if query_type == 'songs': 241 | params['extend[songs]'] = 'attribution,composerName,contentRating,discNumber,durationInMillis,isrc,movementCount,movementName,movementNumber,releaseDate,trackNumber,workNamedata' 242 | params['include[songs]'] = 'artists,albums' + (f',{self.lyrics_resource}' if self.storefront == self.lyrics_storefront else '') # doesn't give lyrics? 243 | params['extend[albums]'] = 'copyright,upc' 244 | elif query_type == 'playlists': 245 | params['include[playlists]'] = 'curator' 246 | params['extend[playlists]'] = 'artwork,description,trackTypes,trackCount' 247 | 248 | results = self._get('search', params)['results'] 249 | if query_type in results: 250 | results = results[query_type]['data'] 251 | else: 252 | results = [] 253 | 254 | return results 255 | 256 | def get_playlist_base_data(self, playlist_id): 257 | return self._get(f'playlists/{playlist_id}', params={ 258 | 'include': 'curator,tracks', 259 | 'extend': 'artwork,description,trackTypes,trackCount', 260 | 'include[songs]': 'artists,albums' + (f',{self.lyrics_resource}' if self.storefront == self.lyrics_storefront else ''), 261 | 'extend[songs]': 'extendedAssetUrls,attribution,composerName,contentRating,discNumber,durationInMillis,isrc,movementCount,movementName,movementNumber,releaseDate,trackNumber,workNamedata', 262 | 'extend[albums]': 'copyright,upc' 263 | })['data'][0] 264 | 265 | def get_playlist_tracks(self, playlist_data): 266 | tracks_list, track_data = [], {} 267 | tracks = list(playlist_data['relationships']['tracks']['data']) 268 | offset = len(tracks) 269 | 270 | while len(tracks) + offset <= playlist_data['attributes']['trackCount']: 271 | tracks += self._get(f'playlists/{playlist_data["id"]}/tracks', params={ 272 | 'offset': offset, 273 | 'include[songs]': 'artists,albums' + (f',{self.lyrics_resource}' if self.storefront == self.lyrics_storefront else ''), 274 | 'extend[songs]': 'extendedAssetUrls,attribution,composerName,contentRating,discNumber,durationInMillis,isrc,movementCount,movementName,movementNumber,releaseDate,trackNumber,workNamedata', 275 | 'extend[albums]': 'copyright,upc', 276 | 'limit': 100 277 | })['data'] 278 | offset += 100 279 | 280 | for track in tracks: 281 | tracks_list.append(track['id']) 282 | track_data[track['id']] = track 283 | 284 | return tracks_list, track_data 285 | 286 | def get_tracks_by_ids(self, track_ids: list = None, isrc: str = None): 287 | if not track_ids: track_ids = [] 288 | 289 | params = {'filter[isrc]': isrc} if isrc else {'ids': ','.join(track_ids)} 290 | params['include'] = 'artists,albums' + (f',{self.lyrics_resource}' if self.storefront == self.lyrics_storefront else '') 291 | params['extend'] = 'attribution,composerName,contentRating,discNumber,durationInMillis,isrc,movementCount,movementName,movementNumber,releaseDate,trackNumber,workNamedata' 292 | params['extend[albums]'] = 'copyright,upc' 293 | 294 | return self._get('songs', params)['data'] 295 | 296 | def get_track(self, track_id: str = None): 297 | return self.get_tracks_by_ids([track_id])[0] 298 | 299 | @staticmethod 300 | def get_lyrics_support(track_attributes): 301 | # could technically be a single line in the lambda 302 | if track_attributes.get('hasTimeSyncedLyrics'): 303 | return 1 if track_attributes.get('isVocalAttenuationAllowed') else 2 304 | else: 305 | return 3 if track_attributes.get('hasLyrics') else 4 306 | 307 | def get_track_by_isrc(self, isrc: str, album_name: str): 308 | results = self.get_tracks_by_ids(isrc=isrc) 309 | 310 | correct_region_results = [i for i in results if i['attributes']['url'].split('i=')[-1].split('&')[0] == i['id']] 311 | incorrect_region_results = [i for i in results if i['attributes']['url'].split('i=')[-1].split('&')[0] != i['id']] 312 | 313 | correct_region_results_sorted_by_track_number = sorted(correct_region_results, key=lambda x: x['attributes'].get('trackNumber', 1)) 314 | 315 | fix_results_by_album = lambda list_to_sort: sorted(list_to_sort, key=lambda x: (x['attributes']['albumName'] != album_name)) 316 | correct_album_correct_region_results = fix_results_by_album(correct_region_results_sorted_by_track_number) 317 | correct_album_incorrect_region_results = fix_results_by_album(incorrect_region_results) 318 | 319 | correct_album_prioritised_lyrics_results = sorted(correct_album_correct_region_results, key=lambda x: self.get_lyrics_support(x['attributes'])) 320 | return correct_album_prioritised_lyrics_results + correct_album_incorrect_region_results 321 | 322 | def get_lyrics(self, track_id, lyrics_resource=None): 323 | if not lyrics_resource: lyrics_resource = self.lyrics_resource 324 | 325 | try: 326 | data = self._get(f'songs/{track_id}/{lyrics_resource}', storefront=self.lyrics_storefront, language=self.language) 327 | except self.exception: 328 | return None 329 | 330 | return data#['data'][0]['attributes']['ttml'] 331 | -------------------------------------------------------------------------------- /fingerprint.py: -------------------------------------------------------------------------------- 1 | # This is likely not necessary at all, but I (OrfiDev) decided to reverse engineer and 2 | # reimplement the fingerprinting algorithm used by Apple's web login as used by Apple Music anyways. 3 | # 4 | # I'm not sure if this is reversible (as in even checkable if it's correct) 5 | # maybe the part which I assumed to be a checksum is actually a way to derive some variable required to decode? 6 | 7 | import pytz 8 | import random 9 | import datetime 10 | import urllib.parse 11 | 12 | timezone = pytz.timezone('America/Los_Angeles') 13 | 14 | class Fingerprint: 15 | def encode(cls, e): 16 | y = ["%20", ";;;", "%3B", "%2C", "und", "fin", "ed;", "%28", "%29", "%3A", "/53", "ike", "Web", "0;", ".0", "e;", "on", "il", "ck", "01", "in", "Mo", "fa", "00", "32", "la", ".1", "ri", "it", "%u", "le"] 17 | A = ".0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz" 18 | w = { 19 | 1: [4, 15], 20 | 110: [8, 239], 21 | 74: [8, 238], 22 | 57: [7, 118], 23 | 56: [7, 117], 24 | 71: [8, 233], 25 | 25: [8, 232], 26 | 101: [5, 28], 27 | 104: [7, 111], 28 | 4: [7, 110], 29 | 105: [6, 54], 30 | 5: [7, 107], 31 | 109: [7, 106], 32 | 103: [9, 423], 33 | 82: [9, 422], 34 | 26: [8, 210], 35 | 6: [7, 104], 36 | 46: [6, 51], 37 | 97: [6, 50], 38 | 111: [6, 49], 39 | 7: [7, 97], 40 | 45: [7, 96], 41 | 59: [5, 23], 42 | 15: [7, 91], 43 | 11: [8, 181], 44 | 72: [8, 180], 45 | 27: [8, 179], 46 | 28: [8, 178], 47 | 16: [7, 88], 48 | 88: [10, 703], 49 | 113: [11, 1405], 50 | 89: [12, 2809], 51 | 107: [13, 5617], 52 | 90: [14, 11233], 53 | 42: [15, 22465], 54 | 64: [16, 44929], 55 | 0: [16, 44928], 56 | 81: [9, 350], 57 | 29: [8, 174], 58 | 118: [8, 173], 59 | 30: [8, 172], 60 | 98: [8, 171], 61 | 12: [8, 170], 62 | 99: [7, 84], 63 | 117: [6, 41], 64 | 112: [6, 40], 65 | 102: [9, 319], 66 | 68: [9, 318], 67 | 31: [8, 158], 68 | 100: [7, 78], 69 | 84: [6, 38], 70 | 55: [6, 37], 71 | 17: [7, 73], 72 | 8: [7, 72], 73 | 9: [7, 71], 74 | 77: [7, 70], 75 | 18: [7, 69], 76 | 65: [7, 68], 77 | 48: [6, 33], 78 | 116: [6, 32], 79 | 10: [7, 63], 80 | 121: [8, 125], 81 | 78: [8, 124], 82 | 80: [7, 61], 83 | 69: [7, 60], 84 | 119: [7, 59], 85 | 13: [8, 117], 86 | 79: [8, 116], 87 | 19: [7, 57], 88 | 67: [7, 56], 89 | 114: [6, 27], 90 | 83: [6, 26], 91 | 115: [6, 25], 92 | 14: [6, 24], 93 | 122: [8, 95], 94 | 95: [8, 94], 95 | 76: [7, 46], 96 | 24: [7, 45], 97 | 37: [7, 44], 98 | 50: [5, 10], 99 | 51: [5, 9], 100 | 108: [6, 17], 101 | 22: [7, 33], 102 | 120: [8, 65], 103 | 66: [8, 64], 104 | 21: [7, 31], 105 | 106: [7, 30], 106 | 47: [6, 14], 107 | 53: [5, 6], 108 | 49: [5, 5], 109 | 86: [8, 39], 110 | 85: [8, 38], 111 | 23: [7, 18], 112 | 75: [7, 17], 113 | 20: [7, 16], 114 | 2: [5, 3], 115 | 73: [8, 23], 116 | 43: [9, 45], 117 | 87: [9, 44], 118 | 70: [7, 10], 119 | 3: [6, 4], 120 | 52: [5, 1], 121 | 54: [5, 0] 122 | } 123 | 124 | # the actual encoding function 125 | def main_encode(e): 126 | def t(r, o, input_tuple, n): 127 | shift, value = input_tuple 128 | r = (r << shift) | value 129 | o += shift 130 | while o >= 6: 131 | e = (r >> (o - 6)) & 63 132 | n += A[e] 133 | r ^= e << (o - 6) 134 | o -= 6 135 | return n, r, o 136 | 137 | n, r, o = "", 0, 0 138 | n, r, o = t(r, o, (6, (7 & len(e)) << 3 | 0), n) 139 | n, r, o = t(r, o, (6, 56 & len(e) | 1), n) 140 | 141 | for char in e: 142 | char_code = ord(char) 143 | if char_code not in w: 144 | return "" 145 | n, r, o = t(r, o, w[char_code], n) 146 | 147 | n, r, o = t(r, o, w[0], n) 148 | if o > 0: 149 | n, r, o = t(r, o, (6 - o, 0), n) 150 | 151 | return n 152 | 153 | # replacing some stuff in the string? 154 | n = e 155 | for r, rep in enumerate(y): 156 | n = n.replace(rep, chr(r + 1)) 157 | 158 | # checksum calculation I think 159 | n_val = 65535 160 | for char in e: 161 | n_val = ((n_val >> 8) | (n_val << 8)) & 65535 162 | n_val ^= 255 & ord(char) 163 | n_val ^= (255 & n_val) >> 4 164 | n_val ^= (n_val << 12) & 65535 165 | n_val ^= ((255 & n_val) << 5) & 65535 166 | n_val &= 65535 167 | n_val &= 65535 168 | checksum = A[n_val >> 12] + A[(n_val >> 6) & 63] + A[n_val & 63] 169 | 170 | # adding checksum to the encoded string 171 | return main_encode(n) + checksum 172 | 173 | def generate(cls): 174 | def get_timezone_offset(date): 175 | local_time = timezone.localize(date) 176 | return int(-local_time.utcoffset().total_seconds() / 60) 177 | 178 | t1 = get_timezone_offset(datetime.datetime(2005, 1, 15)) 179 | t2 = get_timezone_offset(datetime.datetime(2005, 7, 15)) 180 | 181 | def base_is_dst(): 182 | return abs(t1 - t2) != 0 183 | 184 | def base_is_dst_str(): 185 | return str(base_is_dst()).lower() 186 | 187 | def is_dst(date): 188 | return base_is_dst and get_timezone_offset(date) == min(t1, t2) 189 | 190 | def is_dst_str(date): 191 | return str(is_dst(date)).lower() 192 | 193 | def calculate_offset(date): 194 | return int(-(get_timezone_offset(date) + abs(t2 - t1) * is_dst(date)) / 60) 195 | 196 | # technically not the same as the browser, but close enough 197 | def get_locale_string(date): 198 | return urllib.parse.quote(date.strftime("%m/%d/%Y, %I:%M:%S %p")) 199 | 200 | def get_timestamp(date): 201 | return int(date.timestamp() * 1000) 202 | 203 | current_time = datetime.datetime.now() 204 | 205 | return f'TF1;020;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;{base_is_dst_str()};{is_dst_str(current_time)};{get_timestamp(current_time)};{calculate_offset(current_time)};{get_locale_string(datetime.datetime(2005,6,7,21,33,44,888))};;;;;;;;;{random.randint(1000, 9999)};{t1};{t2};{get_locale_string(current_time)};;;;;;;;;;;;;;;;;;;;;;;;25;;;;;;;;;;;;;;;5.6.1-0;;' 206 | 207 | def create_fingerprint(cls): 208 | return cls.encode(cls.generate()) 209 | 210 | # all the garbage that is tracked for fingerprinting if you're curious 211 | ''' 212 | var t = new Date 213 | , r = new Date 214 | , o = [u("TF1"), u("020"), function() { 215 | return ScriptEngineMajorVersion() 216 | } 217 | , function() { 218 | return ScriptEngineMinorVersion() 219 | } 220 | , function() { 221 | return ScriptEngineBuildVersion() 222 | } 223 | , function() { 224 | return c("{7790769C-0471-11D2-AF11-00C04FA35D02}") 225 | } 226 | , function() { 227 | return c("{89820200-ECBD-11CF-8B85-00AA005B4340}") 228 | } 229 | , function() { 230 | return c("{283807B5-2C60-11D0-A31D-00AA00B92C03}") 231 | } 232 | , function() { 233 | return c("{4F216970-C90C-11D1-B5C7-0000F8051515}") 234 | } 235 | , function() { 236 | return c("{44BBA848-CC51-11CF-AAFA-00AA00B6015C}") 237 | } 238 | , function() { 239 | return c("{9381D8F2-0288-11D0-9501-00AA00B911A5}") 240 | } 241 | , function() { 242 | return c("{4F216970-C90C-11D1-B5C7-0000F8051515}") 243 | } 244 | , function() { 245 | return c("{5A8D6EE0-3E18-11D0-821E-444553540000}") 246 | } 247 | , function() { 248 | return c("{89820200-ECBD-11CF-8B85-00AA005B4383}") 249 | } 250 | , function() { 251 | return c("{08B0E5C0-4FCB-11CF-AAA5-00401C608555}") 252 | } 253 | , function() { 254 | return c("{45EA75A0-A269-11D1-B5BF-0000F8051515}") 255 | } 256 | , function() { 257 | return c("{DE5AED00-A4BF-11D1-9948-00C04F98BBC9}") 258 | } 259 | , function() { 260 | return c("{22D6F312-B0F6-11D0-94AB-0080C74C7E95}") 261 | } 262 | , function() { 263 | return c("{44BBA842-CC51-11CF-AAFA-00AA00B6015B}") 264 | } 265 | , function() { 266 | return c("{3AF36230-A269-11D1-B5BF-0000F8051515}") 267 | } 268 | , function() { 269 | return c("{44BBA840-CC51-11CF-AAFA-00AA00B6015C}") 270 | } 271 | , function() { 272 | return c("{CC2A9BA0-3BDD-11D0-821E-444553540000}") 273 | } 274 | , function() { 275 | return c("{08B0E5C0-4FCB-11CF-AAA5-00401C608500}") 276 | } 277 | , function() { 278 | return "" 279 | } 280 | , function() { 281 | return "" 282 | } 283 | , function() { 284 | return "" 285 | } 286 | , function() { 287 | return s(["navigator.productSub", "navigator.appMinorVersion"]) 288 | } 289 | , function() { 290 | return "" 291 | } 292 | , function() { 293 | return "" 294 | } 295 | , function() { 296 | return s(["navigator.oscpu", "navigator.cpuClass"]) 297 | } 298 | , function() { 299 | return "" 300 | } 301 | , function() { 302 | return "" 303 | } 304 | , function() { 305 | return "" 306 | } 307 | , function() { 308 | return "" 309 | } 310 | , function() { 311 | return s(["navigator.language", "navigator.userLanguage"]) 312 | } 313 | , function() { 314 | return "" 315 | } 316 | , function() { 317 | return "" 318 | } 319 | , function() { 320 | return "" 321 | } 322 | , function() { 323 | return "" 324 | } 325 | , function() { 326 | return "" 327 | } 328 | , function() { 329 | return "" 330 | } 331 | , function() { 332 | return 0 !== Math.abs(h - g) 333 | } 334 | , function() { 335 | return a(t) 336 | } 337 | , function() { 338 | return "@UTC@" 339 | } 340 | , function() { 341 | var e = 0; 342 | return e = 0, 343 | a(t) && (e = Math.abs(h - g)), 344 | -(t.getTimezoneOffset() + e) / 60 345 | } 346 | , function() { 347 | return new Date(2005,5,7,21,33,44,888).toLocaleString() 348 | } 349 | , function() { 350 | return "" 351 | } 352 | , function() { 353 | return "" 354 | } 355 | , function() { 356 | return v.Acrobat 357 | } 358 | , function() { 359 | return v.Flash 360 | } 361 | , function() { 362 | return v.QuickTime 363 | } 364 | , function() { 365 | return v["Java Plug-in"] 366 | } 367 | , function() { 368 | return v.Director 369 | } 370 | , function() { 371 | return v.Office 372 | } 373 | , function() { 374 | return "@CT@" 375 | } 376 | , function() { 377 | return h 378 | } 379 | , function() { 380 | return g 381 | } 382 | , function() { 383 | return t.toLocaleString() 384 | } 385 | , function() { 386 | return "" 387 | } 388 | , function() { 389 | return "" 390 | } 391 | , function() { 392 | return "" 393 | } 394 | , function() { 395 | return "" 396 | } 397 | , function() { 398 | return "" 399 | } 400 | , function() { 401 | return n("Acrobat") 402 | } 403 | , function() { 404 | return n("Adobe SVG") 405 | } 406 | , function() { 407 | return n("Authorware") 408 | } 409 | , function() { 410 | return n("Citrix ICA") 411 | } 412 | , function() { 413 | return n("Director") 414 | } 415 | , function() { 416 | return n("Flash") 417 | } 418 | , function() { 419 | return n("MapGuide") 420 | } 421 | , function() { 422 | return n("MetaStream") 423 | } 424 | , function() { 425 | return n("PDFViewer") 426 | } 427 | , function() { 428 | return n("QuickTime") 429 | } 430 | , function() { 431 | return n("RealOne") 432 | } 433 | , function() { 434 | return n("RealPlayer Enterprise") 435 | } 436 | , function() { 437 | return n("RealPlayer Plugin") 438 | } 439 | , function() { 440 | return n("Seagate Software Report") 441 | } 442 | , function() { 443 | return n("Silverlight") 444 | } 445 | , function() { 446 | return n("Windows Media") 447 | } 448 | , function() { 449 | return n("iPIX") 450 | } 451 | , function() { 452 | return n("nppdf.so") 453 | } 454 | , function() { 455 | var e = document.createElement("span"); 456 | e.innerHTML = " ", 457 | e.style.position = "absolute", 458 | e.style.left = "-9999px", 459 | document.body.appendChild(e); 460 | var t = e.offsetHeight; 461 | return document.body.removeChild(e), 462 | t 463 | } 464 | , m(), m(), m(), m(), m(), m(), m(), m(), m(), m(), m(), m(), m(), m(), function() { 465 | return "5.6.1-0" 466 | } 467 | , m()]; 468 | ''' -------------------------------------------------------------------------------- /interface.py: -------------------------------------------------------------------------------- 1 | import xmltodict 2 | import base64 3 | import json 4 | 5 | from urllib.parse import urlparse 6 | 7 | from utils.models import * 8 | 9 | from .applemusic_api import AppleMusicApi 10 | 11 | 12 | module_information = ModuleInformation( 13 | service_name = 'Apple Music (Basic Support)', 14 | module_supported_modes = ModuleModes.covers | ModuleModes.lyrics | ModuleModes.credits | ModuleModes.playlist, 15 | session_settings = { 16 | 'email': '', 17 | 'password': '', 18 | 'force_region': '', 19 | 'selected_language': 'en' 20 | }, 21 | session_storage_variables=[ 22 | 'storefront', 'language', 'lyrics_language', 'lyrics_storefront', 23 | 'verified_storefront', 'verified_language', 'verified_lyrics_language', 'verified_user_token', 24 | 'access_token' 25 | ], 26 | global_settings = { 27 | 'get_original_cover': True, 28 | 'print_original_cover_url': False, 29 | 'lyrics_type': 'standard', # 'custom' or 'standard' 30 | 'lyrics_custom_ms_sync': False, 31 | 'lyrics_language_override': '', 32 | 'lyrics_syllable_sync': False 33 | }, 34 | netlocation_constant = 'apple', 35 | test_url = 'https://music.apple.com/us/playlist/beat-saber-x-monstercat/pl.0ccb67a275dc416c9dadd6fe1f80d518', 36 | url_decoding = ManualEnum.manual 37 | ) 38 | 39 | 40 | class ModuleInterface: 41 | def __init__(self, module_controller: ModuleController): 42 | self.tsc = module_controller.temporary_settings_controller 43 | self.module_controller = module_controller 44 | self.msettings = module_controller.module_settings 45 | self.exception = module_controller.module_error 46 | self.oprint = module_controller.printer_controller.oprint 47 | 48 | self.lyrics_resource = 'syllable-lyrics' if self.msettings['lyrics_syllable_sync'] else 'lyrics' 49 | if self.msettings['lyrics_syllable_sync'] and self.msettings['lyrics_type'] == 'standard': raise self.exception("Syllable synced lyrics cannot be downloaded with the standard lyrics type.") 50 | 51 | self.session = AppleMusicApi(self.exception, lyrics_resource=self.lyrics_resource) 52 | 53 | access_token = self.tsc.read('access_token') 54 | if access_token and json.loads(base64.b64decode(access_token.split('.')[1] + '==').decode('utf-8'))['exp'] > module_controller.get_current_timestamp(): 55 | self.session.access_token = access_token 56 | else: 57 | self.tsc.set('access_token', self.session.get_access_token()) 58 | 59 | user_token = self.tsc.read('user_token') 60 | if user_token: 61 | self.session.user_token = user_token 62 | # print(self.session.check_active_subscription()) 63 | 64 | if self.tsc.read('storefront') and self.tsc.read('language') and self.tsc.read('lyrics_language') and self.tsc.read('verified_storefront') == self.msettings['force_region'] and self.tsc.read('verified_language') == self.msettings['selected_language'] and self.tsc.read('verified_lyrics_language') == self.msettings['lyrics_language_override']: 65 | self.session.storefront = self.tsc.read('storefront') 66 | self.session.language = self.tsc.read('language') 67 | self.session.lyrics_storefront = self.tsc.read('lyrics_storefront') 68 | self.session.lyrics_language = self.tsc.read('lyrics_language') 69 | elif user_token: 70 | self.set_regions() 71 | 72 | def set_regions(self): 73 | account_storefront, account_active, language_tag, lyrics_language_tag, lyrics_storefront = self.session.get_account_details(self.msettings['force_region'], self.msettings['selected_language'], self.msettings['lyrics_language_override']) 74 | 75 | self.tsc.set('storefront', account_storefront) 76 | self.tsc.set('language', language_tag) 77 | self.tsc.set('lyrics_language', lyrics_language_tag) 78 | self.tsc.set('lyrics_storefront', lyrics_storefront) 79 | 80 | self.tsc.set('verified_storefront', self.msettings['force_region']) 81 | self.tsc.set('verified_language', self.msettings['selected_language']) 82 | self.tsc.set('verified_lyrics_language', self.msettings['lyrics_language_override']) 83 | 84 | def login(self, email, password): 85 | user_token = self.session.auth(email, password) 86 | self.tsc.set('user_token', user_token) 87 | 88 | self.set_regions() 89 | 90 | @staticmethod 91 | def custom_url_parse(link): 92 | url = urlparse(link) 93 | components = url.path.split('/') 94 | if not components or len(components) < 4: 95 | print('Invalid URL: ' + link) 96 | exit() 97 | 98 | if components[2] == 'playlist': 99 | return MediaIdentification( 100 | media_type = DownloadTypeEnum.playlist, 101 | media_id = components[-1].split('?')[0].split('.')[-1] 102 | ) 103 | else: 104 | print('Unsupported URL: ' + link) 105 | exit() 106 | 107 | def parse_cover_url(self, unparsed_url, resolution, compression_level: CoverCompressionEnum, file_format=ImageFileTypeEnum.jpg): 108 | if file_format is ImageFileTypeEnum.png and (self.msettings['get_original_cover'] or self.msettings['print_original_cover_url']): 109 | result = 'https://a1.mzstatic.com/r40/' + '/'.join(unparsed_url.split('/')[5:-1]) 110 | if self.msettings['print_original_cover_url']: 111 | self.oprint('Original cover URL: ' + result) 112 | 113 | if self.msettings['get_original_cover']: 114 | cover_extension = unparsed_url.split('.')[-1] 115 | if cover_extension not in [i.name for i in ImageFileTypeEnum]: 116 | raise self.module_controller.module_error('Invalid cover extension: ' + cover_extension) 117 | 118 | return result, ImageFileTypeEnum[cover_extension] 119 | 120 | if compression_level is CoverCompressionEnum.low: 121 | compression = '-100' 122 | elif compression_level is CoverCompressionEnum.high: 123 | compression = '-0' 124 | crop_code = '' 125 | 126 | # while Apple Music doesn't use the compression modifier, we use the crop code position in the format string for convenience 127 | final_crop_code = crop_code + compression 128 | 129 | url = unparsed_url.format(w=resolution, h=resolution, c=final_crop_code, f=file_format.name).replace('bb.', compression+'.') 130 | url = f'{url.rsplit(".", 1)[0]}.{file_format.name}' 131 | return url, file_format 132 | 133 | def get_track_cover(self, track_id, cover_options: CoverOptions, data = {}): 134 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 135 | 136 | cover_url, cover_type = self.parse_cover_url( 137 | unparsed_url = track_data['attributes']['artwork']['url'], 138 | resolution = cover_options.resolution, 139 | compression_level = cover_options.compression, 140 | file_format = cover_options.file_type 141 | ) 142 | 143 | return CoverInfo( 144 | url = cover_url, 145 | file_type = cover_type 146 | ) 147 | 148 | def get_playlist_info(self, playlist_id, data={}): 149 | cover_options = self.module_controller.orpheus_options.default_cover_options 150 | playlist_data = data[playlist_id] if playlist_id in data else self.session.get_playlist_base_data(playlist_id) 151 | if 'tracks' not in playlist_data.get('relationships', {}): 152 | if 'relationships' not in playlist_data: playlist_data['relationships'] = {} 153 | playlist_data['relationships']['tracks'] = {} 154 | playlist_data['relationships']['tracks']['data'] = {} 155 | tracks_list, track_data = self.session.get_playlist_tracks(playlist_data) 156 | playlist_info = playlist_data['attributes'] 157 | 158 | cover_url, cover_type = self.parse_cover_url( 159 | unparsed_url = playlist_data['attributes']['artwork']['url'], 160 | resolution = cover_options.resolution, 161 | compression_level = cover_options.compression, 162 | file_format = cover_options.file_type 163 | ) 164 | 165 | return PlaylistInfo( 166 | name = playlist_info['name'], 167 | creator = playlist_info['curatorName'], 168 | tracks = tracks_list, 169 | release_year = playlist_info['lastModifiedDate'].split('-')[0] if playlist_info.get('lastModifiedDate') else None, 170 | cover_url = cover_url, 171 | cover_type = cover_type, 172 | track_extra_kwargs = {'data': track_data} 173 | ) 174 | 175 | def get_track_info(self, track_id, quality_tier: QualityEnum, codec_options: CodecOptions, data={}, total_discs=None): 176 | cover_options = self.module_controller.orpheus_options.default_cover_options 177 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 178 | track_relations = track_data['relationships'] 179 | track_info = track_data['attributes'] 180 | if not 'lyrics' in track_relations: track_relations['lyrics'] = self.session.get_lyrics(track_id) 181 | 182 | return TrackInfo( 183 | name = track_info['name'], 184 | release_year = track_info.get('releaseDate', '').split('-')[0], 185 | album_id = track_relations['albums']['data'][0]['id'], 186 | album = track_info['albumName'], 187 | artists = [i['attributes']['name'] for i in track_relations['artists']['data']], 188 | artist_id = track_relations['artists']['data'][0]['id'], 189 | duration = track_info['durationInMillis'] // 1000, 190 | explicit = track_info.get('contentRating') == 'explicit', 191 | codec = CodecEnum.FLAC, 192 | cover_url = self.parse_cover_url( 193 | unparsed_url = track_info['artwork']['url'], 194 | resolution = cover_options.resolution, 195 | compression_level = cover_options.compression, 196 | file_format = ImageFileTypeEnum.jpg 197 | )[0], 198 | tags = Tags( 199 | album_artist = track_relations['albums']['data'][0]['attributes']['artistName'], 200 | track_number = track_info.get('trackNumber'), 201 | total_tracks = track_relations['albums']['data'][0]['attributes'].get('trackCount'), 202 | disc_number = track_info.get('discNumber'), 203 | total_discs = total_discs, 204 | genres = track_info.get('genreNames'), 205 | isrc = track_info.get('isrc'), 206 | upc = track_relations['albums']['data'][0]['attributes'].get('upc'), 207 | copyright = track_relations['albums']['data'][0]['attributes'].get('copyright'), 208 | composer = track_info.get('composerName'), 209 | release_date = track_info.get('releaseDate') 210 | ), 211 | description = track_info.get('editorialNotes', {}).get('standard'), 212 | cover_extra_kwargs = {'data': {track_id: track_data}}, 213 | lyrics_extra_kwargs = {'data': {track_id: track_data}}, 214 | credits_extra_kwargs = {'data': {track_id: track_data}} 215 | ) 216 | 217 | @staticmethod 218 | def get_timestamp(input_ts): 219 | mins = int(input_ts.split(':')[-2]) if ':' in input_ts else 0 220 | secs = float(input_ts.split(':')[-1]) if ':' in input_ts else float(input_ts.replace('s', '')) 221 | return mins * 60 + secs 222 | 223 | def ts_format(self, input_ts, already_secs=False): 224 | ts = input_ts if already_secs else self.get_timestamp(input_ts) 225 | mins = int(ts // 60) 226 | secs = ts % 60 227 | return f'{mins:0>2}:{secs:06.3f}' if self.msettings['lyrics_custom_ms_sync'] else f'{mins:0>2}:{secs:05.2f}' 228 | 229 | def parse_lyrics_verse(self, lines, multiple_agents, custom_lyrics, add_timestamps=True): 230 | # using: 231 | # [start new line timestamp]lyrics 232 | # also, there's the enhanced format that we don't use which is: 233 | # [last line end timestamp] lyrics 234 | 235 | synced_lyrics_list = [] 236 | unsynced_lyrics_list = [] 237 | 238 | for line in lines: 239 | if isinstance(line, dict): 240 | if multiple_agents: 241 | agent = line['@ttm:agent'] 242 | if agent[0] != 'v': raise self.exception(f'Weird agent: {agent}') 243 | agent_num = int(agent[1:]) 244 | 245 | if 'span' in line: 246 | words = line['span'] 247 | if not isinstance(words, list): words = [words] 248 | 249 | unsynced_line = f'{agent_num}: ' if multiple_agents else '' 250 | synced_line = f"[{self.ts_format(line['@begin'])}]" if add_timestamps else '' 251 | synced_line += unsynced_line 252 | if add_timestamps and custom_lyrics: synced_line += f"<{self.ts_format(line['@begin'])}>" 253 | 254 | for word in words: 255 | if '@ttm:role' in word: 256 | if word['@ttm:role'] != 'x-bg': raise self.exception(f'Strange lyric role {word["@ttm:role"]}') 257 | if word.get('@prespaced'): unsynced_line += ' ' 258 | 259 | _, bg_verse_synced_lyrics_list = self.parse_lyrics_verse(word['span'], False, False, False) 260 | unsynced_line += ''.join([i[2] for i in bg_verse_synced_lyrics_list]) 261 | 262 | synced_bg_line = '' 263 | first_ts = 0 264 | for bg_word_begin, bg_word_end, bg_word_text in bg_verse_synced_lyrics_list: 265 | if not synced_bg_line and add_timestamps: 266 | first_ts = bg_word_begin 267 | synced_bg_line = f"[{self.ts_format(first_ts, already_secs=True)}]" 268 | if multiple_agents: synced_bg_line += f'{agent_num}: ' 269 | if add_timestamps and multiple_agents: synced_bg_line += f"<{self.ts_format(first_ts, already_secs=True)}>" 270 | synced_bg_line += bg_word_text 271 | if custom_lyrics and add_timestamps: synced_bg_line += f"<{self.ts_format(bg_word_end, already_secs=True)}>" 272 | 273 | synced_lyrics_list.append((first_ts, first_ts, synced_bg_line)) 274 | else: 275 | if word.get('@prespaced'): 276 | synced_line += ' ' 277 | unsynced_line += ' ' 278 | 279 | synced_line += word['#text'] 280 | unsynced_line += word['#text'] 281 | 282 | if custom_lyrics and add_timestamps: synced_line += f"<{self.ts_format(word['@end'])}>" 283 | 284 | synced_lyrics_list.append((self.get_timestamp(line['@begin']), self.get_timestamp(line['@end']), synced_line)) 285 | unsynced_lyrics_list.append(unsynced_line) 286 | elif '#text' in line: 287 | synced_line = f"[{self.ts_format(line['@begin'])}]" if add_timestamps else '' 288 | if add_timestamps and custom_lyrics: synced_line += f"<{self.ts_format(line['@begin'])}>" 289 | if line.get('@prespaced'): synced_line += ' ' 290 | synced_line += line['#text'] 291 | 292 | if custom_lyrics and add_timestamps: synced_line += f"<{self.ts_format(line['@end'])}>" 293 | synced_lyrics_list.append((self.get_timestamp(line['@begin']), self.get_timestamp(line['@end']), synced_line)) 294 | 295 | unsynced_line = f'{agent_num}: ' if multiple_agents else '' 296 | unsynced_line += line['#text'] 297 | unsynced_lyrics_list.append(unsynced_line) 298 | else: 299 | raise self.exception(f'Unknown lyrics data: {line}') 300 | elif isinstance(line, str): 301 | # TODO: more research needed on Apple + Genius sourced unsynced lyrics 302 | # there are some random unicode things like ’ which we might want to filter out 303 | unsynced_lyrics_list.append(line) 304 | else: 305 | raise self.exception(f'Invalid lyrics type? {line}, type {type(line)}') 306 | return unsynced_lyrics_list, synced_lyrics_list 307 | 308 | def get_lyrics_xml(self, track_id, data = {}): 309 | # in theory the case where the lyrics and set storefronts differ this is inefficient 310 | # but it is simpler this way 311 | track_data = data[track_id] if track_id in data else self.session.get_track(track_id) 312 | 313 | lyrics_data_dict = track_data['relationships'].get(self.lyrics_resource) 314 | if lyrics_data_dict and lyrics_data_dict.get('data') and lyrics_data_dict['data'][0].get('attributes'): 315 | lyrics_xml = lyrics_data_dict['data'][0]['attributes']['ttml'] 316 | elif track_data['attributes']['hasLyrics']: 317 | lyrics_data_dict = self.session.get_lyrics(track_id) 318 | track_data['relationships'][self.lyrics_resource] = lyrics_data_dict 319 | lyrics_xml = lyrics_data_dict['data'][0]['attributes']['ttml'] if lyrics_data_dict and lyrics_data_dict.get('data') else None 320 | if not lyrics_xml: 321 | if self.lyrics_resource != 'lyrics': 322 | # unlikely to work, but try it anyway 323 | self.oprint("Warning: lyrics resource not found, trying fallback") 324 | lyrics_data_dict = self.session.get_lyrics(track_id, 'lyrics') 325 | track_data['relationships'][self.lyrics_resource] = lyrics_data_dict 326 | lyrics_xml = lyrics_data_dict['data'][0]['attributes']['ttml'] if lyrics_data_dict and lyrics_data_dict.get('data') else None 327 | 328 | if not lyrics_xml: 329 | self.oprint("Warning: lyrics for this track are not available to this Apple Music account.") 330 | else: 331 | lyrics_xml = None 332 | 333 | return lyrics_xml 334 | 335 | def get_track_lyrics(self, track_id, data = {}): 336 | lyrics_xml = self.get_lyrics_xml(track_id, data) 337 | if not lyrics_xml: return LyricsInfo(embedded=None, synced=None) 338 | 339 | lyrics_dict = xmltodict.parse(lyrics_xml.replace('>