├── .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 |
--------------------------------------------------------------------------------