├── README.md ├── README_ja.md ├── api └── client.py ├── config.json ├── mq-dl.py └── requirements.txt /README.md: -------------------------------------------------------------------------------- 1 | # Important Notice 2 | MQ-DL has been put on hold. Sorry for the trouble. The latest build is still working as of 12th Feb 21. 3 | 4 | # MQ-DL 5 | [日本語版はこちらです。](https://github.com/Sorrow446/MQ-DL/blob/master/README_ja.md) 6 | Tool written in Python to download streamable tracks from mora qualitas (モーラクオリタス). 7 | **People have been seen selling my tools. DO NOT buy them. My tools are free and always will be.** 8 | ![](https://i.imgur.com/iCrOETB.png) 9 | [Windows binaries](https://github.com/Sorrow446/MQ-DL/releases) 10 | You might also be interested in [MOOV-DL](https://github.com/Sorrow446/MOOV-DL). 11 | 12 | ## Supported Media 13 | |Type|URL example| 14 | | --- | --- | 15 | |Album|`https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan/album/neo-neo-ep` 16 | |Artist|`https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan` 17 | |Favourites|`https://content.mora-qualitas.com/favorites` 18 | |Playlist|`https://content.mora-qualitas.com/playlist/pp.543884501` 19 | |Track|`https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan/album/neo-neo-ep/track/t.533232673` 20 | |User playlist|`https://content.mora-qualitas.com/playlist/mp.280976624` 21 | 22 | ## Usage Examples 23 | Download a single track. 24 | `mq-dl.py/mq-dl_x86.exe -u https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan/album/neo-neo-ep/track/t.533232673` 25 | 26 | Download from two lists and favourited tracks. 27 | `mq-dl.py/mq-dl_x86.exe -u E:/urls.txt G:/urls_2.txt https://content.mora-qualitas.com/favorites` 28 | 29 | Download a playlist and an artist's discography. 30 | `mq-dl.py/mq-dl_x86.exe -u https://content.mora-qualitas.com/playlist/pp.543884501 https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan` 31 | 32 | You can mix all media types. Duplicate URLs and text files will be filtered. 33 | 34 | ``` 35 | _____ _____ ____ __ 36 | | | |___| \| | 37 | | | | | | |___| | | |__ 38 | |_|_|_|__ _| |____/|_____| 39 | |__| 40 | 41 | usage: mq-dl.py [-h] -u URLS [URLS ...] [-q {1,2,3,4}] [-c {1,2,3,4,5}] 42 | [-t TEMPLATE] [-k] [-o OUTPUT_DIR] [-l {1,2}] 43 | 44 | optional arguments: 45 | -h, --help show this help message and exit 46 | -u URLS [URLS ...], --urls URLS [URLS ...] 47 | Multiple links seperated by spaces or a text file 48 | path. 49 | -q {1,2,3,4}, --quality {1,2,3,4} 50 | 1: AAC PLUS, 2: MP3, 3: AAC, 4: best/FLAC. 51 | -c {1,2,3,4,5}, --cover-size {1,2,3,4,5} 52 | 1: 70, 2: 170, 3: 300, 4: 500, 5: 600. 53 | -t TEMPLATE, --template TEMPLATE 54 | Naming template for track filenames. 55 | -k, --keep-cover Keep cover in album folder. Does not apply to 56 | playlists or favourites. 57 | -o OUTPUT_DIR, --output-dir OUTPUT_DIR 58 | Output directory. Double up backslashes or use single 59 | forward slashes for Windows. Default: \MQ-DL downloads 60 | -l {1,2}, --meta-lang {1,2} 61 | Metadata language. 1 = English, 2 = Japanese. 62 | ``` 63 | 64 | ## Config 65 | |Option|Info| 66 | | --- | --- | 67 | |email|Your email address. 68 | |password|Your password. 69 | |output_dir|Where to download to. You must double up your backslashes or use single forward slashes instead. 70 | |quality|Track quality to request from the API. 1: AAC PLUS, 2: MP3, 3: AAC, 4: best/FLAC. 71 | |cover_size|Cover size to fetch. 1: 70, 2: 170, 3: 300, 4: 500, 5: 600. 72 | |fname_template|Filename template for tracks. Available variables: artist, isrc, title, track, trackpadded, album, albumartist, copyright, label, tracktotal, upc, year 73 | |keep_cover|Keep cover in album folder. Does not apply to playlists or favourites. 74 | |meta_language|Language of metadata to request from the API. 1 = English, 2 = Japanese. 75 | |media_types\artist\folder_template|Folder template for artist media type. Blank = no folder. Available variables: artist. 76 | |media_types\artist\albums|Include main albums in artist media type. 77 | |media_types\artist\compilations|Include compilation albums in artist media type. 78 | |media_types\artist\singles_and_eps|Include singles and EPs in artist media type. 79 | |media_types\favourites\folder_name|Folder name for favourites media type. Blank = no folder. 80 | |media_types\track\folder_template|Folder template for track media type. Blank = no folder. Available variables: album, albumartist, copyright, label, tracktotal, upc, year 81 | |media_types\album\folder_template|Folder name for album media type. Blank = no folder. Available variables: album, albumartist, copyright, label, tracktotal, upc, year 82 | |media_types\playlist\folder_template|Folder name for playlist media type. Blank = no folder. Available variables: id, name 83 | |media_types\user_playlist\folder_template|Folder name for user playlist media type. Blank = no folder. Available variables: id, name 84 | 85 | Any of the same specified CLI arguments will override your config file. 86 | -------------------------------------------------------------------------------- /README_ja.md: -------------------------------------------------------------------------------- 1 | # MQ-DL 2 | モーラクオリタスからのストリームできるトラックをダウンロードするためのPythonで書かれたツールです。 3 | ![](https://i.imgur.com/iCrOETB.png) 4 | [Windowsバイナリー](https://github.com/Sorrow446/MQ-DL/releases) 5 | [MOOV-DL](https://github.com/Sorrow446/MOOV-DL)にも興味があるかもしれません。 6 | 7 | ## 対応されているメディア 8 | |種類|URL例| 9 | | --- | --- | 10 | |アルバム|`https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan/album/neo-neo-ep` 11 | |アーティスト|`https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan` 12 | |お気に入り|`https://content.mora-qualitas.com/favorites` 13 | |プレイリスト|`https://content.mora-qualitas.com/playlist/pp.543884501` 14 | |トラック|`https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan/album/neo-neo-ep/track/t.533232673` 15 | |ユーザープレイリスト|`https://content.mora-qualitas.com/playlist/mp.280976624` 16 | 17 | ## 使用例 18 | トラックをダウンロードする。 19 | `mq-dl.py/mq-dl_x86.exe -u https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan/album/neo-neo-ep/track/t.533232673` 20 | 21 | 2つのリストからとお気に入りのトラックをダウンロードする。 22 | `mq-dl.py/mq-dl_x86.exe -u E:/urls.txt G:/urls_2.txt https://content.mora-qualitas.com/favorites` 23 | 24 | プレイリストとアーティストのディスコグラフィーをダウンロードする。 25 | `mq-dl.py/mq-dl_x86.exe -u https://content.mora-qualitas.com/playlist/pp.543884501 https://content.mora-qualitas.com/artist/ryukku-to-soine-gohan` 26 | 27 | 全部のメディアの種類が組み合わせられます。複製URLと複製テキストファイルがフィルターされます。 28 | 29 | ``` 30 | _____ _____ ____ __ 31 | | | |___| \| | 32 | | | | | | |___| | | |__ 33 | |_|_|_|__ _| |____/|_____| 34 | |__| 35 | 36 | usage: mq-dl.py [-h] -u URLS [URLS ...] [-q {1,2,3,4}] [-c {1,2,3,4,5}] 37 | [-t TEMPLATE] [-k] [-o OUTPUT_DIR] [-l {1,2}] 38 | 39 | optional arguments: 40 | -h, --help show this help message and exit 41 | -u URLS [URLS ...], --urls URLS [URLS ...] 42 | Multiple links seperated by spaces or a text file 43 | path. 44 | -q {1,2,3,4}, --quality {1,2,3,4} 45 | 1: AAC PLUS, 2: MP3, 3: AAC, 4: best/FLAC. 46 | -c {1,2,3,4,5}, --cover-size {1,2,3,4,5} 47 | 1: 70, 2: 170, 3: 300, 4: 500, 5: 600. 48 | -t TEMPLATE, --template TEMPLATE 49 | Naming template for track filenames. 50 | -k, --keep-cover Keep cover in album folder. Does not apply to 51 | playlists or favourites. 52 | -o OUTPUT_DIR, --output-dir OUTPUT_DIR 53 | Output directory. Double up backslashes or use single 54 | forward slashes for Windows. Default: \MQ-DL downloads 55 | -l {1,2}, --meta-lang {1,2} 56 | Metadata language. 1 = English, 2 = Japanese. 57 | ``` 58 | -------------------------------------------------------------------------------- /api/client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class Client(): 5 | 6 | def __init__(self): 7 | self.s = requests.Session() 8 | self.s.headers.update({ 9 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 10 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome' 11 | '/75.0.3770.100 Safari/537.36', 12 | 'Referer':'http://localhost:19330/' 13 | }) 14 | self.bases = ['https://api.napster.com/', 'http://direct.rhapsody.com/'] 15 | self.key = "OWJjYjNlMjUtM2Q2OC00MmUxLTkwM2YtNWVmMmUyMGY2OWYy" 16 | 17 | def make_call(self, method, epoint, data=None, params=None, headers=None, i=0): 18 | if headers: 19 | self.s.headers.update(headers) 20 | r = self.s.request(method, self.bases[i] + epoint, params=params, data=data) 21 | r.raise_for_status() 22 | if headers: 23 | for k in headers.keys(): 24 | del self.s.headers[k] 25 | return r.json() 26 | 27 | def auth(self, email, pwd, lang): 28 | data = { 29 | 'username': email, 30 | 'password': pwd, 31 | 'grant_type': 'password' 32 | } 33 | headers = { 34 | 'Authorization': 'Basic T1dKallqTmxNalV0TTJRMk9DMDBNbVV4TFRrd00yWXROV1ZtT' 35 | 'W1VeU1HWTJPV1l5OllqTTBOakkwWkdFdE5XUTJZUzAwWlROa0xUa3pZ' 36 | 'MkV0TWpGbE9HSmlPV00yTlRReA==' 37 | } 38 | j = self.make_call( 39 | 'POST', 'oauth/token', data=data, headers=headers) 40 | self.token = j['access_token'] 41 | self.guid = j['guid'] 42 | self.lang = lang 43 | 44 | def resolve_id(self, alb_art_shcut, alb_shcut=None, tra_shcut=None): 45 | params = { 46 | 'albumShortcut': alb_shcut, 47 | 'artistShortcut': alb_art_shcut, 48 | 'trackShortcut': tra_shcut, 49 | 'developerKey': '5C8F8G9G8B4D0E5J' 50 | } 51 | j = self.make_call( 52 | 'GET', 'metadata/data/methods/getIdByShortcut.js', params=params, i=1) 53 | return j['id'] 54 | 55 | def get_alb_meta(self, alb_id): 56 | params = { 57 | 'catalog': 'JP_MORAQUALITAS', 58 | 'lang': self.lang, 59 | 'rights': 2 60 | } 61 | headers = { 62 | 'apikey': self.key 63 | } 64 | j = self.make_call( 65 | 'GET', "v2.2/albums/" + alb_id, params=params, headers=headers) 66 | return j['albums'][0] 67 | 68 | def get_art_meta(self, art_id): 69 | params = { 70 | 'catalog': 'JP_MORAQUALITAS', 71 | 'lang': self.lang, 72 | 'rights': 2 73 | } 74 | headers = { 75 | 'apikey': self.key 76 | } 77 | j = self.make_call( 78 | 'GET', "v2.2/artists/" + art_id, params=params, headers=headers) 79 | return j['artists'][0] 80 | 81 | def get_favs_meta(self): 82 | favs_meta = [] 83 | params = { 84 | 'catalog': 'JP_MORAQUALITAS', 85 | 'lang': self.lang, 86 | 'user': self.guid, 87 | 'filter': 'track', 88 | 'rights': 2, 89 | 'limit': 25, 90 | 'offset': 0 91 | } 92 | headers = { 93 | 'apikey': self.key, 94 | 'Authorization': "Bearer " + self.token 95 | } 96 | while True: 97 | j = self.make_call( 98 | 'GET', 'v2.2/me/favorites', params=params, headers=headers) 99 | if j['meta']['returnedCount'] == 0: 100 | break 101 | favs_meta.append(j['favorites']['data']['tracks']) 102 | params['offset'] += 25 103 | return favs_meta[0] 104 | 105 | def get_alb_tra_meta(self, alb_id): 106 | params = { 107 | 'catalog': 'JP_MORAQUALITAS', 108 | 'lang': self.lang, 109 | 'rights': 2 110 | } 111 | headers = { 112 | 'apikey': self.key 113 | } 114 | j = self.make_call( 115 | 'GET', "v2.2/albums/" + alb_id + "/tracks", params=params, headers=headers) 116 | return j['tracks'] 117 | 118 | def get_tra_meta(self, tra_id): 119 | params = { 120 | 'catalog': 'JP_MORAQUALITAS', 121 | 'lang': self.lang, 122 | 'rights': 2 123 | } 124 | headers = { 125 | 'apikey': self.key 126 | } 127 | j = self.make_call( 128 | 'GET', "v2.2/tracks/" + tra_id, params=params, headers=headers) 129 | return j['tracks'][0] 130 | 131 | def get_plist_meta(self, plist_id): 132 | params = { 133 | 'catalog': 'JP_MORAQUALITAS', 134 | 'lang': self.lang, 135 | 'user': self.guid, 136 | 'rights': 2 137 | } 138 | headers = { 139 | 'apikey': self.key, 140 | 'Authorization': "Bearer " + self.token 141 | } 142 | j = self.make_call( 143 | 'GET', "v2.2/me/library/playlists/" + plist_id, params=params, headers=headers) 144 | return j['playlists'] 145 | 146 | def get_plist_tra_meta(self, plist_id): 147 | plist_meta = [] 148 | params = { 149 | 'catalog': 'JP_MORAQUALITAS', 150 | 'lang': self.lang, 151 | 'user': self.guid, 152 | 'filter': 'track', 153 | 'rights': 2, 154 | 'limit': 25, 155 | 'offset': 0 156 | } 157 | headers = { 158 | 'apikey': self.key, 159 | 'Authorization': "Bearer " + self.token 160 | } 161 | 162 | while True: 163 | j = self.make_call( 164 | 'GET', "v2.2/playlists/" + plist_id + "/tracks", params=params, headers=headers) 165 | if j['meta']['returnedCount'] == 0: 166 | break 167 | plist_meta.append(j['tracks']) 168 | params['offset'] += 25 169 | return plist_meta[0] 170 | 171 | def get_cover(self, alb_id, dim): 172 | alb_cov = "{0}imageserver/v2/albums/{1}/images/{2}x{2}.jpg".format( 173 | self.bases[0], alb_id, dim) 174 | return alb_cov 175 | 176 | def get_tra_stream(self, brate, fmt, tra_id): 177 | params = { 178 | 'bitrate': brate, 179 | 'format': fmt, 180 | 'protocol': '', 181 | 'track': tra_id 182 | } 183 | headers = { 184 | 'Authorization': 'Bearer ' + self.token, 185 | 'Origin': 'https://app.napster.com', 186 | 'Content-Type': 'application/json' 187 | } 188 | j = self.make_call( 189 | 'GET', 'v2.2/streams', params=params, headers=headers) 190 | return j['streams'][0]['url'] 191 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "", 3 | "password": "", 4 | "output_dir": "", 5 | "quality": 4, 6 | "cover_size": 5, 7 | "fname_template": "{trackpadded}. {title}", 8 | "keep_cover": true, 9 | "meta_language": 1, 10 | "media_types": { 11 | "artist": { 12 | "folder_template": "{artist} discography", 13 | "albums": true, 14 | "compilations": false, 15 | "singles_and_eps": true 16 | }, 17 | "favourites": { 18 | "folder_name": "Favourites" 19 | }, 20 | "track": { 21 | "folder_template": "{albumartist} - {album}" 22 | }, 23 | "album": { 24 | "folder_template": "{albumartist} - {album}" 25 | }, 26 | "playlist": { 27 | "folder_template": "{name}_{id}" 28 | }, 29 | "user_playlist": { 30 | "folder_template": "{name}_{id}" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /mq-dl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import re 4 | import sys 5 | import json 6 | import argparse 7 | import platform 8 | import traceback 9 | 10 | import requests 11 | from tqdm import tqdm 12 | from mutagen import File 13 | from mutagen import id3 14 | from mutagen.mp4 import MP4, MP4Cover 15 | from mutagen.flac import FLAC, Picture 16 | from mutagen.id3 import ID3NoHeaderError 17 | from requests.exceptions import HTTPError 18 | 19 | from api import client 20 | 21 | 22 | def err(msg): 23 | print(msg) 24 | traceback.print_exc() 25 | 26 | def auth(email, pwd, lang): 27 | try: 28 | client.auth(email, pwd, lang) 29 | except HTTPError: 30 | err('Failed to login.') 31 | sys.exit(1) 32 | print('Signed in successfully.') 33 | 34 | def parse_cfg(): 35 | with open('config.json') as f: 36 | return json.load(f) 37 | 38 | def read_txt(txt_path): 39 | with open(txt_path) as f: 40 | return f.readlines() 41 | 42 | def process_urls(urls): 43 | all_fixed = [] 44 | txt_paths = [] 45 | fix = lambda x : x.strip().split('?type')[0] 46 | for url in urls: 47 | if url.endswith('.txt'): 48 | if url in txt_paths: 49 | continue 50 | for txt_url in read_txt(url): 51 | fixed = fix(txt_url) 52 | if not fixed in all_fixed: 53 | all_fixed.append(fixed) 54 | txt_paths.append(url) 55 | else: 56 | fixed = fix(url) 57 | if not fixed in all_fixed: 58 | all_fixed.append(fixed) 59 | return all_fixed 60 | 61 | def process_cfg(cfg): 62 | cfg['meta_lang'] = {1: 'en-US', 2: 'ja-JP'}[cfg['meta_lang']] 63 | cfg['quality'] = { 64 | 1: 'AAC PLUS', 2: 'MP3', 3: 'AAC', 4: 'FLAC'}[cfg['quality']] 65 | cfg['cover_size'] = { 66 | 1: '70', 2: '170', 3: '300', 4: '500', 5: '600'}[cfg['cover_size']] 67 | if (cfg['media_types']['artist']['albums'] == False and 68 | cfg['media_types']['artist']['compilations'] == False and 69 | cfg['media_types']['artist']['singles_and_eps'] == False): 70 | raise ValueError('All artists values cannot be false.') 71 | cfg['media_types']['artist']['main'] = cfg['media_types']['artist']['albums'] 72 | cfg['media_types']['artist']['singlesAndEPs'] = cfg['media_types']['artist']['singles_and_eps'] 73 | del cfg['media_types']['artist']['albums'] 74 | del cfg['media_types']['artist']['singles_and_eps'] 75 | if not cfg["output_dir"]: 76 | cfg['output_dir'] = "MQ-DL downloads" 77 | cfg['urls'] = process_urls(cfg['urls']) 78 | return cfg 79 | 80 | def resolve_ids(match, groups_len): 81 | alb_art = None 82 | alb_shcut = None 83 | tra_shcut = None 84 | if groups_len == 1: 85 | alb_art = match.group(1) 86 | elif groups_len == 2: 87 | alb_art = match.group(1) 88 | alb_shcut = match.group(2) 89 | elif groups_len == 3: 90 | alb_art = match.group(1) 91 | alb_shcut = match.group(2) 92 | tra_shcut = match.group(3) 93 | return client.resolve_id(alb_art, alb_shcut=alb_shcut, tra_shcut=tra_shcut) 94 | 95 | def get_artist_meta(art_id): 96 | alb_ids = [] 97 | art_meta = client.get_art_meta(art_id) 98 | if not art_meta.get('albumGroups'): 99 | return art_meta['name'], None 100 | for k, v in cfg['media_types']['artist'].items(): 101 | if v: 102 | try: 103 | alb_ids.extend(art_meta['albumGroups'][k]) 104 | except KeyError: 105 | pass 106 | return art_meta['name'], alb_ids 107 | 108 | def parse_prefs(): 109 | cfg = parse_cfg() 110 | parser = argparse.ArgumentParser() 111 | parser.add_argument( 112 | '-u', '--urls', 113 | nargs='+', required=True, 114 | help='Multiple links seperated by spaces or a text file path.' 115 | ) 116 | parser.add_argument( 117 | '-q', '--quality', 118 | choices=[1, 2, 3, 4], default=cfg['quality'], type=int, 119 | help='1: AAC PLUS, 2: MP3, 3: AAC, 4: best/FLAC.' 120 | ) 121 | parser.add_argument( 122 | '-c', '--cover-size', 123 | choices=[1, 2, 3, 4 ,5], default=cfg['cover_size'], type=int, 124 | help='1: 70, 2: 170, 3: 300, 4: 500, 5: 600.' 125 | ) 126 | parser.add_argument( 127 | '-t', '--template', 128 | default=cfg['fname_template'], 129 | help='Naming template for track filenames.' 130 | ) 131 | parser.add_argument( 132 | '-k', '--keep-cover', 133 | action='store_true', default=cfg['keep_cover'], 134 | help='Keep cover in album folder. Does not apply to playlists or favourites.' 135 | ) 136 | parser.add_argument( 137 | '-o', '--output-dir', 138 | default=cfg['output_dir'], 139 | help='Output directory. Double up backslashes or use single ' 140 | 'forward slashes for Windows. Default: \MQ-DL downloads' 141 | ) 142 | parser.add_argument( 143 | '-l', '--meta-lang', 144 | choices=[1, 2], type=int, default=cfg['meta_language'], 145 | help='Metadata language. 1 = English, 2 = Japanese.' 146 | ) 147 | args = vars(parser.parse_args()) 148 | cfg.update(args) 149 | cfg = process_cfg(cfg) 150 | return cfg 151 | 152 | def check_url(url): 153 | url_types = [ 154 | # regex | url type | at least one resolve needed 155 | (r'https://content.mora-qualitas.com/members/[a-zA-Z-\d]+/favorites$', 'favourites', False), 156 | (r'https://content.mora-qualitas.com/favorites$', 'favourites', False), 157 | (r'https://content.mora-qualitas.com/\?id=(alb.\d+)$', 'album', False), 158 | (r'https://content.mora-qualitas.com/artist/(art.\d+)$', 'artist', False), 159 | (r'https://content.mora-qualitas.com/artist/([a-zA-Z-\d]+)$', 'artist', True), 160 | (r'https://content.mora-qualitas.com/artist/([a-zA-Z-\d]+)/album/([a-zA-Z-\d*]+)$', 'album', True), 161 | (r'https://content.mora-qualitas.com/artist/(art.\d+)/album/(alb.\d+)$', 'album', False), 162 | (r'https://content.mora-qualitas.com/artist/([a-zA-Z-\d]+)/album/([a-zA-Z-\d*]+)/track/([A-Za-z\d-]+)$', 'track', True), 163 | (r'https://content.mora-qualitas.com/\?id=(tra.\d+)$', 'track', False), 164 | (r'https://content.mora-qualitas.com/artist/([a-zA-Z-\d]+)/album/([a-zA-Z-\d*]+)/track/(t.\d+)$', 'track', True), 165 | (r'https://content.mora-qualitas.com/playlist/(mp.\d+)$', 'usr_playlist', False), 166 | (r'https://content.mora-qualitas.com/playlist/(pp\.\d+)$', 'playlist', False) 167 | ] 168 | for url_type in url_types: 169 | match = re.match(url_type[0], url, re.IGNORECASE) 170 | if not match: 171 | continue 172 | groups_len = len(match.groups()) 173 | if url_type[2] == True: 174 | _id = resolve_ids(match, groups_len) 175 | else: 176 | if groups_len == 0: 177 | _id = None 178 | else: 179 | _id = match.group(groups_len) 180 | media_type = url_type[1] 181 | break 182 | if match: 183 | return media_type, _id 184 | else: 185 | return None, None 186 | 187 | def parse_meta(src, meta=None, num=None, total=None): 188 | # Set tracktotal / num manually in case of disked albums. 189 | if meta: 190 | meta['artist'] = src.get('artistName') 191 | meta['isrc'] = src.get('isrc') 192 | meta['title'] = src.get('name') 193 | meta['track'] = num 194 | meta['trackpadded'] = str(num).zfill(len(str(meta['tracktotal']))) 195 | else: 196 | meta = { 197 | 'album': src.get('name'), 198 | 'albumartist': src.get('artistName'), 199 | 'copyright': src.get('copyright'), 200 | 'label': src.get('label'), 201 | 'tracktotal': total, 202 | 'upc': src.get('upc') 203 | } 204 | try: 205 | meta['year'] = src.get('originallyReleased').split('-')[0] 206 | except AttributeError: 207 | meta['year'] = None 208 | return meta 209 | 210 | def query_quals(quals): 211 | parsed_quals = {} 212 | best_order = ['FLAC', 'AAC', 'MP3', 'AAC PLUS'] 213 | want = cfg['quality'] 214 | orig_want = want 215 | for q in quals: 216 | parsed_quals.setdefault(q['name'], []).extend( 217 | [(q['bitrate'], q['sampleBits'], q['sampleRate'])] 218 | ) 219 | while True: 220 | if parsed_quals[want]: 221 | best = max(parsed_quals[want]) 222 | ext = { 223 | 'FLAC': '.flac', 224 | 'AAC': '.m4a', 225 | 'MP3': '.mp3', 226 | 'AAC PLUS': '.m4a' 227 | }[want] 228 | specs = { 229 | 'fmt': want, 230 | 'brate': best[0], 231 | 'bdepth': best[1], 232 | 'srate': best[2], 233 | 'ext': ext 234 | } 235 | break 236 | else: 237 | want = best_order[best_order.index(want)+1] 238 | if orig_want != want: 239 | print( 240 | "Unavailable in your chosen quality. {} will be used " 241 | "instead.".format(want) 242 | ) 243 | return specs 244 | 245 | def parse_template(meta, unparsed, default): 246 | try: 247 | parsed = unparsed.format(**meta) 248 | except KeyError: 249 | print('Failed to parse template. Default one will be used instead.') 250 | parsed = default.format(**meta) 251 | return sanitize(parsed) 252 | 253 | def sanitize(f): 254 | if is_win: 255 | return re.sub(r'[\/:*?"><|]', '_', f) 256 | else: 257 | return re.sub('/', '_', f) 258 | 259 | def dir_setup(path): 260 | if not os.path.isdir(path): 261 | os.makedirs(path) 262 | 263 | def get_track(stream_url): 264 | r = requests.get(stream_url, stream=True, 265 | headers={ 266 | 'Range': 'bytes=0-', 267 | 'User-Agent': client.s.headers['User-Agent'], 268 | 'Referer': client.s.headers['Referer'] 269 | } 270 | ) 271 | r.raise_for_status() 272 | return r, int(r.headers['content-length']) 273 | 274 | def download_track(stream_url, specs, title, num, total, pre_path): 275 | if specs['fmt'] == "FLAC": 276 | fmtted_specs = "{}-bit / {} Hz FLAC".format(specs['bdepth'], 277 | specs['srate']) 278 | else: 279 | fmtted_specs = "{} kbps {}".format(specs['brate'], specs['fmt']) 280 | print("Downloading track {} of {}: {} - {}".format(num, total, title, 281 | fmtted_specs) 282 | ) 283 | r, size = get_track(stream_url) 284 | with open(pre_path, 'wb') as f: 285 | with tqdm(total=size, unit='B', unit_scale=True, unit_divisor=1024) as bar: 286 | for chunk in r.iter_content(32*1024): 287 | if chunk: 288 | f.write(chunk) 289 | bar.update(len(chunk)) 290 | 291 | def write_tags(pre_path, meta, fmt, cov_path): 292 | if fmt == "FLAC": 293 | audio = FLAC(pre_path) 294 | del meta['trackpadded'] 295 | for k, v in meta.items(): 296 | if v: 297 | audio[k] = str(v) 298 | if cov_path: 299 | with open(cov_path, 'rb') as f: 300 | image = Picture() 301 | image.type = 3 302 | image.mime = "image/jpeg" 303 | image.data = f.read() 304 | audio.add_picture(image) 305 | elif fmt == "MP3": 306 | try: 307 | audio = id3.ID3(pre_path) 308 | except ID3NoHeaderError: 309 | audio = id3.ID3() 310 | audio['TRCK'] = id3.TRCK( 311 | encoding=3, text="{}/{}".format(meta['track'], meta['tracktotal']) 312 | ) 313 | legend={ 314 | 'album': id3.TALB, 315 | 'albumartist': id3.TPE2, 316 | 'artist': id3.TPE1, 317 | #"comment": id3.COMM, 318 | 'copyright': id3.TCOP, 319 | 'isrc': id3.TSRC, 320 | 'label': id3.TPUB, 321 | 'title': id3.TIT2, 322 | 'year': id3.TYER 323 | } 324 | for k, v in meta.items(): 325 | id3tag = legend.get(k) 326 | if v and id3tag: 327 | audio[id3tag.__name__] = id3tag(encoding=3, text=v) 328 | if cov_path: 329 | with open(cov_path, 'rb') as f: 330 | audio.add(id3.APIC(3, 'image/jpeg', 3, None, f.read())) 331 | else: 332 | audio = MP4(pre_path) 333 | audio['\xa9nam'] = meta['title'] 334 | audio['\xa9alb'] = meta['album'] 335 | audio['aART'] = meta['albumartist'] 336 | audio['\xa9ART'] = meta['artist'] 337 | audio['trkn'] = [(meta['track'], meta['tracktotal'])] 338 | audio['\xa9day'] = meta['year'] 339 | audio['cprt'] = meta['copyright'] 340 | if cov_path: 341 | with open(cov_path, "rb") as f: 342 | audio['covr'] = [MP4Cover(f.read(), imageformat = MP4Cover.FORMAT_JPEG)] 343 | audio.save(pre_path) 344 | 345 | def write_cov(alb_id, cov_path): 346 | url = client.get_cover(alb_id, cfg['cover_size']) 347 | r = requests.get(url, 348 | headers={ 349 | 'User-Agent': client.s.headers['User-Agent'], 350 | 'Referer': client.s.headers['Referer'] 351 | } 352 | ) 353 | r.raise_for_status() 354 | with open(cov_path, 'wb') as f: 355 | f.write(r.content) 356 | 357 | def iter_track(tra_src_meta, alb_path, total, cov_path, alb_id=None, cov=True, alb_meta=None, n=-1): 358 | for num, track in enumerate(tra_src_meta, 1): 359 | if n == -1: 360 | print("Track {} of {}:".format(num, total)) 361 | else: 362 | #print("Track 1 of 1:") 363 | num = n 364 | try: 365 | if track['isStreamable'] == False: 366 | print('Track isn\'t allowed to be streamed.') 367 | continue 368 | specs = query_quals(track['formats'] + track['losslessFormats']) 369 | if alb_meta == None: 370 | alb_id = track['albumId'] 371 | alb_src_meta = client.get_alb_meta(alb_id) 372 | alb_meta = parse_meta(alb_src_meta, total=total) 373 | meta = parse_meta(track, meta=alb_meta, num=num) 374 | pre_path = os.path.join(alb_path, str(num) + ".mq-dl") 375 | template = parse_template(meta, cfg['fname_template'], '{trackpadded}. {title}') 376 | post_path = os.path.join(alb_path, template) + specs['ext'] 377 | if os.path.isfile(post_path): 378 | print('Track already exists locally.') 379 | continue 380 | stream_url = client.get_tra_stream(specs['brate'], specs['fmt'], 381 | track['id']) 382 | if n != -1: 383 | num = 1 384 | download_track(stream_url, specs, meta['title'], num, total, pre_path) 385 | try: 386 | write_cov(alb_id, cov_path) 387 | except HTTPError: 388 | err('Failed to get cover.') 389 | cov_path = None 390 | except OSError: 391 | err('Failed to write cover.') 392 | cov_path = None 393 | write_tags(pre_path, meta, specs['fmt'], cov_path) 394 | try: 395 | os.rename(pre_path, post_path) 396 | except OSError: 397 | err('Failed to rename track.') 398 | if cov_path != None: 399 | if cov == False or cfg['keep_cover'] == False: 400 | os.remove(cov_path) 401 | except Exception: 402 | err('Track failed.') 403 | 404 | def album(alb_id, template=''): 405 | alb_src_meta = client.get_alb_meta(alb_id) 406 | tra_src_meta = client.get_alb_tra_meta(alb_id) 407 | total = len(tra_src_meta) 408 | alb_meta = parse_meta(alb_src_meta, total=total) 409 | if not template: 410 | template = parse_template( 411 | alb_meta, cfg['media_types']['album']['folder_template'], '{albumartist} - {album}') 412 | alb_fol = "{} - {}".format(alb_meta['albumartist'], alb_meta['album']) 413 | alb_path = os.path.join(cfg['output_dir'], template, sanitize(alb_fol)) 414 | cov_path = os.path.join(alb_path, 'cover.jpg') 415 | dir_setup(alb_path) 416 | print(alb_fol) 417 | iter_track(tra_src_meta, alb_path, total, cov_path, alb_id=alb_id, alb_meta=alb_meta) 418 | 419 | def artist(art_id): 420 | artist, art_ids = get_artist_meta(art_id) 421 | print(artist) 422 | if art_ids == None: 423 | print("Artist either doesn't have any albums or they have been filtered out.") 424 | return 425 | template = parse_template( 426 | {'artist': artist}, cfg['media_types']['artist']['folder_template'], '{artist} discography') 427 | total = len(art_ids) 428 | for num, art_id in enumerate(art_ids, 1): 429 | print("Album {} of {}:".format(num, total)) 430 | try: 431 | album(art_id, template=template) 432 | except Exception: 433 | err('Album failed.') 434 | 435 | def favourites(_): 436 | favs_src_meta = client.get_favs_meta() 437 | total = len(favs_src_meta) 438 | favs_path = os.path.join(cfg['output_dir'], sanitize(cfg['media_types']['favourites']['folder_name'])) 439 | cov_path = os.path.join(favs_path, 'cover.jpg') 440 | dir_setup(favs_path) 441 | print('Favourites') 442 | iter_track(favs_src_meta, favs_path, total, cov_path, cov=False) 443 | 444 | def track(tra_id): 445 | tra_src_meta = client.get_tra_meta(tra_id) 446 | alb_src_meta = client.get_alb_meta(tra_src_meta['albumId']) 447 | alb_tra_src_meta = client.get_alb_tra_meta(tra_src_meta['albumId']) 448 | for num, track in enumerate(alb_tra_src_meta, 1): 449 | if track['id'].lower() == tra_id.lower(): 450 | alb_tra_src_meta = track 451 | track_num = num 452 | total = alb_src_meta['trackCount'] 453 | alb_meta = parse_meta(alb_src_meta, total=total) 454 | print("{} - {}".format(tra_src_meta['artistName'], tra_src_meta['name'])) 455 | template = parse_template( 456 | alb_meta, cfg['media_types']['track']['folder_template'], '{albumartist} - {album}') 457 | tra_path = os.path.join(cfg['output_dir'], template) 458 | cov_path = os.path.join(tra_path, 'cover.jpg') 459 | dir_setup(tra_path) 460 | iter_track([tra_src_meta], tra_path, 1, cov_path, alb_id=tra_src_meta['albumId'], alb_meta=alb_meta, n=track_num) 461 | 462 | def playlist(plist_id, key='playlist'): 463 | plist_src_meta = client.get_plist_meta(plist_id) 464 | if len(plist_src_meta) == 0: 465 | print('Playlist does not exist.') 466 | return 467 | plist_src_meta = plist_src_meta[0] 468 | total = plist_src_meta['trackCount'] 469 | if total == 0: 470 | print('Playlist does not contain any tracks.') 471 | return 472 | print(plist_src_meta['name']) 473 | template_dict = {'id': plist_src_meta['id'], 'name': plist_src_meta['name']} 474 | template = parse_template(template_dict, 475 | cfg['media_types'][key]['folder_template'], '{name}_{id}') 476 | plist_path = os.path.join(cfg['output_dir'], template) 477 | cov_path = os.path.join(plist_path, 'cover.jpg') 478 | dir_setup(plist_path) 479 | plist_tra_src_meta = client.get_plist_tra_meta(plist_id) 480 | iter_track(plist_tra_src_meta, plist_path, total, cov_path, cov=False) 481 | 482 | def usr_playlist(plist_id): 483 | playlist(plist_id, key='user_playlist') 484 | 485 | def main(media_type, _id): 486 | globals()[media_type](_id) 487 | 488 | if __name__ == "__main__": 489 | client = client.Client() 490 | is_win = platform.system() == "Windows" 491 | try: 492 | if hasattr(sys, 'frozen'): 493 | os.chdir(os.path.dirname(sys.executable)) 494 | else: 495 | os.chdir(os.path.dirname(__file__)) 496 | except OSError: 497 | pass 498 | print(''' 499 | _____ _____ ____ __ 500 | | | |___| \| | 501 | | | | | | |___| | | |__ 502 | |_|_|_|__ _| |____/|_____| 503 | |__| 504 | ''') 505 | cfg = parse_prefs() 506 | auth(cfg['email'], cfg['password'], cfg['meta_lang']) 507 | total = len(cfg['urls']) 508 | for num, url in enumerate(cfg['urls'], 1): 509 | print("\nItem {} of {}:".format(num, total)) 510 | media_type, _id = check_url(url) 511 | if media_type == None: 512 | print("Invalid URL:", url) 513 | continue 514 | try: 515 | main(media_type, _id) 516 | except KeyboardInterrupt: 517 | sys.exit() 518 | except Exception: 519 | err('Item failed.') 520 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tqdm 2 | mutagen 3 | requests --------------------------------------------------------------------------------