├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── ncmdump ├── __init__.py ├── __main__.py ├── core.py └── crypto.py └── pyproject.toml /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: PyPI Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 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 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ww-rm 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.md: -------------------------------------------------------------------------------- 1 | # ncmdump-py 2 | 3 | A simple package used to dump ncm files to mp3 or flac files, it can: 4 | 5 | - Decrypt and dump `.ncm` files. 6 | - Auto add album and cover info into `.mp3` or `.flac` files. 7 | - Auto try download cover image when there is no cover data in `.ncm` files. 8 | 9 | ## Install 10 | 11 | ```bat 12 | pip install ncmdump-py 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### Command-line tool 18 | 19 | ```plain 20 | python -m ncmdump [-h] [--in-folder IN_FOLDER] [--out-folder OUT_FOLDER] [--dump-metadata] [--dump-cover] [files ...] 21 | ``` 22 | 23 | ```plain 24 | usage: ncmdump [-h] [--in-folder IN_FOLDER] [--out-folder OUT_FOLDER] [--dump-metadata] [--dump-cover] [files ...] 25 | 26 | Dump ncm files with progress bar and logging info, only process files with suffix '.ncm' 27 | 28 | positional arguments: 29 | files Files to dump, can follow multiple files. 30 | 31 | optional arguments: 32 | -h, --help show this help message and exit 33 | --in-folder IN_FOLDER 34 | Input folder of files to dump. 35 | --out-folder OUT_FOLDER 36 | Output folder of files dumped. 37 | --dump-metadata Whether dump metadata. 38 | --dump-cover Whether dump album cover. 39 | ``` 40 | 41 | ### Import in your code 42 | 43 | ```python 44 | from ncmdump import NeteaseCloudMusicFile 45 | 46 | ncmfile = NeteaseCloudMusicFile("filename.ncm") 47 | ncmfile.decrypt() 48 | 49 | print(ncmfile.music_metadata) # show music metadata 50 | 51 | ncmfile.dump_music("filename.mp3") # auto detect correct suffix 52 | 53 | # Maybe you also need dump metadata or cover image 54 | # ncmfile.dump_metadata("filename.json") 55 | # ncmfile.dump_cover("filename.jpeg") 56 | ``` 57 | 58 | --- 59 | 60 | *If you think this project is helpful to you, :star: it and let more people see!* 61 | -------------------------------------------------------------------------------- /ncmdump/__init__.py: -------------------------------------------------------------------------------- 1 | from ncmdump.core import NeteaseCloudMusicFile 2 | 3 | __version__ = "1.1.5" 4 | -------------------------------------------------------------------------------- /ncmdump/__main__.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from argparse import ArgumentParser 3 | from pathlib import Path 4 | 5 | from rich.progress import ( 6 | BarColumn, 7 | Progress, 8 | SpinnerColumn, 9 | TaskProgressColumn, 10 | TextColumn, 11 | TimeElapsedColumn, 12 | TimeRemainingColumn, 13 | ) 14 | 15 | from ncmdump import NeteaseCloudMusicFile, __version__ 16 | 17 | if __name__ == "__main__": 18 | print(f"ncmdump v{__version__}\n") 19 | 20 | parser = ArgumentParser("ncmdump", description="Dump ncm files with progress bar and logging info, only process files with suffix '.ncm'") 21 | parser.add_argument("files", nargs="*", help="Files to dump, can follow multiple files.") 22 | parser.add_argument("--in-folder", help="Input folder of files to dump.") 23 | parser.add_argument("--out-folder", help="Output folder of files dumped.", default=".") 24 | 25 | parser.add_argument("--dump-metadata", help="Whether dump metadata.", action="store_true") 26 | parser.add_argument("--dump-cover", help="Whether dump album cover.", action="store_true") 27 | 28 | args = parser.parse_args() 29 | 30 | out_folder = Path(args.out_folder) 31 | out_folder.mkdir(parents=True, exist_ok=True) 32 | 33 | dump_metadata = args.dump_metadata 34 | dump_cover = args.dump_cover 35 | 36 | files = args.files 37 | if args.in_folder: 38 | files.extend(Path(args.in_folder).iterdir()) 39 | files = list(filter(lambda p: p.suffix == ".ncm", map(Path, files))) 40 | 41 | if not files: 42 | parser.print_help() 43 | else: 44 | with Progress( 45 | SpinnerColumn(), 46 | TextColumn("[progress.description]{task.description}"), 47 | BarColumn(), 48 | TaskProgressColumn("[progress.percentage]{task.completed:d}/{task.total:d}"), 49 | TimeRemainingColumn(), 50 | TimeElapsedColumn() 51 | ) as progress: 52 | task = progress.add_task("[#d75f00]Dumping files", total=len(files)) 53 | 54 | for ncm_path in files: 55 | output_path = out_folder.joinpath(ncm_path.with_suffix(".mp3")) # suffix will be corrected later 56 | 57 | try: 58 | ncmfile = NeteaseCloudMusicFile(ncm_path).decrypt() 59 | music_path = ncmfile.dump_music(output_path) 60 | 61 | if dump_metadata: 62 | ncmfile.dump_metadata(output_path) 63 | if dump_cover: 64 | ncmfile.dump_cover(output_path) 65 | 66 | except Exception as e: 67 | progress.log(f"[red]ERROR[/red]: {ncm_path} -> {traceback.format_exc()}") 68 | 69 | else: 70 | if not ncmfile.has_metadata: 71 | progress.log(f"[yellow]WARNING[/yellow]: {ncm_path} -> {music_path}, no metadata found") 72 | if not ncmfile.has_cover: 73 | progress.log(f"[yellow]WARNING[/yellow]: {ncm_path} -> {music_path}, no cover data found") 74 | 75 | finally: 76 | progress.advance(task) 77 | -------------------------------------------------------------------------------- /ncmdump/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | import imghdr 4 | import json 5 | import mimetypes 6 | from base64 import b64decode 7 | from io import BytesIO 8 | from os import PathLike 9 | from pathlib import Path 10 | from typing import List, Union 11 | from urllib import request 12 | 13 | from mutagen import flac, id3, mp3 14 | from PIL import Image 15 | 16 | from ncmdump import crypto 17 | 18 | __all__ = ["NeteaseCloudMusicFile", "Metadata", "MusicMetadata"] 19 | 20 | 21 | class MusicMetadata: 22 | """Metadata for music""" 23 | 24 | def __init__(self, data: dict = None) -> None: 25 | self._data = data or {} 26 | 27 | def __repr__(self) -> str: 28 | return self._data.__repr__() 29 | 30 | def __str__(self) -> str: 31 | return self._data.__str__() 32 | 33 | @property 34 | def json(self) -> dict: 35 | return self._data 36 | 37 | @property 38 | def format(self) -> str: 39 | return self._data.get("format", "mp3") 40 | 41 | @property 42 | def id(self) -> int: 43 | return self._data.get("musicId", -1) 44 | 45 | @property 46 | def name(self) -> str: 47 | return self._data.get("musicName", "Unknown") 48 | 49 | @property 50 | def artists(self) -> List[str]: 51 | return [a[0] for a in self._data.get("artist", [])] 52 | 53 | @property 54 | def album(self) -> str: 55 | return self._data.get("album", "Unknown") 56 | 57 | @property 58 | def cover_url(self) -> str: 59 | return self._data.get("albumPic", "http://p3.music.126.net/tBTNafgjNnTL1KlZMt7lVA==/18885211718935735.jpg") 60 | 61 | 62 | class Metadata: 63 | """Metadata for ncm file. 64 | 65 | `music`: 66 | 67 | ```json 68 | { 69 | "format": "flac", 70 | "musicId": 431259256, 71 | "musicName": "カタオモイ", 72 | "artist": [["Aimer", 16152]], 73 | "album": "daydream", 74 | "albumId": 34826361, 75 | "albumPicDocId": 109951165052089697, 76 | "albumPic": "http://p1.music.126.net/2QRYxUqXfW0zQpm2_DVYRA==/109951165052089697.jpg", 77 | "mvId": 0, 78 | "flag": 4, 79 | "bitrate": 876923, 80 | "duration": 207866, 81 | "alias": [], 82 | "transNames": ["单相思"] 83 | } 84 | ``` 85 | 86 | `dj`: 87 | 88 | ```json 89 | { 90 | "programId": 2506516081, 91 | "programName": "03 踏遍万水千山", 92 | "mainMusic": { 93 | "musicId": 1957438579, 94 | "musicName": "03 踏遍万水千山", 95 | "artist": [], 96 | "album": "[DJ节目]北方文艺出版社的DJ节目 第8期", 97 | "albumId": 0, 98 | "albumPicDocId": 109951167551086981, 99 | "albumPic": "https://p1.music.126.net/M48NPuT591tIqqUdQyKZlg==/109951167551086981.jpg", 100 | "mvId": 0, 101 | "flag": 0, 102 | "bitrate": 320000, 103 | "duration": 1222948, 104 | "alias": [], 105 | "transNames": [] 106 | }, 107 | "djId": 7891086863, 108 | "djName": "北方文艺出版社", 109 | "djAvatarUrl": "http://p1.music.126.net/DQr2q_S23tYY8vU_C-kAYw==/109951167535553901.jpg", 110 | "createTime": 1655691020376, 111 | "brand": "林徽因传:倾我所能去坚强", 112 | "serial": 3, 113 | "programDesc": "这是一本有温度、有态度的传记,记录了真正意义上的民国女神——林徽因,从容坚强、传奇丰沛的一生。", 114 | "programFeeType": 15, 115 | "programBuyed": true, 116 | "radioId": 977264730, 117 | "radioName": "林徽因传:倾我所能去坚强", 118 | "radioCategory": "文学出版", 119 | "radioCategoryId": 3148096, 120 | "radioDesc": "这是一本有温度、有态度的传记,记录了真正意义上的民国女神——林徽因,从容坚强、传奇丰沛的一生。", 121 | "radioFeeType": 1, 122 | "radioFeeScope": 0, 123 | "radioBuyed": true, 124 | "radioPrice": 30, 125 | "radioPurchaseCount": 0 126 | } 127 | ``` 128 | 129 | """ 130 | 131 | def __init__(self, metadata: bytes = b"") -> None: 132 | self._metadata = metadata or b"music:{}" 133 | 134 | self._type = self._metadata[:self._metadata.index(b":")].decode() 135 | self._data: dict = json.loads(self._metadata[self._metadata.index(b":") + 1:]) 136 | 137 | if self.type == "music": 138 | self._music_metadata = MusicMetadata(self._data) 139 | elif self.type == "dj": 140 | self._music_metadata = MusicMetadata(self._data.get("mainMusic")) 141 | else: 142 | raise TypeError(f"Unknown metadata type: '{self.type}'") 143 | 144 | def __repr__(self) -> str: 145 | return self._data.__repr__() 146 | 147 | def __str__(self) -> str: 148 | return self._data.__str__() 149 | 150 | @property 151 | def type(self) -> str: 152 | return self._type 153 | 154 | @property 155 | def json(self) -> dict: 156 | return self._data 157 | 158 | @property 159 | def music_metadata(self) -> MusicMetadata: 160 | return self._music_metadata 161 | 162 | 163 | class NeteaseCloudMusicFile: 164 | """ncm file""" 165 | 166 | MAGIC_HEADER = b"CTENFDAM" 167 | 168 | AES_KEY_RC4_KEY = bytes.fromhex("687A4852416D736F356B496E62617857") 169 | RC4_KEY_XORBYTE = 0x64 170 | 171 | AES_KEY_METADATA = bytes.fromhex("2331346C6A6B5F215C5D2630553C2728") 172 | METADATA_XORBYTE = 0x63 173 | 174 | @property 175 | def has_metadata(self) -> bool: 176 | return self._metadata_enc_size > 0 177 | 178 | @property 179 | def has_cover(self) -> bool: 180 | return self._cover_data_size > 0 181 | 182 | @property 183 | def metadata(self) -> Metadata: 184 | return self._metadata 185 | 186 | @property 187 | def music_metadata(self) -> MusicMetadata: 188 | return self._metadata.music_metadata 189 | 190 | @property 191 | def _cover_suffix(self) -> str: 192 | return f".{imghdr.what(None, self._cover_data[:32])}" 193 | 194 | @property 195 | def _cover_mime(self) -> str: 196 | return mimetypes.types_map.get(self._cover_suffix, "") 197 | 198 | def __init__(self, path: Union[str, PathLike]) -> None: 199 | """ 200 | Args: 201 | path (str or PathLike): ncm file path 202 | """ 203 | 204 | self._path = Path(path) 205 | self._parse() 206 | 207 | def _parse(self) -> None: 208 | """parse file.""" 209 | 210 | with self._path.open("rb") as ncmfile: 211 | self._hdr = ncmfile.read(8) 212 | 213 | if self._hdr != self.MAGIC_HEADER: 214 | raise TypeError(f"{self._path} is not a valid ncm file.") 215 | 216 | # XXX: 2 bytes unknown 217 | self._gap1 = ncmfile.read(2) 218 | 219 | self._rc4_key_enc_size = int.from_bytes(ncmfile.read(4), "little") 220 | self._rc4_key_enc = ncmfile.read(self._rc4_key_enc_size) 221 | self._rc4_key = b"" 222 | 223 | self._metadata_enc_size = int.from_bytes(ncmfile.read(4), "little") 224 | self._metadata_enc = ncmfile.read(self._metadata_enc_size) 225 | self._metadata = Metadata(b"") 226 | 227 | # XXX: 9 bytes unknown 228 | self._crc32 = int.from_bytes(ncmfile.read(4), "little") 229 | self._gap2 = ncmfile.read(5) 230 | 231 | self._cover_data_size = int.from_bytes(ncmfile.read(4), "little") 232 | self._cover_data = ncmfile.read(self._cover_data_size) 233 | 234 | self._music_data_enc = ncmfile.read() 235 | self._music_data = b"" 236 | 237 | def _decrypt_rc4_key(self) -> None: 238 | """ 239 | Attributes: 240 | self._rc4_key: bytes 241 | """ 242 | 243 | cryptor = crypto.NCMAES(self.AES_KEY_RC4_KEY) 244 | 245 | rc4_key = bytes(map(lambda b: b ^ self.RC4_KEY_XORBYTE, self._rc4_key_enc)) 246 | rc4_key = cryptor.unpad(cryptor.decrypt(rc4_key)) 247 | 248 | self._rc4_key = rc4_key[len(b"neteasecloudmusic"):] 249 | 250 | def _decrypt_metadata(self) -> None: 251 | """ 252 | Attributes: 253 | self._metadata: Metadata 254 | """ 255 | 256 | # if no metadata 257 | if self._metadata_enc_size > 0: 258 | cryptor = crypto.NCMAES(self.AES_KEY_METADATA) 259 | 260 | metadata = bytes(map(lambda b: b ^ self.METADATA_XORBYTE, self._metadata_enc)) 261 | 262 | metadata = b64decode(metadata[len(b"163 key(Don't modify):"):]) 263 | metadata = cryptor.unpad(cryptor.decrypt(metadata)) 264 | 265 | self._metadata = Metadata(metadata) 266 | 267 | def _decrypt_music_data(self) -> None: 268 | """ 269 | Attributes: 270 | self._music_data: bytes 271 | """ 272 | 273 | cryptor = crypto.NCMRC4(self._rc4_key) 274 | self._music_data = cryptor.decrypt(self._music_data_enc) 275 | 276 | def _try_get_cover_data(self) -> int: 277 | """If no cover data, try get cover data by url in metadata""" 278 | 279 | if self._cover_data_size <= 0: 280 | try: 281 | with request.urlopen(self._metadata.music_metadata.cover_url) as res: 282 | if res.status < 400: 283 | self._cover_data = res.read() 284 | self._cover_data_size = len(self._cover_data) 285 | except: 286 | pass 287 | 288 | return self._cover_data_size 289 | 290 | def decrypt(self) -> "NeteaseCloudMusicFile": 291 | """Decrypt all data. 292 | 293 | Returns: 294 | self 295 | """ 296 | 297 | self._decrypt_rc4_key() 298 | self._decrypt_metadata() 299 | 300 | self._try_get_cover_data() 301 | 302 | return self 303 | 304 | def dump_metadata(self, path: Union[str, PathLike], suffix: str = ".json") -> Path: 305 | """Dump metadata. 306 | 307 | Args: 308 | path (str or PathLike): path to dump. 309 | suffix (str): suffix for path, default to `.json` 310 | 311 | Returns: 312 | Path: path dumped. 313 | """ 314 | 315 | path = Path(path) 316 | path.parent.mkdir(parents=True, exist_ok=True) 317 | 318 | path = path.with_suffix(suffix) 319 | path.write_text(json.dumps(self._metadata.json, ensure_ascii=False, indent=4), "utf8") 320 | return path 321 | 322 | def dump_cover(self, path: Union[str, PathLike]) -> Path: 323 | """Dump cover image. 324 | 325 | Args: 326 | path (str or PathLike): path to dump. 327 | 328 | Returns: 329 | Path: path dumped. 330 | 331 | Note: 332 | If no cover data found, an empty file will be dumped, with same file stem and `None` suffix. 333 | """ 334 | 335 | path = Path(path) 336 | path.parent.mkdir(parents=True, exist_ok=True) 337 | 338 | path = path.with_suffix(self._cover_suffix) 339 | path.write_bytes(self._cover_data) 340 | return path 341 | 342 | def _dump_music(self, path: Union[str, PathLike]) -> Path: 343 | """Dump music without any other info.""" 344 | 345 | # lazy decrypt 346 | if not self._music_data: 347 | self._decrypt_music_data() 348 | 349 | path = Path(path) 350 | path.parent.mkdir(parents=True, exist_ok=True) 351 | 352 | path = path.with_suffix(f".{self._metadata.music_metadata.format}") 353 | path.write_bytes(self._music_data) 354 | return path 355 | 356 | def _addinfo_mp3(self, path: Union[str, PathLike]) -> None: 357 | """Add info for mp3 format.""" 358 | 359 | audio = mp3.MP3(path) 360 | 361 | audio["TIT2"] = id3.TIT2(text=self._metadata.music_metadata.name, encoding=id3.Encoding.UTF8) # title 362 | audio["TALB"] = id3.TALB(text=self._metadata.music_metadata.album, encoding=id3.Encoding.UTF8) # album 363 | audio["TPE1"] = id3.TPE1(text="/".join(self._metadata.music_metadata.artists), encoding=id3.Encoding.UTF8) # artists 364 | audio["TPE2"] = id3.TPE2(text="/".join(self._metadata.music_metadata.artists), encoding=id3.Encoding.UTF8) # album artists 365 | 366 | if self._cover_data_size > 0: 367 | audio["APIC"] = id3.APIC(type=id3.PictureType.COVER_FRONT, mime=self._cover_mime, data=self._cover_data) # cover 368 | 369 | audio.save() 370 | 371 | def _addinfo_flac(self, path: Union[str, PathLike]) -> None: 372 | """Add info for flac format.""" 373 | 374 | audio = flac.FLAC(path) 375 | 376 | # add music info 377 | audio["title"] = self._metadata.music_metadata.name 378 | audio["artist"] = self._metadata.music_metadata.artists 379 | audio["album"] = self._metadata.music_metadata.album 380 | audio["albumartist"] = "/".join(self._metadata.music_metadata.artists) 381 | 382 | # add cover 383 | if self._cover_data_size > 0: 384 | cover = flac.Picture() 385 | cover.type = id3.PictureType.COVER_FRONT 386 | cover.data = self._cover_data 387 | 388 | with BytesIO(self._cover_data) as data: 389 | with Image.open(data) as f: 390 | cover.mime = self._cover_mime 391 | cover.width = f.width 392 | cover.height = f.height 393 | cover.depth = len(f.getbands()) * 8 394 | 395 | audio.add_picture(cover) 396 | 397 | audio.save() 398 | 399 | def dump_music(self, path: Union[str, PathLike]) -> Path: 400 | """Dump music with metadata and cover. 401 | 402 | Args: 403 | path (str or PathLike): path to dump. 404 | 405 | Returns: 406 | Path: path dumped. 407 | 408 | Raises: 409 | NotImplementedError: If there are some unknown file types, it will only dump music data without music info. 410 | """ 411 | 412 | path = self._dump_music(path) 413 | 414 | if self._metadata.music_metadata.format == "flac": 415 | self._addinfo_flac(path) 416 | elif self._metadata.music_metadata.format == "mp3": 417 | self._addinfo_mp3(path) 418 | else: 419 | raise NotImplementedError(f"Unknown file type '{self._metadata.music_metadata.format}', failded to add music info.") 420 | 421 | return path 422 | -------------------------------------------------------------------------------- /ncmdump/crypto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | 4 | from Crypto.Cipher import AES 5 | from Crypto.Util.Padding import unpad 6 | 7 | __all__ = ["NCMRC4", "NCMAES"] 8 | 9 | 10 | class NCMRC4: 11 | """RC4 for ncm file.""" 12 | 13 | def __init__(self, key: bytes) -> None: 14 | """ 15 | Args: 16 | key (bytes): RC4 key bytes 17 | """ 18 | 19 | self._key = key 20 | self._s_box = bytearray(range(256)) 21 | self._key_box = bytearray(256) 22 | self._key_pos = 0 23 | 24 | # standard RC4 init 25 | j = 0 26 | for i in range(256): 27 | j = (j + self._s_box[i] + self._key[i % len(self._key)]) & 0xFF 28 | self._s_box[i], self._s_box[j] = self._s_box[j], self._s_box[i] 29 | 30 | # non-standard keybox generate 31 | for i in range(256): 32 | j = (i + 1) & 0xFF 33 | s_j = self._s_box[j] 34 | s_jj = self._s_box[(s_j + j) & 0xFF] 35 | self._key_box[i] = self._s_box[(s_jj + s_j) & 0xFF] 36 | 37 | def decrypt(self, ciphertext: bytes) -> bytes: 38 | """decrypt 39 | 40 | Args: 41 | ciphertext (bytes): btyes to be decrypted 42 | 43 | Returns: 44 | bytes: plaintext 45 | """ 46 | 47 | plaintext = bytearray() 48 | for b in ciphertext: 49 | plaintext.append(b ^ self._key_box[self._key_pos]) 50 | if self._key_pos >= 255: 51 | self._key_pos = 0 52 | else: 53 | self._key_pos += 1 54 | return bytes(plaintext) 55 | 56 | 57 | class NCMAES: 58 | """AES128 (ECB mode) for ncm file.""" 59 | 60 | def __init__(self, key: bytes) -> None: 61 | """ 62 | Args: 63 | key (bytes): AES128 key bytes 64 | """ 65 | 66 | assert len(key) == 16 67 | self._key = key 68 | 69 | self._cryptor = AES.new(self._key, AES.MODE_ECB) 70 | 71 | def decrypt(self, ciphertext: bytes) -> bytes: 72 | """decrypt 73 | 74 | Args: 75 | ciphertext (bytes): btyes to be decrypted 76 | 77 | Returns: 78 | bytes: plaintext 79 | """ 80 | 81 | return self._cryptor.decrypt(ciphertext) 82 | 83 | def unpad(self, padded_data: bytes) -> bytes: 84 | """unpad (pkcs7) for AES plain text. 85 | 86 | Args: 87 | padded_data (bytes): data decrypted by NCMAES 88 | 89 | Returns: 90 | bytes: unpadded data. 91 | """ 92 | 93 | return unpad(padded_data, len(self._key), "pkcs7") 94 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "ncmdump-py" 7 | authors = [ 8 | {name = "ww-rm", email = "ww-rm@qq.com"}, 9 | ] 10 | description = "Dump ncm files to mp3 or flac files." 11 | requires-python = ">=3.7" 12 | dependencies = [ 13 | "mutagen", 14 | "Pillow", 15 | "pycryptodome", 16 | "rich", 17 | ] 18 | dynamic = ["version", "readme"] 19 | 20 | [project.urls] 21 | "Homepage" = "https://github.com/ww-rm/ncmdump-py" 22 | "Issues" = "https://github.com/ww-rm/ncmdump-py/issues" 23 | 24 | [tool.setuptools] 25 | packages = ["ncmdump"] 26 | 27 | [tool.setuptools.dynamic] 28 | version = {attr = "ncmdump.__version__"} 29 | readme = {file = ["README.md"], content-type = "text/markdown"} 30 | --------------------------------------------------------------------------------