├── .gitignore ├── README.md ├── funding.yml ├── genie.py ├── modules ├── __init__.py ├── client.py ├── config.py.example ├── exceptions.py ├── logger.py └── utils.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # GeniePy specific. 132 | logs/ 133 | modules/config.py 134 | .idea 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | 6 | 7 | 8 |

9 | 10 | # DEPRECATED 11 | See: https://github.com/Slyyxp/rsack 12 | 13 | ## Overview 14 | **GeniePy** is a tool for downloading streamable tracks from **[Genie.co.kr](https://www.genie.co.kr/)** 15 | 16 | Tested on **[Python 3.8.0](https://www.python.org/downloads/release/python-380/)** 17 | 18 | ## Prerequisites 19 | 20 | - Python 3.6+ 21 | - Genie.co.kr subscription. 22 | 23 | ## Installation & Setup 24 | 25 | ```console 26 | $ git clone https://github.com/Slyyxp/GeniePy.git 27 | $ cd GeniePy 28 | $ pip install -r requirements.txt 29 | ``` 30 | 31 | * Insert username and password into config.py.example 32 | * Optionally add the device id & user agent of your own android device 33 | * Rename config.py.example to config.py 34 | 35 | ## Command Usage 36 | ``` 37 | python genie.py -u {album_url} -f {format} 38 | ``` 39 | Command | Description | Example 40 | ------------- | ------------- | ------------- 41 | -u | Genie album url (Required) | `https://www.genie.co.kr/detail/albumInfo?axnm=81510805` 42 | -f | Format. 1: MP3, 2: 16-bit FLAC, 3: 24-bit FLAC (Optional) | `2` 43 | 44 | ## config.py 45 | 46 | **credentials:** 47 | 48 | Config | Description | Example 49 | ------------- | ------------- | ------------- 50 | username | Genie Username | `Slyyxp` 51 | password | Genie Password | `ReallyBadPassword123` 52 | device_id | Android Device ID | `eb9d53a3c424f961` 53 | user_agent | User Agent | `genie/ANDROID/5.1.1/WIFI/SM-G930L/dreamqltecaneb9d53a3c424f961/500200714/40807` 54 | 55 | **prefs:** 56 | 57 | Config | Description | Example 58 | ------------- | ------------- | ------------- 59 | download_directory | Directory to download files to | `Z:/GeniePy/downloads` 60 | log_directory | Directory to save log files to | `Z:/GeniePy/logs` 61 | default_format | Default download format (1: MP3, 2: 16-bit FLAC, 3: 24-bit FLAC) | `3` 62 | artist_folders | Whether or not to nest downloads into artist folders | `True/False` 63 | ascii_art | Whether or not to display ascii art on every run | `True/False` 64 | 65 | # To Do 66 | - [x] Figure out hardware identifiers 67 | - [ ] Refactor & Cleanup rip() 68 | - [ ] Playlist support 69 | - [ ] Artist support 70 | 71 | ## Disclaimer 72 | - The usage of this script **may be** illegal in your country. It's your own responsibility to inform yourself of Copyright Law. 73 | -------------------------------------------------------------------------------- /funding.yml: -------------------------------------------------------------------------------- 1 | github: slyyxp 2 | -------------------------------------------------------------------------------- /genie.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import os 4 | import sys 5 | from urllib import parse 6 | import mutagen.id3 as id3 7 | from mutagen.flac import FLAC 8 | from mutagen.id3 import ID3NoHeaderError 9 | from tqdm import tqdm 10 | from requests import HTTPError 11 | 12 | from modules import client, config, exceptions, logger, utils 13 | 14 | 15 | def getargs(): 16 | parser = argparse.ArgumentParser() 17 | parser.add_argument('-u', nargs="*", help="URL.", required=True) 18 | parser.add_argument('-f', help="Format. 1: MP3, 2: 16-bit FLAC, 3: 24-bit FLAC", 19 | default=prefs['default_format'], choices=[1, 2, 3], type=int 20 | ) 21 | return parser.parse_args() 22 | 23 | 24 | def download_track(url, title, abs, cur, total, specs): 25 | """ 26 | :param url: Direct stream link to the track 27 | :param title: Title of the song for tqdm progress 28 | :param abs: Path of the file we'll download it to. 29 | :param cur: Track number 30 | :param total: Track total 31 | :param specs: Track specifications 32 | """ 33 | print('\nDownloading track {} of {}: {} - {}'.format(cur, total, title, specs)) 34 | r = client.session.get(parse.unquote(url), stream=True) 35 | r.raise_for_status() 36 | size = int(r.headers.get('content-length', 0)) 37 | with open(abs, 'wb') as f: 38 | with tqdm(total=size, unit='B', 39 | unit_scale=True, unit_divisor=1024, 40 | initial=0, miniters=1) as bar: 41 | for chunk in r.iter_content(32 * 1024): 42 | if chunk: 43 | f.write(chunk) 44 | bar.update(len(chunk)) 45 | 46 | def download_cover(cover_url, path): 47 | """ 48 | :param cover_url: Direct url to the cover artwork 49 | :param path: Path to download the cover to 50 | """ 51 | path = os.path.join(path, prefs['cover_name']) 52 | if not utils.exist_check(path): 53 | r = client.session.get(cover_url) 54 | with open(path, 'wb') as f: 55 | f.write(r.content) 56 | 57 | def fix_tags(abs, ext, f_meta): 58 | """ 59 | :param abs: Path of the file we're tagging 60 | :param ext: Extension of the file we're tagging 61 | :param f_meta: Dict containing the metadata of the track we're tagging. 62 | """ 63 | if ext == "mp3": 64 | try: 65 | audio = id3.ID3(abs) 66 | except ID3NoHeaderError: 67 | audio = id3.ID3() 68 | audio['TIT2'] = id3.TIT2(text=str(f_meta['track_title'])) 69 | audio['TALB'] = id3.TALB(text=str(f_meta['album_title'])) 70 | audio['TCON'] = id3.TCON(text=str(f_meta['album_title'])) 71 | audio['TRCK'] = id3.TRCK(text=str(f_meta['track_number']) + "/" + str(f_meta['track_total'])) 72 | audio['TPOS'] = id3.TPOS(text=str(f_meta['disc_number']) + "/" + str(f_meta['disc_total'])) 73 | audio['TDRC'] = id3.TDRC(text=f_meta['release_date']) 74 | audio['TPUB'] = id3.TPUB(text=f_meta['planning']) 75 | audio['TPE1'] = id3.TPE1(text=f_meta['track_artist']) 76 | audio['TPE2'] = id3.TPE2(text=f_meta['album_artist']) 77 | audio.save(abs, "v2_version=3") 78 | else: 79 | audio = FLAC(abs) 80 | audio['TRACKTOTAL'] = str(f_meta['track_total']) 81 | audio['DISCTOTAL'] = str(f_meta['disc_total']) 82 | audio['DATE'] = f_meta['release_date'] 83 | audio['LABEL'] = f_meta['planning'] 84 | audio['ARTIST'] = f_meta['track_artist'] 85 | audio['ALBUMARTIST'] = f_meta['album_artist'] 86 | audio.save() 87 | 88 | 89 | def main(): 90 | """ 91 | Main function which will control the flow of our script when called. 92 | """ 93 | total = len(args.u) 94 | for n, url in enumerate(args.u, 1): 95 | logger_genie.info("Album {} of {}".format(n, total)) 96 | album_id = utils.check_url(url) 97 | if not album_id: 98 | return 99 | meta = client.get_meta(album_id) 100 | album_fol = "{} - {}".format( 101 | parse.unquote(meta['DATA0']['DATA'][0]['ARTIST_NAME']), 102 | parse.unquote(meta['DATA0']['DATA'][0]['ALBUM_NAME']) 103 | ) 104 | if prefs['artist_folders']: 105 | album_fol_abs = os.path.join( 106 | os.path.dirname(__file__), prefs['download_directory'], 107 | parse.unquote(utils.sanitize(meta['DATA0']['DATA'][0]['ARTIST_NAME'])), utils.sanitize(album_fol) 108 | ) 109 | else: 110 | album_fol_abs = os.path.join( 111 | os.path.dirname(__file__), prefs['download_directory'], utils.sanitize(album_fol) 112 | ) 113 | logger_genie.info("Album found: " + album_fol) 114 | utils.make_dir(album_fol_abs) 115 | cover_url = parse.unquote(meta['DATA0']['DATA'][0]['ALBUM_IMG_PATH_600']) 116 | # If no 600x600 artwork is present then fallback to what's available 117 | if not cover_url: 118 | cover_url = parse.unquote(meta['DATA0']['DATA'][0]['ALBUM_IMG_PATH']) 119 | download_cover(cover_url, album_fol_abs) 120 | f_meta = { 121 | "album_title": meta['DATA0']['DATA'][0]['ALBUM_NAME'], 122 | "track_total": len(meta['DATA1']['DATA']), 123 | "album_artist": parse.unquote(meta['DATA0']['DATA'][0]['ARTIST_NAME']), 124 | "release_date": meta['DATA0']['DATA'][0]['ALBUM_RELEASE_DT'], 125 | "planning": parse.unquote(meta['DATA0']['DATA'][0]['ALBUM_PLANNER']) 126 | } 127 | f_meta['disc_total'] = meta['DATA1']['DATA'][f_meta['track_total'] - 1]['ALBUM_CD_NO'] 128 | for track in meta['DATA1']['DATA']: 129 | try: 130 | s_meta = client.get_stream_meta(track['SONG_ID'], args.f) 131 | except HTTPError: 132 | logger_genie.warning("Could not get stream info for {}".format(track['SONG_ID'])) 133 | continue 134 | except exceptions.StreamMetadataError: 135 | continue 136 | cur = track['ROWNUM'] 137 | track_title = parse.unquote(track['SONG_NAME']) 138 | f_meta['track_title'] = track_title 139 | f_meta['track_artist'] = parse.unquote(track['ARTIST_NAME']) 140 | f_meta['disc_number'] = track['ALBUM_CD_NO'] 141 | f_meta['track_number'] = track['ALBUM_TRACK_NO'] 142 | ext = utils.get_ext(s_meta['FILE_EXT']) 143 | post_abs = os.path.join( 144 | album_fol_abs, "{}. {}.{}".format( 145 | cur.zfill(2), utils.sanitize(track_title), ext 146 | ) 147 | ) 148 | if utils.exist_check(post_abs): 149 | continue 150 | if not utils.allowed_check(s_meta['STREAMING_LICENSE_YN']): 151 | continue 152 | pre_abs = os.path.join(album_fol_abs, cur + ".genie-dl") 153 | specs = utils.parse_specs(s_meta['FILE_EXT'], s_meta['FILE_BIT']) 154 | download_track(s_meta['STREAMING_MP3_URL'], track_title, 155 | pre_abs, cur, f_meta['track_total'], specs 156 | ) 157 | try: 158 | fix_tags(pre_abs, ext, f_meta) 159 | logger_genie.debug("Tags updated: {}".format(f_meta)) 160 | except Exception as e: 161 | raise e 162 | try: 163 | os.rename(pre_abs, post_abs) 164 | logger_genie.debug("{} has been renamed".format(post_abs)) 165 | except OSError: 166 | raise exceptions.TrackRenameError("Could not rename {}".format(pre_abs)) 167 | 168 | 169 | if __name__ == '__main__': 170 | try: 171 | client = client.Client() 172 | prefs = config.prefs 173 | if prefs['ascii_art']: 174 | utils.print_title() 175 | args = getargs() 176 | logger_genie = logger.log_setup() 177 | logger_genie.debug(args) 178 | args.f = {1: 320, 2: 1000, 3: "24bit"}[args.f] 179 | client.auth() 180 | main() 181 | except KeyboardInterrupt: 182 | sys.exit() 183 | -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /modules/client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import requests 5 | 6 | import modules.config as cfg 7 | import modules.exceptions as exceptions 8 | 9 | logger_client = logging.getLogger("Client") 10 | 11 | 12 | class Client: 13 | 14 | def __init__(self): 15 | self.session = requests.Session() 16 | self.credentials = cfg.credentials 17 | self.dev_id = self.credentials['device_id'] 18 | self.usr_agent = self.credentials['user_agent'] 19 | 20 | self.session.headers.update({ 21 | "User-Agent": self.usr_agent, 22 | "Referer": "app.genie.co.kr" 23 | }) 24 | 25 | def make_call(self, sub, epoint, data): 26 | """ 27 | :param sub: Url Prefix 28 | :param epoint: Endpoint 29 | :param data: Post data 30 | :return: API Response 31 | 32 | Endpoints used: 33 | player/j_StmInfo.json - Provides information on the streamed track. 34 | member/j_Member_Login.json - Authentication. 35 | song/j_AlbumSongList.json - Provides album information. 36 | """ 37 | r = self.session.post("https://{}.genie.co.kr/{}".format(sub, epoint), data=data) 38 | r.raise_for_status() 39 | 40 | return r.json() 41 | 42 | def auth(self): 43 | """ 44 | Authenticate our session appearing as an Android device 45 | """ 46 | data = { 47 | "uxd": self.credentials['username'], 48 | "uxx": self.credentials['password'] 49 | } 50 | r = self.make_call("app", "member/j_Member_Login.json", data) 51 | if r['Result']['RetCode'] != "0": 52 | raise exceptions.AuthenticationError("Authentication failed.") 53 | else: 54 | logger_client.info("Login Successful.") 55 | self.usr_num = r['DATA0']['MemUno'] 56 | self.usr_token = r['DATA0']['MemToken'] 57 | self.stm_token = r['DATA0']['STM_TOKEN'] 58 | 59 | def get_meta(self, id): 60 | """ 61 | :param id: Album ID. 62 | :return: API Response containing album metadata. 63 | """ 64 | data = { 65 | "axnm": id, 66 | "dcd": self.dev_id, 67 | "mts": "Y", 68 | "stk": self.stm_token, 69 | "svc": "IV", 70 | "tct": "Android", 71 | "unm": self.usr_num, 72 | "uxtk": self.usr_token 73 | } 74 | r = self.make_call("app", "song/j_AlbumSongList.json", data) 75 | logger_client.debug(r) 76 | if r['Result']['RetCode'] != "0": 77 | raise exceptions.AlbumMetadataError("Failed to get album metadata.") 78 | 79 | return r 80 | 81 | def get_stream_meta(self, id, q): 82 | """ 83 | :param id: Album ID 84 | :param q: Album quality. 85 | :return: API Response containing metadata for the currently streamed track. 86 | 87 | Quality options: 88 | 1 - MP3 89 | 2 - 16bit FLAC 90 | 3 - 24bit FLAC 91 | """ 92 | data = { 93 | "bitrate": q, 94 | "dcd": self.dev_id, 95 | "stk": self.stm_token, 96 | "svc": "IV", 97 | "unm": self.usr_num, 98 | "uxtk": self.usr_token, 99 | "xgnm": id 100 | } 101 | r = self.make_call("stm", "player/j_StmInfo.json", data) 102 | logger_client.debug(r) 103 | if r['Result']['RetCode'] == "A00003": 104 | raise exceptions.NewDeviceError("Device ID has changed since last stream.") 105 | if r['Result']['RetCode'] != "0": 106 | raise exceptions.StreamMetadataError("Failed to get stream metadata.") 107 | 108 | return r['DataSet']['DATA'][0] 109 | -------------------------------------------------------------------------------- /modules/config.py.example: -------------------------------------------------------------------------------- 1 | credentials={ 2 | "username": "", 3 | "password": "", 4 | "device_id": "eb9d53a3c424f961", 5 | "user_agent": "genie/ANDROID/5.1.1/WIFI/SM-G930L/dreamqltecaneb9d53a3c424f961/500200714/40807", 6 | } 7 | 8 | prefs={ 9 | "download_directory": "downloads", 10 | "log_directory": "logs", 11 | "default_format": 3, 12 | "cover_name": "cover.jpg", 13 | "artist_folders": True, 14 | "ascii_art": True 15 | } -------------------------------------------------------------------------------- /modules/exceptions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger_exceptions = logging.getLogger("Exceptions") 4 | 5 | class AuthenticationError(Exception): 6 | def __init__(self, message): 7 | logger_exceptions.debug(message) 8 | super().__init__(message) 9 | 10 | class TrackRenameError(Exception): 11 | def __init__(self, message): 12 | logger_exceptions.debug(message) 13 | super().__init__(message) 14 | 15 | class StreamMetadataError(Exception): 16 | def __init__(self, message): 17 | logger_exceptions.debug(message) 18 | super().__init__(message) 19 | 20 | class AlbumMetadataError(Exception): 21 | def __init__(self, message): 22 | logger_exceptions.debug(message) 23 | super().__init__(message) 24 | 25 | class NewDeviceError(Exception): 26 | def __init__(self, message): 27 | logger_exceptions.debug(message) 28 | super().__init__(message) 29 | -------------------------------------------------------------------------------- /modules/logger.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from datetime import datetime 4 | from modules.utils import make_dir 5 | from modules.config import prefs 6 | 7 | def log_setup(): 8 | filename = '{:%H.%M.%S}.log'.format(datetime.now()) 9 | folder_name = os.path.join(prefs['log_directory'], '{:%Y-%m-%d}'.format(datetime.now())) 10 | make_dir(folder_name) 11 | log_path = os.path.join(folder_name, filename) 12 | logging.basicConfig(level=logging.DEBUG, 13 | handlers=[logging.FileHandler(log_path, 'w', 'utf-8')], 14 | format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s', 15 | datefmt='%Y-%m-%d %H:%M:%S') 16 | console = logging.StreamHandler() 17 | console.setLevel(logging.INFO) 18 | formatter = logging.Formatter('%(name)-12s: %(levelname)-8s %(message)s') 19 | console.setFormatter(formatter) 20 | logging.getLogger("").addHandler(console) 21 | logger_genie = logging.getLogger("Genie") 22 | # Suppress requests module if level < WARNING 23 | logging.getLogger("requests").setLevel(logging.WARNING) 24 | 25 | return logger_genie 26 | -------------------------------------------------------------------------------- /modules/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import platform 4 | import logging 5 | 6 | logger_utilities = logging.getLogger("Utilities") 7 | 8 | 9 | def print_title(): 10 | print(""" 11 | _______ __ ______ 12 | | __|.-----.-----.|__|.-----.| __ \.--.--. 13 | | | || -__| || || -__|| __/| | | 14 | |_______||_____|__|__||__||_____||___| |___ | 15 | |_____| 16 | 17 | """) 18 | 19 | 20 | def allowed_check(allowed): 21 | if allowed == "Y": 22 | return True 23 | print("Track is not allowed to be streamed.") 24 | 25 | 26 | def parse_specs(type, br): 27 | return { 28 | "MP3": "MP3 " + br, 29 | "FLA": "16-bit / 44.1 kHz FLAC", 30 | "F44": "24-bit / 44.1 kHz FLAC", 31 | "F48": "24-bit / 48 kHz FLAC", 32 | "F88": "24-bit / 88.2 kHz FLAC", 33 | "F96": "24-bit / 96 kHz FLAC", 34 | "F192": "24-bit / 192 kHz FLAC" 35 | }[type] 36 | 37 | 38 | def get_ext(type): 39 | if type == "MP3": 40 | return "mp3" 41 | else: 42 | return "flac" 43 | 44 | 45 | def make_dir(dir): 46 | if not os.path.isdir(dir): 47 | os.makedirs(dir) 48 | 49 | 50 | def exist_check(abs): 51 | """ 52 | :param abs: Absolute path 53 | :return: If path exists. 54 | """ 55 | if os.path.isfile(abs): 56 | logger_utilities.info("{} already exists locally.".format(os.path.basename(abs))) 57 | return True 58 | 59 | 60 | def _is_win(): 61 | if platform.system() == 'Windows': 62 | return True 63 | 64 | 65 | def sanitize(fn): 66 | """ 67 | :param fn: Filename 68 | :return: Sanitized string 69 | 70 | Removes invalid characters in the filename dependant on Operating System. 71 | """ 72 | if _is_win(): 73 | return re.sub(r'[\/:*?"><|]', '_', fn) 74 | else: 75 | return re.sub('/', '_', fn) 76 | 77 | 78 | def check_url(url): 79 | """ 80 | :param url: Genie url 81 | :return: Album ID 82 | """ 83 | expression = "https://www\.genie\.co\.kr/detail/albumInfo\?axnm=(\d{8})$" 84 | match = re.match(expression, url) 85 | if match: 86 | return match.group(1) 87 | logger_utilities.critical("Invalid URL: {}".format(url)) 88 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tqdm==4.45.0 2 | requests==2.20.0 3 | mutagen==1.42.0 4 | --------------------------------------------------------------------------------