├── .gitignore ├── LICENSE ├── README.en.md ├── README.md ├── funding.yml ├── rsack ├── __init__.py ├── bugs.py ├── clients │ ├── __init__.py │ ├── bugs.py │ ├── genie.py │ └── kkbox.py ├── exceptions.py ├── genie.py ├── kkbox.py ├── main.py ├── utils.py └── version.py ├── rsack_settings.ini.example └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Ransack specific 2 | settings.ini 3 | ransack_settings.ini 4 | rsack_settings.ini 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # Idea 137 | .idea/ 138 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | ![GitHub last commit](https://img.shields.io/github/last-commit/Slyyxp/rsack) ![GitHub repo size](https://img.shields.io/github/repo-size/Slyyxp/rsack) ![GitHub](https://img.shields.io/github/license/Slyyxp/rsack) ![PyPI - Downloads](https://img.shields.io/pypi/dm/rsack) ![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/Slyyxp/rsack) ![GitHub issues](https://img.shields.io/github/issues-raw/Slyyxp/rsack) 2 | 3 | # Installation 4 | ```bash 5 | pip install rsack 6 | ``` 7 | 8 | ## Alternatively.. 9 | ```bash 10 | git clone https://github.com/Slyyxp/rsack.git 11 | cd rsack 12 | python setup.py install 13 | ``` 14 | 15 | # Features 16 | ## Bugs 17 | - FLAC16, 320kbps 18 | - Timed lyrics 19 | - Artist batching 20 | - Extensive tagging 21 | - Concurrent downloads 22 | - Client utlizing undocumented mobile API. 23 | 24 | ## Genie 25 | - FLAC24, FLAC16, 320kbps 26 | - Artist batching 27 | - Timed lyrics 28 | - Extensive tagging 29 | - Concurrent downloads 30 | - Client utlizing undocumented mobile API. 31 | 32 | # rsack_settings.ini 33 | `rsack_settings.ini` can be located in your home folder. 34 | 35 | # Wiki 36 | [Command Usage](https://github.com/Slyyxp/rsack/wiki/Command-Usage) 37 | [Example Configuration](https://github.com/Slyyxp/rsack/wiki/Configuration) 38 | [Account Creation](https://github.com/Slyyxp/rsack/wiki/Account-Creation) 39 | 40 | # Retrieving API Data 41 | ```python 42 | from rsack.clients import bugs 43 | 44 | client = bugs.Client() # Initialize client object 45 | client.auth(username='', password='') # Authorize user 46 | 47 | artist = client.get_artist(id=80219706) # Make call for artist information using artist UID 48 | album = client.get_album(id=4071297) # Make call for album information using album UID 49 | track = client.get_track(id=6147328) # Make call for track information using track UID 50 | ``` 51 | ```python 52 | from rsack.clients import genie 53 | 54 | client = genie.Client() # Initialize client object 55 | client.auth(username="", password="") # Authorize user 56 | 57 | album = client.get_album(82525503) # Make call for album information using album UID 58 | artist = client.get_artist(80006273) # Make call for artist information using artist UID 59 | track = client.get_stream_meta(95970973) # Make call for stream information using track UID 60 | ``` 61 | # FAQ 62 | ### Why Are Downloads Slow? 63 | Servers for both Bugs and Genie are located in Korea, if you are outside of Asia downloads will likely be somewhat slow. 64 | 65 | ## Bugs 66 | ### Can I Download Music Videos? 67 | No you cannot, these files are not streamable. 68 | ### Does Bugs Have Hi-Res? 69 | Bugs does not offer any 24bit files at the time of writing this. 70 | 71 | ## Genie 72 | ### Which Streaming Pass Do I Need? 73 | KT offer a 24bit package, beyond that i'm not sure. 74 | https://product.kt.com/wDic/productDetail.do?ItemCode=1282 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub last commit](https://img.shields.io/github/last-commit/Slyyxp/rsack) ![GitHub repo size](https://img.shields.io/github/repo-size/Slyyxp/rsack) ![GitHub](https://img.shields.io/github/license/Slyyxp/rsack) ![PyPI - Downloads](https://img.shields.io/pypi/dm/rsack) ![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/Slyyxp/rsack) ![GitHub issues](https://img.shields.io/github/issues-raw/Slyyxp/rsack) 2 | 3 | [English](https://github.com/Slyyxp/rsack/blob/master/README.en.md) 4 | 5 | # 설치방법 6 | ```bash 7 | pip install rsack 8 | ``` 9 | 10 | ## 다른 설치방법 11 | ```bash 12 | git clone https://github.com/Slyyxp/rsack.git 13 | cd rsack 14 | python setup.py install 15 | ``` 16 | 17 | # 기능 소개 18 | ## 벅스 19 | - FLAC16, 320kbps 20 | - 실시간 가사 21 | - 특정 아티스트의 음원 일괄 다운로드 22 | - 상세한 태그 23 | - 동시 다중 다운로드 24 | - 문서화되지 않은 모바일 API를 활용하는 클라이언트 25 | 26 | ## 지니 27 | - FLAC24, FLAC16, 320kbps 28 | - 특정 아티스트의 음원 일괄 다운로드 29 | - 실시간 가사 30 | - 상세한 태그 31 | - 동시 다중 다운로드 32 | - 문서화되지 않은 모바일 API를 활용하는 클라이언트 33 | 34 | # rsack_settings.ini 35 | `rsack_settings.ini` 파일은 여러분이 지정한 폴더에 위치할 수 있습니다 36 | 37 | # 위키 38 | [명령 옵션 사용법](https://github.com/Slyyxp/rsack/wiki/Command-Usage) 39 | [설정 예제](https://github.com/Slyyxp/rsack/wiki/Configuration) 40 | [음원 사이트 계정 만드는 법](https://github.com/Slyyxp/rsack/wiki/Account-Creation) 41 | 42 | # API 데이터 가져오기 43 | ```python 44 | from rsack.clients import bugs 45 | 46 | client = bugs.Client() # 클라이언트 오브젝트 초기화 47 | client.auth(username='', password='') # 음원 사이트 계정 인증 48 | 49 | artist = client.get_artist(id=80219706) # 아티스트 UID를 사용해서 아티스트 정보를 가져옴 50 | album = client.get_album(id=4071297) # 앨범 UID를 사용해서 앨범 정보를 가져옴 51 | track = client.get_track(id=6147328) # 트랙 UID를 사용해서 트랙 정보를 가져옴 52 | ``` 53 | ```python 54 | from rsack.clients import genie 55 | 56 | client = genie.Client() # 클라이언트 오브젝트 초기화 57 | client.auth(username="", password="") # 음원 사이트 계정 인증 58 | 59 | album = client.get_album(82525503) # 앨범 UID를 사용해서 앨범 정보를 가져옴 60 | artist = client.get_artist(80006273) # 아티스트 UID를 사용해서 아티스트 정보를 가져옴 61 | track = client.get_stream_meta(95970973) # 트랙 UID를 사용해서 스트리밍 정보를 가져옴 62 | ``` 63 | # 자주 묻는 질문 64 | ### 왜 다운로드 속도가 느린가요? 65 | 벅스와 지니의 서버는 한국에 위치해있기 때문에 아시아 외의 지역이라면 느릴 수 있습니다 66 | 67 | ## 벅스 68 | ### 뮤직 비디오를 다운로드할 수 있나요? 69 | 스트리밍이 불가능한 파일이라 안됩니다 70 | ### 24비트 하이 레졸루션 음원도 다운로드할 수 있나요? 71 | 현재 벅스는 24비트 음원을 스트리밍해주지 않습니다 72 | 73 | ## 지니 74 | ### 어떤 이용권을 사용해야 하나요? 75 | KT 혜택에 보시면 24비트 음원 스트리밍이 가능한 이용권이 있습니다 76 | https://product.kt.com/wDic/productDetail.do?ItemCode=1282 77 | -------------------------------------------------------------------------------- /funding.yml: -------------------------------------------------------------------------------- 1 | github: slyyxp 2 | -------------------------------------------------------------------------------- /rsack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Slyyxp/rsack/0ce2628b89ba96cea04ca02fc095de0d9eabf59e/rsack/__init__.py -------------------------------------------------------------------------------- /rsack/bugs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from datetime import datetime 4 | from loguru import logger 5 | from mutagen.flac import FLAC, Picture 6 | import mutagen.id3 as id3 7 | from mutagen.id3 import ID3NoHeaderError 8 | from concurrent.futures import ThreadPoolExecutor 9 | 10 | from rsack.clients import bugs 11 | from rsack.utils import Settings, track_to_flac, insert_total_tracks, contribution_check, sanitize, _format_date 12 | 13 | 14 | class Download: 15 | def __init__(self, type: str, id: int): 16 | """Initialize and control flow of download process 17 | 18 | Args: 19 | type (str): String to declare type of download (album/artist/track). 20 | id (int): Unique ID. 21 | """ 22 | self.settings = Settings().Bugs() 23 | self.client = bugs.Client(proxy=self.settings['proxy']) 24 | self.conn_info = self.client.auth(email=self.settings['email'], password=self.settings['password']) 25 | logger.info(f"Threads: {self.settings['threads']}") 26 | if type == "artist": 27 | self._artist(id) 28 | elif type == "album": 29 | self._album(id) 30 | 31 | def _artist(self, id: int): 32 | """Handle artist downloads 33 | 34 | Args: 35 | id (int): Unique Artist ID 36 | 37 | Note: 38 | self.client.get_artist() returns an album list, but does not include all the necessary tagging info. 39 | This means it's not worth passing it on and an additional request has to be used. 40 | """ 41 | artist = self.client.get_artist(id) 42 | logger.info(f"{len(artist['list'][1]['artist_album']['list'])} releases found") 43 | for album in artist['list'][1]['artist_album']['list']: 44 | contribution = contribution_check(id, int(album['artist_id'])) 45 | if contribution: 46 | if self.settings['contributions']: 47 | self._album(album['album_id']) 48 | else: 49 | logger.debug("Skipping album contribution") 50 | else: 51 | self._album(album['album_id']) 52 | 53 | def _album(self, id: int): 54 | """Handle album downloads 55 | 56 | Args: 57 | id (int): Unique album id 58 | """ 59 | self.album = self.client.get_album(id)['list'][0]['album_info']['result'] 60 | logger.info(f"Album: {self.album['title']}") 61 | # Acquire disc total 62 | self.album['disc_total'] = self.album['tracks'][-1]['disc_id'] 63 | # Add track_total to meta. 64 | insert_total_tracks(self.album['tracks']) 65 | self._album_path() 66 | self._download_cover() 67 | # Begin downloading tracks 68 | with ThreadPoolExecutor(max_workers=int(self.settings['threads'])) as executor: 69 | executor.map(self._download, self.album['tracks']) 70 | 71 | @logger.catch 72 | def _template(self): 73 | keys = { 74 | "artist": self.album['artist_disp_nm'], 75 | "title": self.album['title'], 76 | "local_title": self.album['title_local'], 77 | "date": _format_date(self.album['release_ymd']), 78 | "local_date": _format_date(self.album['release_local_ymd']), 79 | "album_id": str(self.album['album_id']), 80 | "artist_id": str(self.album['artist_id']), 81 | "type": self.album['album_tp'], 82 | } 83 | template = self.settings['template'] 84 | for k in keys: 85 | template = template.replace(f"{{{k}}}", sanitize(keys[k])) 86 | return template 87 | 88 | @logger.catch 89 | def _album_path(self): 90 | """Creates necessary directories""" 91 | self.album_path = self.settings['path'] + self._template() 92 | try: 93 | if not os.path.exists(self.album_path): 94 | logger.debug(f"Creating {self.album_path}") 95 | os.makedirs(self.album_path) 96 | except OSError as exc: 97 | if exc.errno == 36: # Exceeded path limit 98 | if len(self.album['artist_disp_nm']) > len(self.album['title']): # Check whether artist name or album name is the issue 99 | self.album['artist_disp_nm'] = "Various Artists" # Change to V.A. as Bugs has likely compiled a huge list of artists 100 | logger.debug("Artist name forcibly changed to try and reduce length") 101 | else: # If title is the issue 102 | logger.debug("Album title forcibly changed to try and reduce length") 103 | self.album_path = self.settings['path'] + "EDIT ME" 104 | # Retry 105 | if not os.path.exists(self.album_path): 106 | logger.debug(f"Creating {self.album_path}") 107 | os.makedirs(self.album_path) 108 | 109 | # Create nested disc folders 110 | if self.album['disc_total'] > 1: 111 | self.discs = True 112 | for i in range(self.album['disc_total']): 113 | d = os.path.join(self.album_path, f"Disc {str(i + 1)}") 114 | if not os.path.exists(d): 115 | os.makedirs(d) 116 | else: 117 | self.discs = False 118 | 119 | @logger.catch 120 | def _download(self, track: dict): 121 | """Downloads track 122 | 123 | Args: 124 | track (dict): Contains track information from API response 125 | """ 126 | if track['is_flac_str_premium'] and not self.client.premium: 127 | logger.warning("Lossless is only available for Premium users, MP3 will be downloaded.") 128 | logger.info(f"Track: {track['track_title']}") 129 | if self.discs: 130 | file_path = os.path.join(self.album_path, f"Disc {str(track['disc_id'])}", f"{track['track_no']:02d}. {sanitize(track['track_title'])}.temp") 131 | else: 132 | file_path = os.path.join(self.album_path, f"{track['track_no']:02d}. {sanitize(track['track_title'])}.temp") 133 | if self._exist_check(file_path): 134 | logger.debug(f"{track['track_title']} already exists") 135 | else: 136 | # Create params required to request the track 137 | params = { 138 | "ConnectionInfo": self.conn_info, 139 | "api_key": self.client.api_key, 140 | "overwrite_session": "Y", 141 | "track_id": track['track_id'] 142 | } 143 | # Create headers for byte position. 144 | headers = { 145 | "Range": 'bytes=%d-' % self._return_bytes(file_path), 146 | } 147 | r = requests.get(f"http://api.bugs.co.kr/3/tracks/{track['track_id']}/listen/android/flac", headers=headers, params=params, stream=True) 148 | if r.url.split("?")[0].endswith(".mp3"): # If response redirects to MP3 file set quality to .mp3 149 | quality = '.mp3' 150 | elif r.url.split("?")[0].endswith(".m4a"): 151 | quality = '.m4a' 152 | else: # Otherwise .flac 153 | quality = '.flac' 154 | if quality != '.m4a': 155 | if r.status_code == 404: 156 | logger.info(f"{track['track_title']} unavailable") 157 | else: 158 | with open(file_path, 'ab') as f: 159 | for chunk in r.iter_content(32 * 1024): 160 | if chunk: 161 | f.write(chunk) 162 | c_path = file_path.replace(".temp", quality) 163 | os.rename(file_path, c_path) 164 | self._tag(track, c_path) 165 | else: 166 | logger.info(f"{track['track_title']} is unavailable.") 167 | 168 | @staticmethod 169 | def _return_bytes(file_path: str) -> int: 170 | """Returns number of bytes in file 171 | 172 | Args: 173 | file_path (str): File path 174 | 175 | Returns: 176 | int: Returns size in bytes 177 | """ 178 | 179 | if os.path.exists(file_path): 180 | logger.debug(f"Existing .temp file {os.path.basename(file_path)} has resumed.") 181 | return os.path.getsize(file_path) 182 | else: 183 | return 0 184 | 185 | @staticmethod 186 | def _exist_check(file_path: str) -> bool: 187 | """Check if file exists for both possible cases 188 | 189 | Args: 190 | file_path (str): .temp file path 191 | 192 | Returns: 193 | bool: True if exists else false 194 | """ 195 | if os.path.exists(file_path.replace('.temp', '.mp3')): 196 | return True 197 | if os.path.exists(file_path.replace('.temp', '.flac')): 198 | return True 199 | else: 200 | return False 201 | 202 | def _download_cover(self): 203 | """Downloads cover artwork""" 204 | self.cover_path = os.path.join(self.album_path, 'cover.jpg') 205 | if os.path.exists(self.cover_path): 206 | logger.info('Cover already exists') 207 | else: 208 | r = requests.get(self.album['img_urls'][self.settings['cover_size']]) 209 | r.raise_for_status 210 | with open(self.cover_path, 'wb') as f: 211 | f.write(r.content) 212 | logger.info('Cover artwork downloaded.') 213 | 214 | @logger.catch 215 | def _tag(self, track: dict, file_path: str): 216 | """Append ID3/FLAC tags 217 | 218 | Args: 219 | track (dict): API response containing track information 220 | file_path (str): File being tagged 221 | """ 222 | lyrics = self._get_lyrics(track['track_id'], track['lyrics_tp']) 223 | tags = track_to_flac(track, self.album, lyrics) 224 | if str(file_path).endswith('.flac'): 225 | f_file = FLAC(file_path) 226 | # Add cover artwork to flac file 227 | f_file.clear_pictures() # Delete existing cover artwork 228 | if self.cover_path: 229 | f_image = Picture() 230 | f_image.type = 3 231 | f_image.desc = 'Front Cover' 232 | with open(self.cover_path, 'rb') as f: 233 | f_image.data = f.read() 234 | f_file.add_picture(f_image) 235 | logger.debug(f"Writing tags to {file_path}") 236 | for k, v in tags.items(): 237 | f_file[k] = str(v) 238 | f_file.save() 239 | if str(file_path).endswith('.mp3'): 240 | # Legend contains all ID3 tags for each FLAC header. 241 | legend = { 242 | "ALBUM": id3.TALB, 243 | "ALBUMARTIST": id3.TPE2, 244 | "ARTIST": id3.TPE1, 245 | "COMMENT": id3.COMM, 246 | "COMPOSER": id3.TCOM, 247 | "COPYRIGHT": id3.TCOP, 248 | "DATE": id3.TDRC, 249 | "GENRE": id3.TCON, 250 | "ISRC": id3.TSRC, 251 | "LABEL": id3.TPUB, 252 | "PERFORMER": id3.TOPE, 253 | "TITLE": id3.TIT2, 254 | "LYRICS": id3.USLT 255 | } 256 | try: 257 | m_file = id3.ID3(file_path) 258 | except ID3NoHeaderError: 259 | m_file = id3.ID3() 260 | logger.debug(f"Writing tags to {file_path}") 261 | # Apply tags using the legend 262 | for k, v in tags.items(): 263 | try: 264 | id3tag = legend[k] 265 | m_file[id3tag.__name__] = id3tag(encoding=3, text=v) 266 | except KeyError: 267 | continue 268 | # Track and disc numbers 269 | m_file.add(id3.TRCK(encoding=3, text=f"{track['track_no']}/{track['track_total']}")) 270 | m_file.add(id3.TPOS(encoding=3, text=f"{track['disc_id']}/{self.album['disc_total']}")) 271 | # Apply cover artwork 272 | m_file.delall("APIC") # Delete existing cover artwork 273 | if self.cover_path: 274 | with open(self.cover_path, 'rb') as cov_obj: 275 | m_file.add(id3.APIC(3, 'image/jpg', 3, '', cov_obj.read())) 276 | m_file.save(file_path, 'v2_version=3') 277 | 278 | def _get_lyrics(self, track_id: int, lyrics_tp: str) -> str: 279 | """Retrieves and formats track lyrics 280 | 281 | Args: 282 | track_id (int): Unique track ID. 283 | lyrics_tp (str): 'T'/'N': Timed/Normal lyrics from settings. 284 | 285 | Returns: 286 | str: Formatted lyrics 287 | """ 288 | # If user prefers timed then retrieve timed lyrics 289 | if lyrics_tp and self.settings['timed_lyrics']: 290 | # Retrieve timed lyrics 291 | r = requests.get(f"https://music.bugs.co.kr/player/lyrics/T/{track_id}") 292 | # Format timed lyrics 293 | lyrics = r.json()['lyrics'].replace("#", "\n") 294 | line_split = (line.split('|') for line in lyrics.splitlines()) 295 | lyrics = ("\n".join( 296 | f'[{datetime.fromtimestamp(round(float(a), 2)).strftime("%M:%S.%f")[0:-4]}]{b}' for a, b in line_split)) 297 | # If user prefers untimed or timed unavailable then use untimed 298 | elif not lyrics_tp or not self.settings['timed_lyrics']: 299 | r = requests.get(f'https://music.bugs.co.kr/player/lyrics/N/{track_id}') 300 | lyrics = r.json()['lyrics'] 301 | # If unavailable leave as empty string 302 | if lyrics_tp is None: 303 | lyrics = "" 304 | else: 305 | lyrics = "" 306 | return lyrics -------------------------------------------------------------------------------- /rsack/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Slyyxp/rsack/0ce2628b89ba96cea04ca02fc095de0d9eabf59e/rsack/clients/__init__.py -------------------------------------------------------------------------------- /rsack/clients/bugs.py: -------------------------------------------------------------------------------- 1 | # Standard 2 | import requests 3 | from loguru import logger 4 | 5 | from rsack.exceptions import InvokeMapError 6 | 7 | class Client: 8 | def __init__(self, proxy=None): 9 | self.session = requests.Session() 10 | self.api_key = "b2de0fbe3380408bace96a5d1a76f800" 11 | self.session.headers.update({ 12 | "User-Agent": "Mobile|Bugs|4.11.30|Android|5.1.1|SM-G965N|samsung|market", 13 | "Host": "api.bugs.co.kr", 14 | }) 15 | 16 | retry_strategy = requests.packages.urllib3.util.retry.Retry( 17 | total=5, 18 | backoff_factor=5, 19 | status_forcelist=[404, 429, 500, 502, 503, 504], 20 | method_whitelist=["HEAD", "GET", "OPTIONS"] 21 | ) 22 | 23 | adapter = requests.adapters.HTTPAdapter(max_retries=retry_strategy) 24 | self.session.mount('https://', adapter) 25 | self.session.mount('http://', adapter) 26 | 27 | # Assign proxy if applicable 28 | if proxy: 29 | logger.debug(f"{proxy.split('@')[1]}") 30 | proxies = {"https": proxy} 31 | self.session.proxies.update(proxies) 32 | 33 | def auth(self, email: str, password: str): 34 | """Authenticates session""" 35 | data = { 36 | "device_id": "gwAHWlkOYX_T8Sl43N78GiaD6Sg_", # Hardcode device id 37 | "passwd": password, 38 | "userid": email 39 | } 40 | r = self.make_call("secure", "mbugs/3/login?", data=data) 41 | if r['ret_code'] == 300: 42 | logger.critical("Authentication Error, Invalid Credentials") 43 | else: 44 | logger.info(f"Login Successful : {r['result']['right']['product']['name']}") 45 | self.nickname = r['result']['extra_data']['nickname'] 46 | self.connection_info = r['result']['coninfo'] 47 | self.premium = r['result']['right']['stream']['is_flac_premium'] 48 | return self.connection_info 49 | 50 | def make_call(self, sub: str, epoint: str, data: dict = None, json: dict = None, params: dict = None): 51 | """Makes an API call 52 | 53 | Args: 54 | sub (str): Subdomain 55 | epoint (str): Endpoint 56 | data (dict, optional): POST data. Defaults to None. 57 | json (dict, optional): POST json. Defaults to None. 58 | params (dict, optional): POST parameters. Defaults to None. 59 | 60 | Returns: 61 | dict: Response 62 | """ 63 | r = self.session.post("https://{}.bugs.co.kr/{}api_key={}".format(sub, epoint, self.api_key), json=json, data=data, params=params) 64 | return r.json() 65 | 66 | def get_artist(self, id: int) -> dict: 67 | """Retrieves artist information 68 | 69 | Args: 70 | id (int): Artists unique id 71 | 72 | Raises: 73 | InvokeMapError: Failed to invoke map 74 | 75 | Returns: 76 | dict: API response 77 | """ 78 | json = [{ 79 | "id": "artist_info", 80 | "args": {"artistId": id} 81 | }, 82 | { 83 | "id": "artist_album", 84 | "args": {"artistId": id, 85 | "albumType": "main", 86 | "tracksYn": "Y", 87 | "page": 1, 88 | "size": 500 89 | }}] 90 | r = self.make_call("api", "3/home/invokeMap?", json=json) 91 | if r['ret_code'] != 0: 92 | raise InvokeMapError(r) 93 | return r 94 | 95 | def get_album(self, id: int) -> dict: 96 | """Retrieves album information 97 | 98 | Args: 99 | id (int): Album unique id. 100 | 101 | Raises: 102 | InvokeMapError: Failed to invoke map. 103 | 104 | Returns: 105 | dict: API response. 106 | """ 107 | json = [{ 108 | "id": "album_info", 109 | "args": {"albumId": id} 110 | }, 111 | { 112 | "id": "artist_role_info", 113 | "args": {"contentsId": id, 114 | "type": "ALBUM" 115 | }}] 116 | r = self.make_call("api", "3/home/invokeMap?", json=json) 117 | if r['ret_code'] != 0: 118 | raise InvokeMapError(r) 119 | return r 120 | 121 | def get_track(self, id: int) -> dict: 122 | """Retrieves track information 123 | 124 | Args: 125 | id (int): Track unique id. 126 | 127 | Raises: 128 | InvokeMapError: Failed to invoke map. 129 | 130 | Returns: 131 | dict: API reponse 132 | """ 133 | json=[{"id":"track_detail", 134 | "args":{"trackId":id} 135 | }] 136 | r = self.make_call("api", "3/home/invokeMap?", json=json) 137 | if r['ret_code'] != 0: 138 | raise InvokeMapError(r) 139 | return r -------------------------------------------------------------------------------- /rsack/clients/genie.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | from loguru import logger 5 | from rsack.exceptions import DeviceIDError 6 | 7 | class Client: 8 | def __init__(self, proxy=None): 9 | self.session = requests.Session() 10 | self.dev_id = "eb9d53a3c424f961" 11 | 12 | self.session.headers.update({ 13 | "User-Agent": "genie/ANDROID/5.1.1/WIFI/SM-G930L/dreamqltecaneb9d53a3c424f961/500200714/40807", 14 | "Referer": "app.genie.co.kr" 15 | }) 16 | 17 | self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=3)) 18 | 19 | retry_strategy = requests.packages.urllib3.util.retry.Retry( 20 | total=5, 21 | backoff_factor=5, 22 | status_forcelist=[404, 429, 500, 502, 503, 504], 23 | method_whitelist=["HEAD", "GET", "OPTIONS"] 24 | ) 25 | 26 | adapter = requests.adapters.HTTPAdapter(max_retries=retry_strategy) 27 | self.session.mount('https://', adapter) 28 | self.session.mount('http://', adapter) 29 | 30 | # Assign proxy if applicable 31 | if proxy: 32 | logger.debug(f"{proxy.split('@')[1]}") 33 | proxies = {"https": proxy} 34 | self.session.proxies.update(proxies) 35 | 36 | def make_call(self, sub: str, epoint: str, data: dict) -> dict: 37 | """Makes API call to specified endpoint 38 | 39 | Args: 40 | sub (str): Subdomain 41 | epoint (str): Endpoint 42 | data (dict): POST data 43 | 44 | Endpoints used: 45 | player/j_StmInfo.json: Returns track information 46 | member/j_Member_Login.json: Authentication. 47 | song/j_AlbumSongList.json: Returns album information 48 | 49 | Returns: 50 | dict: JSON Response 51 | """ 52 | try: 53 | r = self.session.post("https://{}.genie.co.kr/{}".format(sub, epoint), data=data) 54 | except requests.exceptions.ConnectionError: 55 | logger.debug("Remote end closed connection, retrying.") 56 | r = self.session.post("https://{}.genie.co.kr/{}".format(sub, epoint), data=data) 57 | return r.json() 58 | 59 | def auth(self, username: str, password: str): 60 | """ 61 | Authenticate session 62 | """ 63 | data = { 64 | "uxd": username, 65 | "uxx": password 66 | } 67 | r = self.make_call("app", "member/j_Member_Login.json", data) 68 | if r['Result']['RetCode'] != "0": 69 | logger.critical(f"Authentication Failed: {r['RetMsg']}") 70 | exit() 71 | else: 72 | logger.info("Login Successful.") 73 | self.usr_num = r['DATA0']['MemUno'] 74 | self.usr_token = r['DATA0']['MemToken'] 75 | self.stm_token = r['DATA0']['STM_TOKEN'] 76 | 77 | def get_album(self, id: int) -> dict: 78 | """Retrieve album information""" 79 | data = { 80 | "axnm": id, 81 | "dcd": self.dev_id, 82 | "mts": "Y", 83 | "stk": self.stm_token, 84 | "svc": "IV", 85 | "tct": "Android", 86 | "unm": self.usr_num, 87 | "uxtk": self.usr_token 88 | } 89 | r = self.make_call("app", "song/j_AlbumSongList.json", data) 90 | if r['Result']['RetCode'] != "0": 91 | logger.critical("Failed to retrieve metadata") 92 | return r 93 | 94 | def get_artist_albums(self, id: int) -> dict: 95 | """Retrieve artists album information""" 96 | data = { 97 | "uxtk": self.usr_token, 98 | "sign": "Y", 99 | "tct": "Android", 100 | "svc": "IV", 101 | "stk": self.stm_token, 102 | "dcd": self.dev_id, 103 | "xxnm": id, 104 | "unm": self.usr_num, 105 | "mts": "Y", 106 | "pgsize": 500 107 | } 108 | r = self.make_call("app", "song/j_ArtistAlbumList.json", data) 109 | if r['Result']['RetCode'] != "0": 110 | logger.critical("Failed to retrieve metadata") 111 | return r 112 | 113 | def get_artist(self, id: int) -> dict: 114 | """Retrieves artist information""" 115 | data = { 116 | "uxtk": self.usr_token, 117 | "sign": "Y", 118 | "tct": "Android", 119 | "svc": "IV", 120 | "stk": self.stm_token, 121 | "dcd": self.dev_id, 122 | "xxnm": id, 123 | "unm": self.usr_num, 124 | "mts": "Y" 125 | } 126 | r = self.make_call("info", "info/artist", data) 127 | if r['result']['ret_code'] != "0": 128 | logger.critical("Failed to retrieve metadata") 129 | return r 130 | 131 | def get_stream_meta(self, id: int) -> dict: 132 | """Retrieves information on a streamable track 133 | 134 | Args: 135 | id (int): Unique ID of track 136 | 137 | Raises: 138 | DeviceIDError: Raises when RetCode "A00003" is returned. 139 | Caused by sudden change in DeviceID. 140 | 141 | Returns: 142 | dict: JSON Response 143 | """ 144 | data = { 145 | "bitrate": "24bit", 146 | "sign": "Y", 147 | "mts": "Y", 148 | "dcd": self.dev_id, 149 | "stk": self.stm_token, 150 | "itn": "Y", 151 | "svc": "IV", 152 | "unm": self.usr_num, 153 | "uxtk": self.usr_token, 154 | "xgnm": id, 155 | "apvn": 40807 156 | } 157 | r = self.make_call("stm", "player/j_StmInfo.json", data) 158 | if r['Result']['RetCode'] == "A00003": 159 | raise DeviceIDError("Device ID has been changed since last stream.") 160 | if r['Result']['RetCode'] != "0": 161 | logger.critical("Failed to retrieve metadata") 162 | if r['Result']['RetCode'] == "S00001": 163 | logger.debug("This content is currently unavailable for service") 164 | return False 165 | return r['DataSet']['DATA'][0] 166 | 167 | def get_timed_lyrics(self, id: str) -> dict: 168 | """Retrieve the timed lyrics for a track""" 169 | r = self.session.get(f"https://dn.genie.co.kr/app/purchase/get_msl.asp?songid={id}&callback=GenieCallBack") 170 | if r.content.decode('utf-8') == 'NOT FOUND LYRICS': 171 | return None 172 | # Remove unwanted characters 173 | r = r.content.decode('utf-8')[14:-2] 174 | return json.loads(r) -------------------------------------------------------------------------------- /rsack/clients/kkbox.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | from loguru import logger 5 | from time import time, sleep 6 | from random import randrange 7 | from Cryptodome.Cipher import ARC4 8 | from Cryptodome.Hash import MD5 9 | from bs4 import BeautifulSoup 10 | 11 | class KkboxAPI: 12 | """KKBox Client 13 | Source: https://github.com/uhwot/orpheusdl-kkbox/blob/1e217c41af599a8ff9a821d8e3ac13535a03c6de/kkapi.py 14 | """ 15 | def __init__(self, kc1_key="7f1a68f00b747f4ac1469c72e7ef492c", kkid=None, proxy=None): 16 | self.kc1_key = kc1_key.encode('ascii') 17 | self.session = requests.Session() 18 | self.session.headers.update({ 19 | 'user-agent': 'okhttp/3.14.9' 20 | }) 21 | 22 | self.kkid = kkid or '%032X' % randrange(16**32) 23 | 24 | self.params = { 25 | 'enc': 'u', 26 | 'ver': '06090076', 27 | 'os': 'android', 28 | 'osver': '11', 29 | 'lang': 'en', 30 | 'ui_lang': 'en', 31 | 'dist': '0021', 32 | 'dist2': '0021', 33 | 'resolution': '411x683', 34 | 'of': 'j', 35 | 'oenc': 'kc1', 36 | } 37 | 38 | retry_strategy = requests.packages.urllib3.util.retry.Retry( 39 | total=5, 40 | backoff_factor=5, 41 | status_forcelist=[404, 429, 500, 502, 503, 504], 42 | method_whitelist=["HEAD", "GET", "OPTIONS"] 43 | ) 44 | 45 | adapter = requests.adapters.HTTPAdapter(max_retries=retry_strategy) 46 | self.session.mount('https://', adapter) 47 | self.session.mount('http://', adapter) 48 | 49 | # Assign proxy if applicable 50 | if proxy: 51 | logger.debug(f"{proxy.split('@')[1]}") 52 | proxies = {"https": proxy} 53 | self.session.proxies.update(proxies) 54 | 55 | def kc1_decrypt(self, data): 56 | cipher = ARC4.new(self.kc1_key) 57 | return cipher.decrypt(data).decode('utf-8') 58 | 59 | def api_call(self, host, path, params={}, payload=None): 60 | if host == 'ticket': 61 | payload = json.dumps(payload) 62 | 63 | params.update(self.params) 64 | params.update({'timestamp': int(time())}) 65 | 66 | url = f'https://api-{host}.kkbox.com.tw/{path}' 67 | if not payload: 68 | r = self.session.get(url, params=params) 69 | else: 70 | r = self.session.post(url, params=params, data=payload) 71 | 72 | resp = json.loads(self.kc1_decrypt(r.content)) if r.content else None 73 | return resp 74 | 75 | def login(self, email, password, region_bypass=False): 76 | md5 = MD5.new() 77 | md5.update(password.encode('utf-8')) 78 | pswd = md5.hexdigest() 79 | 80 | host = 'login' if not region_bypass else 'login-utapass' 81 | 82 | resp = self.api_call(host, 'login.php', payload={ 83 | 'uid': email, 84 | 'passwd': pswd, 85 | 'kkid': self.kkid, 86 | 'registration_id': '', 87 | }) 88 | 89 | if not resp and region_bypass: 90 | logger.critical('Account expired') 91 | 92 | if resp['status'] not in (2, 3, -4): 93 | logger.critical("Credentials invalid") 94 | 95 | if resp['status'] == -4 and not region_bypass: # Region locked 96 | return self.login(email, password, region_bypass=True) 97 | 98 | self.region_bypass = region_bypass 99 | 100 | self.lang = resp['accept_lang'] 101 | self.apply_session(resp) 102 | 103 | def renew_session(self): 104 | host = 'login' if not self.region_bypass else 'login-utapass' 105 | resp = self.api_call(host, 'check.php') 106 | if resp['status'] not in (2, 3, -4): 107 | logger.critical('Session renewal failed') 108 | self.apply_session(resp) 109 | 110 | def apply_session(self, resp): 111 | self.sid = resp['sid'] 112 | self.params['sid'] = self.sid 113 | 114 | self.lic_content_key = resp['lic_content_key'].encode('ascii') 115 | 116 | self.available_qualities = ['128k', '192k', '320k'] 117 | if resp['high_quality']: 118 | self.available_qualities.append('hifi') 119 | self.available_qualities.append('hires') 120 | 121 | def get_songs(self, ids): 122 | resp = self.api_call('ds', 'v2/song', payload={ 123 | 'ids': ','.join(ids), 124 | 'fields': 'artist_role,song_idx,album_photo_info,song_is_explicit,song_more_url,album_more_url,artist_more_url,genre_name,is_lyrics,audio_quality' 125 | }) 126 | if resp['status']['type'] != 'OK': 127 | logger.critical('Track not found') 128 | return resp['data']['songs'] 129 | 130 | def get_song_lyrics(self, id): 131 | logger.debug(f"Retrieving lyrics: {id}") 132 | return self.api_call('ds', f'v1/song/{id}/lyrics') 133 | 134 | def get_album(self, id): 135 | resp = self.api_call('ds', f'v1/album/{id}') 136 | if resp['status']['type'] != 'OK': 137 | logger.critical('Album not found') 138 | return resp['data'] 139 | 140 | def get_album_more(self, raw_id): 141 | return self.api_call('ds', 'album_more.php', params={ 142 | 'album': raw_id 143 | }) 144 | 145 | def get_artist(self, id): 146 | resp = self.api_call('ds', f'v3/artist/{id}') 147 | if resp['status']['type'] != 'OK': 148 | logger.critical('Artist not found') 149 | return resp['data'] 150 | 151 | def get_artist_albums(self, raw_id, limit, offset): 152 | resp = self.api_call('ds', f'v2/artist/{raw_id}/album', params={ 153 | 'limit': limit, 154 | 'offset': offset, 155 | }) 156 | if resp['status']['type'] != 'OK': 157 | logger.critical('Artist not found') 158 | return resp['data']['album'] 159 | 160 | def get_playlists(self, ids): 161 | resp = self.api_call('ds', f'v1/playlists', params={ 162 | 'playlist_ids': ','.join(ids) 163 | }) 164 | if resp['status']['type'] != 'OK': 165 | logger.critical('Playlist not found') 166 | return resp['data']['playlists'] 167 | 168 | def search(self, query, types, limit): 169 | return self.api_call('ds', 'search_music.php', params={ 170 | 'sf': ','.join(types), 171 | 'limit': limit, 172 | 'query': query, 173 | 'search_ranking': 'sc-A', 174 | }) 175 | 176 | def get_ticket(self, song_id, play_mode = None): 177 | resp = self.api_call('ticket', 'v1/ticket', payload={ 178 | 'sid': self.sid, 179 | 'song_id': song_id, 180 | 'ver': '06090076', 181 | 'os': 'android', 182 | 'osver': '11', 183 | 'kkid': self.kkid, 184 | 'dist': '0021', 185 | 'dist2': '0021', 186 | 'timestamp': int(time()), 187 | 'play_mode': play_mode, 188 | }) 189 | 190 | if resp['status'] != 1: 191 | if resp['status'] == -1: 192 | self.renew_session() 193 | return self.get_ticket(song_id, play_mode) 194 | elif resp['status'] == -4: 195 | self.auth_device() 196 | return self.get_ticket(song_id, play_mode) 197 | elif resp['status'] == 2: 198 | # tbh i'm not sure if this is some rate-limiting thing 199 | # or if it's a bug on their slow-as-hell servers 200 | sleep(0.5) 201 | return self.get_ticket(song_id, play_mode) 202 | logger.critical("Couldn't get track URLs") 203 | 204 | return resp['uris'] 205 | 206 | def auth_device(self): 207 | resp = self.api_call('ds', 'active_sid.php', payload={ 208 | 'ui_lang': 'en', 209 | 'of': 'j', 210 | 'os': 'android', 211 | 'enc': 'u', 212 | 'sid': self.sid, 213 | 'ver': '06090076', 214 | 'kkid': self.kkid, 215 | 'lang': 'en', 216 | 'oenc': 'kc1', 217 | 'osver': '11', 218 | }) 219 | if resp['status'] != 1: 220 | logger.critical("Couldn't auth device") 221 | 222 | def kkdrm_dl(self, url, path): 223 | while True: 224 | try: 225 | headers = { 226 | "Range": 'bytes=%d-' % bytes + 1024, 227 | } 228 | except: 229 | headers={'range': 'bytes=1024-'} # skip first 1024 bytes of track file 230 | 231 | resp = self.session.get(url, stream=True, headers=headers) 232 | resp.raise_for_status() 233 | 234 | size = int(resp.headers['content-length']) 235 | 236 | # drop 512 bytes of keystream 237 | rc4 = ARC4.new(self.lic_content_key, drop=512) 238 | 239 | with open(path, 'wb') as f: 240 | for chunk in resp.iter_content(chunk_size=4096): 241 | f.write(rc4.decrypt(chunk)) 242 | 243 | bytes = self._return_bytes(path) 244 | if bytes < size: 245 | logger.warning(f" Missing {bytes - size} bytes. Continuing download.") 246 | continue 247 | else: 248 | break 249 | 250 | 251 | @staticmethod 252 | def _return_bytes(file_path: str) -> int: 253 | """Returns number of bytes in file 254 | 255 | Args: 256 | file_path (str): File path 257 | 258 | Returns: 259 | int: Returns size in bytes 260 | """ 261 | 262 | if os.path.exists(file_path): 263 | return os.path.getsize(file_path) 264 | else: 265 | return 0 -------------------------------------------------------------------------------- /rsack/exceptions.py: -------------------------------------------------------------------------------- 1 | from requests.models import Response 2 | 3 | class DeviceIDError(Exception): 4 | def __init__(self, message: str): 5 | self.message = message 6 | 7 | def __str__(self) -> str: 8 | return self.message 9 | 10 | class InvokeMapError(Exception): 11 | def __init__(self, response: Response): 12 | self.message = response['ret_detail_msg'] 13 | 14 | def __str__(self) -> str: 15 | return self.message 16 | 17 | class InvalidURL(Exception): 18 | def __init__(self, url: str): 19 | self.url = url 20 | 21 | def __str__(self) -> str: 22 | return f"{self.url} is invalid." -------------------------------------------------------------------------------- /rsack/genie.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mutagen.id3 as id3 3 | from loguru import logger 4 | from urllib.parse import unquote 5 | from mutagen.flac import FLAC, Picture 6 | from concurrent.futures import ThreadPoolExecutor 7 | from requests.models import Response 8 | 9 | from rsack.clients import genie 10 | from rsack.utils import Settings, contribution_check, format_genie_lyrics, get_ext, sanitize, _format_date 11 | 12 | 13 | class Download: 14 | def __init__(self, type: str, id: int): 15 | """Initialize and control the flow of the download""" 16 | self.settings = Settings().Genie() 17 | self.client = genie.Client(proxy=self.settings['proxy']) 18 | self.client.auth(username=self.settings['username'], password=self.settings['password']) 19 | logger.info(f"Threads: {self.settings['threads']}") 20 | if type == "artist": 21 | self._artist(id) 22 | elif type == "album": 23 | self._album(id) 24 | 25 | def _artist(self, id: int): 26 | """Iterate albums in artist""" 27 | meta = self.client.get_artist_albums(id) 28 | for album in meta['DataSet']['DATA']: 29 | if contribution_check(id, int(album['ARTIST_ID'])): 30 | if self.settings['contributions'] == "Y": 31 | self._album(album['ALBUM_ID']) 32 | else: 33 | logger.debug("Skipping contribution") 34 | else: 35 | self._album(album['ALBUM_ID']) 36 | 37 | @logger.catch 38 | def _template(self): 39 | keys = { 40 | "artist": unquote(self.album['ARTIST_NAME']), 41 | "title": unquote(self.album['ALBUM_NAME']), 42 | "date": self.album['ALBUM_RELEASE_DT'], 43 | "album_id": str(self.album['ALBUM_ID']), 44 | "artist_id": str(self.album['ARTIST_ID']), 45 | "type": self.album['ALBUM_TYPE'], 46 | "label_name": self.album['ALBUM_PLANNER'], 47 | } 48 | template = self.settings['template'] 49 | for k in keys: 50 | template = template.replace(f"{{{k}}}", sanitize(keys[k])) 51 | return template 52 | 53 | @logger.catch 54 | def _album(self, id: int): 55 | """Iterate tracks in album""" 56 | self.meta = self.client.get_album(id) 57 | self.album = self.meta['DATA0']['DATA'][0] 58 | self._template() 59 | logger.info(f"Album: {unquote(self.album['ALBUM_NAME'])}") 60 | self.album_path = self.settings['path'] + self._template() 61 | 62 | try: 63 | if not os.path.isdir(self.album_path): 64 | logger.debug(f"Creating: {self.album_path}") 65 | os.makedirs(self.album_path) 66 | except OSError as exc: 67 | if exc.errno == 36: # Exceeded path limit 68 | self.album_path = os.path.join(self.settings['path'], "EDIT ME") 69 | if not os.path.isdir(self.album_path): # Retry 70 | logger.debug(f"Creating: {self.album_path}") 71 | os.makedirs(self.album_path) 72 | 73 | # Create disc directories 74 | self.disc_total = int(self.meta['DATA1']['DATA'][len(self.meta['DATA1']['DATA']) - 1]['ALBUM_CD_NO']) 75 | if self.disc_total > 1: 76 | for i in range(0, self.disc_total): 77 | d = os.path.join(self.album_path, f"Disc {i + 1}") 78 | if not os.path.isdir(d): 79 | os.makedirs(d) 80 | 81 | cover_url = unquote(self.album['ALBUM_IMG_PATH_600']) 82 | if cover_url == "": 83 | cover_url = unquote(self.album['ALBUM_IMG_PATH']) 84 | self._download_cover(cover_url) 85 | 86 | # Initialize empty lists 87 | track_ids = [] 88 | track_numbers = [] 89 | disc_numbers = [] 90 | track_artist = [] 91 | # Append required information to their relevant lists 92 | for track in self.meta['DATA1']['DATA']: 93 | track_ids.append(int(track['SONG_ID'])) 94 | track_numbers.append(f"{track['ALBUM_TRACK_NO']}") 95 | disc_numbers.append(track['ALBUM_CD_NO']) 96 | track_artist.append(track['ARTIST_NAME']) 97 | 98 | # Create ThreadPoolExecutor to handle multiple downloads at once 99 | with ThreadPoolExecutor(max_workers=int(self.settings['threads'])) as executor: 100 | executor.map(self._track, track_ids, track_numbers, 101 | disc_numbers, track_artist) 102 | 103 | @logger.catch 104 | def _track(self, id: int, track_number: str, disc_number: str, track_artist: str): 105 | """Handles the download of a track 106 | 107 | Args: 108 | id (int): Unique ID of the track 109 | track_number (str): String representation of the track number 110 | disc_number (str): String representation of the disc number 111 | track_artist (str): Name of the artist connected to the track 112 | """ 113 | meta = self.client.get_stream_meta(id) 114 | if meta: # Meta can return False if unavailable for stream 115 | logger.info(f"Track: {unquote(meta['SONG_NAME'])}") 116 | ext = get_ext(meta['FILE_EXT']) 117 | if self.disc_total > 1: 118 | file_path = os.path.join(self.album_path, f"Disc {disc_number}", f"{int(track_number):02d}. {sanitize(unquote(meta['SONG_NAME']))}{ext}") 119 | else: 120 | file_path = os.path.join(self.album_path, f"{int(track_number):02d}. {sanitize(unquote(meta['SONG_NAME']))}{ext}") 121 | if os.path.exists(file_path): 122 | logger.debug(f"{file_path} already exists.") 123 | else: 124 | r = self.client.session.get(unquote(meta['STREAMING_MP3_URL'])) 125 | r.raise_for_status() 126 | lyrics = self.client.get_timed_lyrics(id) 127 | try: 128 | self._write_track(file_path, r) 129 | except OSError: 130 | # OSError assumes excessive file length, rename file and continue writing 131 | file_path = os.path.join(self.album_path, f"{track_number}.{ext}") 132 | logger.debug(f"{track_number} has been renamed as it exceeded the maximum length.") 133 | self._write_track(file_path, r) 134 | self._fix_tags(file_path, lyrics, ext, track_number, disc_number, 135 | track_artist, unquote(meta['SONG_NAME'])) 136 | 137 | @staticmethod 138 | def _write_track(file_path: str, r: Response): 139 | """Write track response data to file""" 140 | with open(file_path, 'wb') as f: 141 | for chunk in r.iter_content(32 * 1024): 142 | if chunk: 143 | f.write(chunk) 144 | 145 | @logger.catch 146 | def _fix_tags(self, path: str, lyrics: str, ext: str, track_number: str, disc_number: str, track_artist: str, track_title: str): 147 | """Fixes I3D/FLAC metadata 148 | 149 | Args: 150 | path (str): Path of .mp3/.flac file 151 | lyrics (str): Song lyrics 152 | ext (str): File extension (.mp3/.flac) 153 | track_number (str): String representation of the track number 154 | disc_number (str): String representation of the disc number 155 | track_artist (str): Name of the artist connected to the track 156 | track_title (str): Name of the track 157 | """ 158 | if ext == ".mp3": 159 | # Instantiate ID3 object 160 | try: 161 | audio = id3.ID3(path) 162 | except id3.ID3NoHeaderError: 163 | audio = id3.ID3() 164 | # Delete pre-embedded artwork 165 | audio.delall("APIC") 166 | # Embed existing artwork 167 | if self.cover_path: 168 | with open(self.cover_path, 'rb') as cov_obj: 169 | audio.add(id3.APIC(3, 'image/jpg', 3, '', cov_obj.read())) 170 | # Append necessary tags 171 | audio['TIT2'] = id3.TIT2(text=track_title) 172 | audio['TALB'] = id3.TALB(text=unquote(self.album['ALBUM_NAME'])) 173 | audio['TCON'] = id3.TCON(text=unquote(self.album['ALBUM_NAME'])) 174 | audio['TRCK'] = id3.TRCK(text=str(track_number) + "/" + str(len(self.meta['DATA1']['DATA']))) 175 | audio['TPOS'] = id3.TPOS(text=str(disc_number) + "/" + str(self.disc_total)) 176 | audio['TDRC'] = id3.TDRC(text=self.album['ALBUM_RELEASE_DT']) 177 | audio['TPUB'] = id3.TPUB(text=unquote(self.album['ALBUM_PLANNER'])) 178 | audio['TPE1'] = id3.TPE1(text=unquote(track_artist)) 179 | audio['TPE2'] = id3.TPE2(text=unquote(self.album['ARTIST_NAME'])) 180 | audio['TCON'] = id3.TCON(text="") 181 | audio.setall("COMM", [id3.COMM(text=[u"지니뮤직"], encoding=id3.Encoding.UTF8)]) 182 | if lyrics != None and self.settings['timed_lyrics']: 183 | lyrics = [(v, int(k)) for k, v in lyrics.items()] 184 | audio.setall("SYLT", [id3.SYLT(encoding=id3.Encoding.UTF8, lang='eng', format=2, type=1, text=lyrics)]) 185 | logger.debug(f"Writing tags to: {path}") 186 | audio.save(path, "v2_version=3") # Write file 187 | else: 188 | audio = FLAC(path) 189 | # Delete pre-embedded artwork 190 | audio.clear_pictures() 191 | # Embed existing artwork 192 | if self.cover_path: 193 | f_image = Picture() 194 | f_image.type = 3 195 | f_image.desc = 'Front Cover' 196 | with open(self.cover_path, 'rb') as f: 197 | f_image.data = f.read() 198 | audio.add_picture(f_image) 199 | # Append necessary tags 200 | audio['TRACKNUMBER'] = str(track_number) 201 | audio['TRACKTOTAL'] = str(len(self.meta['DATA1']['DATA'])) 202 | audio['DISCNUMBER'] = str(disc_number) 203 | audio['DISCTOTAL'] = str(self.disc_total) 204 | audio['DATE'] = self.album['ALBUM_RELEASE_DT'] 205 | audio['LABEL'] = unquote(self.album['ALBUM_PLANNER']) 206 | audio['ARTIST'] = unquote(track_artist) 207 | audio['ALBUMARTIST'] = unquote(self.album['ARTIST_NAME']) 208 | audio['ALBUM'] = unquote(self.album['ALBUM_NAME']) 209 | audio['TITLE'] = track_title 210 | audio['COMMENT'] = "지니뮤직" 211 | if lyrics != None and self.settings['timed_lyrics']: 212 | audio['LYRICS'] = format_genie_lyrics(lyrics) 213 | logger.debug(f"Writing tags to: {path}") 214 | audio.save() # Write file 215 | 216 | def _download_cover(self, url: str): 217 | """Download cover artwork""" 218 | self.cover_path = os.path.join(self.album_path, 'cover.jpg') 219 | if not os.path.isfile(self.cover_path): 220 | r = self.client.session.get(unquote(url)) 221 | with open(self.cover_path, 'wb') as f: 222 | f.write(r.content) 223 | else: 224 | logger.debug(f"{self.cover_path} already exists.") -------------------------------------------------------------------------------- /rsack/kkbox.py: -------------------------------------------------------------------------------- 1 | import os 2 | from mutagen.flac import FLAC, Picture 3 | from mutagen.mp4 import MP4 4 | from loguru import logger 5 | from concurrent.futures import ThreadPoolExecutor 6 | 7 | from rsack.clients.kkbox import KkboxAPI 8 | from rsack.utils import Settings, sanitize 9 | 10 | class Download(): 11 | def __init__(self, url): 12 | self.settings = Settings().KKBox() 13 | self.id = url.split("/")[-1] 14 | self.client = KkboxAPI(proxy=self.settings['proxy']) 15 | self.client.login(email=self.settings['email'], 16 | password=self.settings['password'], 17 | region_bypass=False) 18 | if url.split("/")[-2] == "artist": 19 | artist = self.client.get_artist(self.id) 20 | artist_raw_id = artist["profile"]["artist_id"] 21 | artist_albums = self.client.get_artist_albums(artist_raw_id,20,0) 22 | for album in artist_albums: 23 | self._download_album(album['encrypted_album_id']) 24 | else: 25 | self._download_album(self.id) 26 | 27 | @staticmethod 28 | def _determine_type(audio_quality: str) -> str: 29 | """Return name of stream data given the audio quality 30 | """ 31 | legend = { 32 | '128k': 'mp3_128k_chromecast', 33 | '192k': 'mp3_192k_kkdrm1', 34 | '320k': 'aac_320k_m4a_kkdrm1', 35 | 'hifi': 'flac_16_download_kkdrm', 36 | 'hires': 'flac_24_download_kkdrm', 37 | } 38 | return legend[audio_quality] 39 | 40 | def get_img_url(self, url_template, size, file_type='jpg'): 41 | url = url_template 42 | # not using .format() here because of possible data leak vulnerabilities 43 | if size > 2048: 44 | url = url.replace('fit/{width}x{height}', 'original') 45 | url = url.replace('cropresize/{width}x{height}', 'original') 46 | else: 47 | url = url.replace('{width}', str(size)) 48 | url = url.replace('{height}', str(size)) 49 | url = url.replace('{format}', file_type) 50 | return url 51 | 52 | @logger.catch 53 | def _template(self): 54 | keys = { 55 | "artist": self.meta['album']['artist_name'], 56 | "title": self.meta['album']['album_name'], 57 | "date": self.meta['release_date'], 58 | "album_id": str(self.meta['album']['album_id']), 59 | "encrypted_album_id": self.meta['album']['encrypted_album_id'], 60 | "artist_id": str(self.meta['album']['artist_id']), 61 | "encrypted_artist_id": self.meta['album']['encrypted_artist_id'] 62 | } 63 | template = self.settings['template'] 64 | for k in keys: 65 | template = template.replace(f"{{{k}}}", sanitize(keys[k])) 66 | return template 67 | 68 | def _create_album_folder(self): 69 | self.album_path = self.settings['path'] + self._template() 70 | try: 71 | if not os.path.exists(self.album_path): 72 | os.makedirs(self.album_path) 73 | except OSError as exc: 74 | if exc.errno == 36: # Exceeded path limit 75 | self.album_path = os.path.join(self.settings['path'], 'EDIT ME') 76 | if not os.path.exists(self.album_path): # Retry 77 | os.makedirs(self.album_path) 78 | 79 | def _download_cover(self): 80 | url = self.get_img_url(self.meta['album']['album_photo_info']['url_template'], 3000) 81 | logger.info("Downloading original artwork") 82 | r = self.client.session.get(url) 83 | self.cover_path = os.path.join(self.album_path, 'cover.jpg') 84 | with open(self.cover_path, 'wb') as f: 85 | f.write(r.content) 86 | 87 | logger.info("Downloading artwork to embed") 88 | url = self.get_img_url(self.meta['album']['album_photo_info']['url_template'], 600) 89 | r = self.client.session.get(url) 90 | self.cover_path_embed = os.path.join(self.album_path, 'embed.jpg') 91 | with open(self.cover_path_embed, 'wb') as f: 92 | f.write(r.content) 93 | 94 | @logger.catch 95 | def _download_album(self, id): 96 | self.meta = self.client.get_album(id=id) 97 | self.meta['release_date'] = self.meta['album']['album_date'].replace('-', '.') 98 | # Include first day of month if not present 99 | if len(self.meta['release_date']) == 7: 100 | self.meta['release_date'] = self.meta['release_date'] + ".01" 101 | song_list = self.client.get_album_more(self.meta['album']['album_id']) 102 | self._create_album_folder() 103 | self._download_cover() 104 | self.meta['album']['track_total'] = song_list['song_list']['song'][-1]['trankno'] # Assign track total because the existing 'collected_count' is not accurate. 105 | with ThreadPoolExecutor(max_workers=int(self.settings['threads'])) as executor: 106 | executor.map(self._download_track, song_list['song_list']['song']) 107 | logger.debug(f"Deleting {self.cover_path_embed}") 108 | os.remove(self.cover_path_embed) 109 | 110 | def _format_lyrics(self, lyrics: dict): 111 | embedded = '' 112 | synced = '' 113 | for lyr in lyrics['data']['lyrics']: 114 | if not lyr['content']: 115 | embedded += '\n' 116 | synced += '\n' 117 | continue 118 | 119 | time = lyr['start_time'] 120 | min = int(time / (1000 * 60)) 121 | sec = int(time / 1000) % 60 122 | ms = int(time % 100) 123 | time_tag = f'[{min:02d}:{sec:02d}.{ms:02d}]' 124 | 125 | embedded += lyr['content'] + '\n' 126 | synced += time_tag + lyr['content'] + '\n' 127 | return synced 128 | 129 | @logger.catch 130 | def _download_track(self, song: dict): 131 | id = song['song_more_url'].split('/')[-1] 132 | urls = self.client.get_ticket(id, "webclient") 133 | url_type = self._determine_type(song['audio_quality'][-1]) 134 | for u in urls: 135 | if u['name'] == url_type: 136 | url = u['url'] 137 | file_ext = f".{url_type.split('_')[0]}" 138 | if file_ext == '.aac': # Correct .aac to .m4a 139 | file_ext = '.m4a' 140 | file_name = sanitize(f"{song['trankno'].zfill(2)}. {song['text']}{file_ext}") 141 | file_path = os.path.join(self.album_path, file_name) 142 | if not os.path.exists(file_path): 143 | logger.info(f"{file_name}") 144 | self.client.kkdrm_dl(url=url, path=file_path) 145 | if song['song_lyrics_valid'] == 1: 146 | l = self.client.get_song_lyrics(id) 147 | if l['status']['type'] == "OK": 148 | lyrics = self._format_lyrics(l) 149 | else: 150 | lyrics = '' 151 | else: 152 | lyrics = '' 153 | Tag(file_path=file_path, file_ext=file_ext, track_meta=song, lyrics=lyrics, album_meta=self.meta, cover_path=self.cover_path_embed) 154 | else: 155 | logger.info(f"Already exists: {file_path}") 156 | 157 | class Tag(): 158 | def __init__(self, file_path: str, file_ext: str, track_meta: dict, lyrics: str, album_meta: dict, cover_path: str): 159 | self.cover_path = cover_path 160 | self.file_path = file_path 161 | self.album_meta = album_meta 162 | self.track_meta = track_meta 163 | self.lyrics = lyrics 164 | if file_ext == ".flac": 165 | self.flac() 166 | elif file_ext == '.m4a': 167 | self.aac() 168 | 169 | @logger.catch 170 | def aac(self): 171 | audio = MP4(self.file_path) 172 | 173 | # Tags 174 | logger.debug(f"Writing tags to {self.file_path}") 175 | audio['\xa9ART'] = self.track_meta['artist_role']['mainartist_list']['mainartist'] 176 | audio['aART'] = self.album_meta['album']['artist_name'] # Look into this 177 | audio['\xa9alb'] = self.album_meta['album']['album_name'] 178 | audio['\xa9nam'] = self.track_meta['text'] 179 | audio['\xa9cmt'] = self.track_meta['song_id'] 180 | audio['\xa9gen'] = self.track_meta['genre_name'] 181 | audio['trkn'] = [(int(self.track_meta['trankno']), int(self.album_meta['album']['track_total']))] 182 | audio['disk'] = [(1, 1)] # KKBOX don't include disc numbers 183 | audio['\xa9day'] = self.album_meta['release_date'] 184 | 185 | audio.save() 186 | 187 | @logger.catch 188 | def flac(self): 189 | """Tag flac file 190 | """ 191 | audio = FLAC(self.file_path) # Initialize FLAC object. 192 | 193 | # Remove replay gain tags 194 | audio.pop('replaygain_track_peak') 195 | audio.pop('replaygain_album_gain') 196 | audio.pop('replaygain_track_gain') 197 | audio.pop('replaygain_album_peak') 198 | audio.pop('replaygain_reference_loudness') 199 | 200 | # Embed cover artwork 201 | audio_image = Picture() 202 | audio_image.type = 3 203 | audio_image.desc = 'Front Cover' 204 | with open(self.cover_path, 'rb') as f: 205 | audio_image.data = f.read() 206 | audio.add_picture(audio_image) 207 | 208 | # Tags 209 | logger.debug(f"Writing tags to {self.file_path}") 210 | audio['ARTIST'] = self.track_meta['artist_role']['mainartist_list']['mainartist'] 211 | audio['ALBUMARTIST'] = self.album_meta['album']['artist_name'] # Look into this 212 | audio['ALBUM'] = self.album_meta['album']['album_name'] 213 | audio['TITLE'] = self.track_meta['text'] 214 | audio['COMMENT'] = self.track_meta['song_id'] 215 | audio['GENRE'] = self.track_meta['genre_name'] 216 | audio['TRACKNUMBER'] = self.track_meta['trankno'] 217 | audio['TRACKTOTAL'] = str(self.album_meta['album']['track_total']) 218 | audio['DISCTOTAL'] = "1" # KKBOX don't include disc totals 219 | audio['DISCNUMBER'] = "1" # KKBOX don't include disc numbers 220 | audio['DATE'] = self.album_meta['release_date'] 221 | 222 | if self.lyrics != '': 223 | audio['LYRICS'] = self.lyrics 224 | 225 | # Save file 226 | audio.save() -------------------------------------------------------------------------------- /rsack/main.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | from argparse import ArgumentParser 3 | from urllib.parse import urlparse 4 | 5 | from rsack.version import __version__ 6 | from rsack import bugs, genie, kkbox 7 | from rsack.utils import Settings, bugs_id, genie_id 8 | 9 | def get_args(): 10 | """Generate arguments""" 11 | parser = ArgumentParser() 12 | parser.add_argument('-v', '--version', action='store_true', dest='version', required=False) 13 | parser.add_argument('-u', '--url', nargs='*', dest="url", required=False) 14 | return parser.parse_args() 15 | 16 | def main(): 17 | """Entry point""" 18 | args = get_args() 19 | if args.version: 20 | print(__version__) 21 | if args.url: 22 | for url in args.url: 23 | domain = urlparse(url).netloc.replace("www.", "") 24 | if domain == "music.bugs.co.kr": 25 | id = bugs_id(url) 26 | if "album" in url: 27 | bugs.Download(type="album", id=int(id)) 28 | elif "artist" in url: 29 | bugs.Download(type="artist", id=int(id)) 30 | elif "track" in url: 31 | bugs.Download(type="Single tracks not yet available", id=int(id)) 32 | elif domain == "genie.co.kr": 33 | match = genie_id(url) 34 | if match.group(1) == "artistInfo": 35 | type = "artist" 36 | elif match.group(1) == "albumInfo": 37 | type = "album" 38 | else: # Catch invalid info types 39 | logger.critical("URL type unkown") 40 | genie.Download(type=type, id=int(match.group(2))) 41 | elif domain == "play.kkbox.com" or domain == "kkbox.com": 42 | kkbox.Download(url) -------------------------------------------------------------------------------- /rsack/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sys import exit 3 | from platform import system 4 | from re import match, sub 5 | from datetime import datetime 6 | from loguru import logger 7 | from configparser import ConfigParser 8 | 9 | from rsack.exceptions import InvalidURL 10 | 11 | class Settings: 12 | def __init__(self, check=False): 13 | self.ini_path = os.path.join(get_settings_path(), 'rsack_settings.ini') 14 | # If settings doesn't exist then create one 15 | if not os.path.isfile(self.ini_path): 16 | logger.debug(f'Writing {self.ini_path}') 17 | self.generate_settings() 18 | # Read settings.ini 19 | self.config = ConfigParser() 20 | self.config.read(self.ini_path) 21 | 22 | @logger.catch 23 | def generate_settings(self): 24 | """ 25 | Generates settings.ini based on user input 26 | """ 27 | # Write file 28 | config = ConfigParser() 29 | config['Bugs'] = {'email': "email@protonmail.com", 30 | 'password': "mypassword", 31 | 'threads': "2", 32 | 'path': "C:\Music\Korean", 33 | 'timed_lyrics': "true", 34 | 'contributions': "false", 35 | "cover_size": "original", 36 | "template": "/{artist}/{artist} - {title}"} 37 | 38 | config['Genie'] = {'username': "username", 39 | 'password': "mypassword", 40 | 'threads': "2", 41 | 'path': "C:\Music\Korean", 42 | 'timed_lyrics': "Y", 43 | 'contributions': "N", 44 | "template": "/{artist}/{artist} - {title}"} 45 | 46 | config['KKBox'] = {"email": "email@protonmail.com", 47 | "password": "mypassword", 48 | "threads": "2", 49 | "path": "C:\Music\KKBox", 50 | "template": "/{artist}/{artist} - {title}"} 51 | 52 | with open(self.ini_path, 'w+') as configfile: 53 | config.write(configfile) 54 | exit() 55 | 56 | def _str_to_boolean(self, section: str, d: dict) -> dict: 57 | """Iterates dict converting possible boolean values 58 | 59 | Args: 60 | section (str): Section header 61 | d (dict): Dictionary to iterate 62 | 63 | Returns: 64 | dict: Dictionary with string values replaced with boolean 65 | """ 66 | for k in d: 67 | try: 68 | d[k] = self.config.getboolean(section, k) 69 | except ValueError: # Not a boolean 70 | pass 71 | return d 72 | 73 | def Bugs(self) -> dict: 74 | """ 75 | Returns the contents of the 'Bugs' section of settings.ini as dict. 76 | """ 77 | return self._str_to_boolean(section='Bugs', d=dict(self.config.items('Bugs'))) 78 | 79 | def Genie(self) -> dict: 80 | """ 81 | Returns the contents of the 'Genie' section of settings.ini as dict. 82 | """ 83 | return self._str_to_boolean(section='Genie', d=dict(self.config.items('Genie'))) 84 | 85 | def KKBox(self) -> dict: 86 | """ 87 | Returns the contents of the 'KKBox' section of settings.ini as dict. 88 | """ 89 | return self._str_to_boolean(section='KKBox', d=dict(self.config.items('KKBox'))) 90 | 91 | 92 | def track_to_flac(track: dict, album: dict, lyrics: str) -> dict: 93 | """Creates dict with appropriate FLAC headers. 94 | 95 | Args: 96 | track (dict): Dict containing track info from API response 97 | album (dict): Dict containing album info from API response 98 | lyrics (str): Lyrics of the track 99 | """ 100 | meta = { 101 | "ALBUM": track['album_title'], 102 | "ALBUMARTIST": album['artist_disp_nm'], 103 | "ARTIST": track['artist_disp_nm'], 104 | "TITLE": track['track_title'], 105 | "DISCNUMBER": str(track['disc_id']), 106 | "DISCTOTAL": str(album['disc_total']), 107 | "TRACKNUMBER": str(track['track_no']), 108 | "TRACKTOTAL": str(track['track_total']), 109 | "COMMENT": str(track['track_id']), 110 | "DATE": _format_date(track['release_ymd']), 111 | "GENRE": album['genre_str'], 112 | "LABEL": '; '.join(str(label['label_nm']) for label in album['labels']), 113 | "LYRICS": lyrics 114 | } 115 | return meta 116 | 117 | def _format_date(date): 118 | """Formats album release date to preferred format""" 119 | # Append release day if not present. 120 | if len(date) == 6: 121 | date = date + "01" 122 | date_patterns = ["%Y%m%d", "%Y%m", "%Y"] 123 | for pattern in date_patterns: 124 | try: 125 | return datetime.strptime(date, pattern).strftime('%Y.%m.%d') 126 | except ValueError: 127 | pass 128 | 129 | def bugs_id(url: str) -> str: 130 | return match( 131 | r'https?://music\.bugs\.co\.kr/(?:(?:album|artist|track|playlist)/|[a-z]{2}-[a-z]{2}-?\w+(?:-\w+)*-?)(\w+)',url).group(1) 132 | 133 | def genie_id(url: str) -> match: 134 | expression = r"https://genie.co.kr/detail/(artistInfo|albumInfo)......([0-9]*)" 135 | result = match(expression, url) 136 | # This shouldn't be needed, regex needs to be fixed. 137 | if not result: 138 | expression = r"https://www.genie.co.kr/detail/(artistInfo|albumInfo)......([0-9]*)" 139 | result = match(expression, url) 140 | if result: 141 | return result 142 | raise InvalidURL(url) 143 | 144 | def get_settings_path() -> str: 145 | """Returns path of home folder to store settings.ini""" 146 | if "XDG_CONFIG_HOME" in os.environ: 147 | return os.environ['XDG_CONFIG_HOME'] 148 | elif "HOME" in os.environ: 149 | return os.environ['HOME'] 150 | elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ: 151 | return os.environ['HOMEDRIVE'] + os.environ['HOMEPATH'] 152 | else: 153 | return os.path._getfullpathname("./") 154 | 155 | def get_ext(type: str) -> str: 156 | """Return the filetype. 157 | 158 | Args: 159 | type (str): "FILE_EXT" from player/j_StmInfo.json response 160 | Known FILE_EXT's: 161 | F96: FLAC 24bit/96kHz 162 | F44: FLAC 24bit/44.1kHz 163 | FLA: FLAC 16bit 164 | MP3: MP3 165 | 166 | Returns: 167 | str: File extension 168 | """ 169 | if type == "MP3": 170 | return ".mp3" 171 | else: 172 | return ".flac" 173 | 174 | def insert_total_tracks(tracks: list[dict]): 175 | """Add total_tracks to track metadata""" 176 | total_tracks_by_disc_id = {} 177 | for track in tracks: 178 | if track["disc_id"] not in total_tracks_by_disc_id: 179 | total_tracks_by_disc_id[track["disc_id"]] = 0 180 | total_tracks_by_disc_id[track["disc_id"]] += 1 181 | 182 | for track in tracks: 183 | track["track_total"] = total_tracks_by_disc_id[track["disc_id"]] 184 | 185 | def _is_win() -> bool: 186 | if system() == 'Windows': 187 | return True 188 | 189 | def sanitize(fn: str) -> str: 190 | """Sanitizes filenames based on Operating System""" 191 | if _is_win(): 192 | return sub(r'[\/:*?"><|]', '_', fn).strip('. ') 193 | else: 194 | return sub('/', '_', fn).strip() 195 | 196 | def contribution_check(artist_id_provided: int, artist_id_api: int) -> bool: 197 | """Checks if artist is contributing""" 198 | if artist_id_provided == artist_id_api: 199 | return False 200 | else: 201 | return True 202 | 203 | def format_genie_lyrics(lyrics: dict) -> str: 204 | """Convert Genie dict to a usable str format for tagging""" 205 | # Convert millisecond keys to format [00:00.00][minutes:seconds.milliseconds] 206 | lines = [f"[{datetime.fromtimestamp(int(x)/1000).strftime('%M:%S.%f')[:-4]}]{lyrics[x]}" for x in lyrics] 207 | return '\n'.join(lines) -------------------------------------------------------------------------------- /rsack/version.py: -------------------------------------------------------------------------------- 1 | # Package version 2 | __version__ = "0.7.9" 3 | -------------------------------------------------------------------------------- /rsack_settings.ini.example: -------------------------------------------------------------------------------- 1 | [Bugs] 2 | email = email@protonmail.com 3 | password = mypassword 4 | threads = 2 5 | path = C:\Music\Korean 6 | timed_lyrics = true 7 | contributions = false 8 | cover_size = original 9 | template = \{artist}\{artist} - {title} 10 | proxy = socks5://username:password@ip:port 11 | 12 | [Genie] 13 | username = username 14 | password = mypassword 15 | threads = 2 16 | path = C:\Music\Korean 17 | timed_lyrics = true 18 | contributions = false 19 | template = \{artist}\{artist} - {title} 20 | proxy = socks5://username:password@ip:port 21 | 22 | [KKBox] 23 | email = email@protonmail.com 24 | password = mypassword 25 | threads = 2 26 | path = C:\Music\KKBox 27 | template = \{artist}\{artist} - {title} 28 | proxy = socks5://username:password@ip:port 29 | 30 | ## Template Cheat Sheet 31 | # {artist} : artist name 32 | # {title} : album title 33 | # {local_title} : local album title (local to korea) | Bugs.co.kr only 34 | # {date} : release date in yyyy.mm.dd format 35 | # {local_date} : date released in korea in yyyy.mm.dd format | Bugs.co.kr only 36 | # {album_id} : id of the album on webstore 37 | # {encrypted_album_id} : encrypted album id of the album on webstore | KKBox only 38 | # {artist_id} : id of artist on webstore 39 | # {encrypted_artist_id} : encrypted artist id of artist on webstore | KKBox only 40 | # {type} : release type as provided by webstore (E.G SL for Single as provided by Bugs) 41 | # {agency_name} : name of agency the album is released under | Bugs.co.kr only 42 | # {agency_id} : id of agency on webstore | Bugs.co.kr only 43 | # {label_name} : name of the first label listed under the album | Genie/Bugs only 44 | # {label_id} : id of label on webstore | Bugs.co.kr only 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from rsack.version import __version__ 3 | 4 | setup( 5 | name="rsack", 6 | description="A Multi-purpose downloader.", 7 | long_description="Read more at https://github.com/Slyyxp/rsack", 8 | url="https://github.com/Slyyxp/rsack", 9 | author="Slyyxp", 10 | author_email="slyyxp@protonmail.com", 11 | version=__version__, 12 | packages=find_packages(), 13 | install_requires=["requests==2.25.1", "mutagen==1.45.1", "loguru==0.5.3", "pycryptodomex==3.17"], 14 | entry_points={ 15 | 'console_scripts': [ 16 | 'rsack = rsack.main:main' 17 | ] 18 | }) 19 | --------------------------------------------------------------------------------