├── ymd ├── __init__.py ├── __main__.py ├── mime_utils.py ├── api.py ├── cli.py └── core.py ├── pyproject.toml ├── MIGRATION.md ├── LICENSE ├── .gitignore └── README.md /ymd/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ymd/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .cli import main 4 | 5 | sys.exit(main()) 6 | -------------------------------------------------------------------------------- /ymd/mime_utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | 5 | class MimeType(Enum): 6 | JPEG = "image/jpeg" 7 | PNG = "image/png" 8 | 9 | 10 | MAGIC_BYTES = ( 11 | (MimeType.JPEG, bytes((0xFF, 0xD8, 0xFF))), 12 | (MimeType.PNG, bytes((0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))), 13 | ) 14 | 15 | 16 | def guess_mime_type(data: bytes) -> Optional[MimeType]: 17 | for mime_type, magic_bytes in MAGIC_BYTES: 18 | if data.startswith(magic_bytes): 19 | return mime_type 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | "setuptools-git" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "yandex-music-downloader" 10 | version = "3.5.4" 11 | description = "Загрузчик музыки с сервиса Яндекс.Музыка" 12 | requires-python = ">=3.9" 13 | readme = "README.md" 14 | dependencies = [ 15 | "yandex-music @ https://github.com/llistochek/yandex-music-api/archive/9623fbca7704f47766614efe51d66c9fd496714c.zip", 16 | "mutagen>=1.47.0", 17 | "StrEnum", 18 | "pycryptodome" 19 | ] 20 | 21 | [project.scripts] 22 | yandex-music-downloader = "ymd.cli:main" 23 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Переход на новую версию 2 | 3 | ## -> v3.2.0b0 4 | - При использовании аргумента `--unsafe-path` символы `/` и `\` 5 | заменяются на `_`. Если у вас в коллекции есть альбомы/треки/исполнители 6 | в названии которых есть данные символы, и вы используете флаг 7 | `--skip-existing`, эти элементы будут загружены дважды. 8 | 9 | ## v2 -> v3 10 | - Для авторизации используйте аргумент `--token`. Аргументы `--browser`, 11 | `--user-agent`, `--cookies-path` больше не являются валидными, 12 | авторизация через cookies невозможна. 13 | - Удален аргумент `--hq`. Используйте `--quality 1`. 14 | - Удален аргумент `--disable-ipv6` 15 | - Задержка между запросами по умолчанию равна 0. Вы можете убрать 16 | аргумент `--delay`, на мобильный токен не распространяются жесткие 17 | ограничения на количество запросов. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Lev Plyusnin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /ymd/api.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | import random 5 | import time 6 | import typing 7 | from dataclasses import dataclass 8 | from enum import Enum, auto 9 | 10 | from Crypto.Cipher import AES 11 | from strenum import StrEnum 12 | from yandex_music import Client, Track 13 | from yandex_music.utils.sign_request import DEFAULT_SIGN_KEY 14 | 15 | 16 | class Container(Enum): 17 | FLAC = auto() 18 | MP3 = auto() 19 | MP4 = auto() 20 | 21 | 22 | class Codec(Enum): 23 | FLAC = auto() 24 | MP3 = auto() 25 | AAC = auto() 26 | 27 | 28 | @dataclass 29 | class FileFormat: 30 | container: Container 31 | codec: Codec 32 | 33 | 34 | FILE_FORMAT_MAPPING = { 35 | "flac": FileFormat(Container.FLAC, Codec.FLAC), 36 | "flac-mp4": FileFormat(Container.MP4, Codec.FLAC), 37 | "mp3": FileFormat(Container.MP3, Codec.MP3), 38 | "aac": FileFormat(Container.MP4, Codec.AAC), 39 | "he-aac": FileFormat(Container.MP4, Codec.AAC), 40 | "aac-mp4": FileFormat(Container.MP4, Codec.AAC), 41 | "he-aac-mp4": FileFormat(Container.MP4, Codec.AAC), 42 | } 43 | 44 | 45 | class ApiTrackQuality(StrEnum): 46 | LOW = "lq" 47 | NORMAL = "nq" 48 | LOSSLESS = "lossless" 49 | 50 | 51 | @dataclass 52 | class CustomDownloadInfo: 53 | quality: str 54 | file_format: FileFormat 55 | urls: list[str] 56 | decryption_key: str 57 | bitrate: int 58 | 59 | 60 | def get_download_info(track: Track, quality: ApiTrackQuality) -> CustomDownloadInfo: 61 | client = track.client 62 | assert client 63 | timestamp = int(time.time()) 64 | params = { 65 | "ts": timestamp, 66 | "trackId": track.id, 67 | "quality": quality, 68 | "codecs": ",".join(FILE_FORMAT_MAPPING.keys()), 69 | "transports": "encraw", 70 | } 71 | hmac_sign = hmac.new( 72 | DEFAULT_SIGN_KEY.encode(), 73 | "".join(str(e) for e in params.values()).replace(",", "").encode(), 74 | hashlib.sha256, 75 | ) 76 | sign = base64.b64encode(hmac_sign.digest()).decode()[:-1] 77 | params["sign"] = sign 78 | 79 | resp = client.request.get( 80 | "https://api.music.yandex.net/get-file-info", params=params 81 | ) 82 | resp = typing.cast(dict, resp) 83 | e = resp["download_info"] 84 | raw_codec = e["codec"] 85 | file_format = FILE_FORMAT_MAPPING.get(raw_codec) 86 | if file_format is None: 87 | raise ValueError(f"Unknown codec: {raw_codec}") 88 | return CustomDownloadInfo( 89 | quality=e["quality"], 90 | file_format=file_format, 91 | urls=e["urls"], 92 | bitrate=e["bitrate"], 93 | decryption_key=e.get("key"), 94 | ) 95 | 96 | 97 | def download_track(client: Client, download_info: CustomDownloadInfo) -> bytes: 98 | data = client.request.retrieve(random.choice(download_info.urls)) 99 | if decryption_key := download_info.decryption_key: 100 | data = decrypt_data(data, decryption_key) 101 | return data 102 | 103 | 104 | def decrypt_data(data: bytes, key: str) -> bytes: 105 | aes = AES.new( 106 | key=bytes.fromhex(key), 107 | nonce=bytes(12), 108 | mode=AES.MODE_CTR, 109 | ) 110 | 111 | return aes.decrypt(data) 112 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yandex-music-downloader 2 | 3 | > Внимание! В версии v3 был изменен способ авторизации и некоторые 4 | > аргументы. Смотрите [MIGRATION.md](MIGRATION.md) для получения информации 5 | > об изменениях 6 | 7 | ## Содержание 8 | 1. [О программе](#О-программе) 9 | 2. [Установка](#Установка) 10 | 3. [Получение данных для авторизации](#Получение-данных-для-авторизации) 11 | 4. [Примеры использования](#Примеры-использования) 12 | 5. [Использование](#Использование) 13 | 6. [Уровни совместимости](#Уровни-совместимости) 14 | 7. [Спасибо](#Спасибо) 15 | 8. [Дисклеймер](#Дисклеймер) 16 | 17 | ## О программе 18 | Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download). 19 | 20 | ### Возможности 21 | - Возможность загрузки: 22 | - Всех треков исполнителя 23 | - Всех треков из альбома 24 | - Всех треков из плейлиста 25 | - Отдельного трека 26 | - Загрузка всех метаданных трека/альбома: 27 | - Номер трека 28 | - Номер диска 29 | - Название трека 30 | - Исполнитель 31 | - Дополнительные исполнители 32 | - Дата выпуска альбома 33 | - Обложка альбома 34 | - Название альбома 35 | - Текст песни (при использовании флага `--add-lyrics`) 36 | - Загрузка треков в lossless качестве 37 | - Поддержка паттерна для пути сохранения музыки 38 | 39 | ## Установка 40 | Для запуска скрипта требуется Python 3.9+ 41 | ``` 42 | pip install -U https://github.com/llistochek/yandex-music-downloader/archive/main.zip 43 | yandex-music-downloader --help 44 | ``` 45 | 46 | ## Получение данных для авторизации 47 | https://yandex-music.readthedocs.io/en/main/token.html 48 | 49 | ## Примеры использования 50 | Во всех примерах замените `<Токен>` на ваш токен. 51 | 52 | ### Скачать все треки [Arctic Monkeys](https://music.yandex.ru/artist/208167) в наилучшем качестве 53 | ``` 54 | yandex-music-downloader --token "<Токен>" --quality 2 --url "https://music.yandex.ru/artist/208167" 55 | ``` 56 | 57 | ### Скачать альбом [Nevermind](https://music.yandex.ru/album/294912) в высоком качестве, загружая тексты песен в формате LRC (с временными метками) 58 | ``` 59 | yandex-music-downloader --token "<Токен>" --quality 1 --lyrics-format lrc --url "https://music.yandex.ru/album/294912" 60 | ``` 61 | 62 | ### Скачать трек [Seven Nation Army](https://music.yandex.ru/album/11644078/track/6705392) 63 | ``` 64 | yandex-music-downloader --token "<Токен>" --url "https://music.yandex.ru/album/11644078/track/6705392" 65 | ``` 66 | 67 | ## Использование 68 | ``` 69 | usage: yandex-music-downloader [-h] [--quality <Качество>] [--skip-existing] 70 | [--lyrics-format {none,text,lrc}] 71 | [--embed-cover] 72 | [--cover-resolution <Разрешение обложки>] 73 | [--delay <Задержка>] [--stick-to-artist] 74 | [--only-music] 75 | [--compatibility-level <Уровень совместимости>] 76 | [--timeout <Время ожидания>] 77 | [--tries <Количество попыток>] 78 | [--retry-delay <Задержка>] 79 | (--artist-id | --album-id | --track-id | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL) 80 | [--unsafe-path] [--dir <Папка>] 81 | [--path-pattern <Паттерн>] --token <Токен> 82 | 83 | Загрузчик музыки с сервиса Яндекс.Музыка 84 | 85 | options: 86 | -h, --help show this help message and exit 87 | 88 | Общие параметры: 89 | --quality <Качество> Качество трека: 90 | 0 - Низкое (AAC 64kbps) 91 | 1 - Оптимальное (AAC 192kbps) 92 | 2 - Лучшее (FLAC) 93 | (по умолчанию: 0) 94 | --skip-existing Пропускать уже загруженные треки 95 | --lyrics-format {none,text,lrc} 96 | Формат текста песни (по умолчанию: none) 97 | --embed-cover Встраивать обложку в аудиофайл 98 | --cover-resolution <Разрешение обложки> 99 | Разрешение обложки (в пикселях). Передайте "original" для загрузки в оригинальном (наилучшем) разрешении (по умолчанию: 400) 100 | --delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 0) 101 | --stick-to-artist Загружать альбомы, созданные только данным исполнителем 102 | --only-music Загружать только музыкальные альбомы (пропускать подкасты и аудиокниги) 103 | --compatibility-level <Уровень совместимости> 104 | Уровень совместимости, от 0 до 1. См. README для подробного описания (по умолчанию: 1) 105 | 106 | Сетевые параметры: 107 | --timeout <Время ожидания> 108 | Время ожидания ответа от сервера, в секундах. Увеличьте если возникают сетевые ошибки (по умолчанию: 20) 109 | --tries <Количество попыток> 110 | Количество попыток при возникновении сетевых ошибок. 0 - бесконечное количество попыток (по умолчанию: 20) 111 | --retry-delay <Задержка> 112 | Задержка между повторными запросами при сетевых ошибках (по умолчанию: 5) 113 | 114 | ID: 115 | --artist-id 116 | --album-id 117 | --track-id 118 | --playlist-id <владелец плейлиста>/<тип плейлиста> 119 | -u URL, --url URL URL исполнителя/альбома/трека/плейлиста 120 | 121 | Указание пути: 122 | --unsafe-path Не очищать путь от недопустимых символов 123 | --dir <Папка> Папка для загрузки музыки (по умолчанию: .) 124 | --path-pattern <Паттерн> 125 | Поддерживает следующие заполнители: #number, #track-artist, #album-artist, #title, #album, #year, #artist-id, #album-id, #track-id, #number-padded (по умолчанию: #album-artist/#album/#number - #title) 126 | 127 | Авторизация: 128 | --token <Токен> Токен для авторизации. См. README для способов получения 129 | ``` 130 | 131 | ## Уровни совместимости 132 | Уровень совместимости позволяет отойти от стандарта тегов, которого 133 | придерживается библиотека mutagen. Сделано это для поддержки большего 134 | количества музыкальных плееров. Ниже подробно описаны все уровни. 135 | 136 | ### 0 137 | Стандартные теги mutagen. 138 | 139 | ### 1 140 | Затрагиваемые форматы: `m4a` 141 | 142 | - Теги с несколькими значениями (`\xa9ART` и `aART`) устанвливаются с 143 | разделителем `;`. Пример: `Artist1; Artist2; Artist3` 144 | 145 | 146 | ## Спасибо 147 | - Разработчикам проекта [yandex-music-api](https://github.com/MarshalX/yandex-music-api) 148 | - @ArtemBay за [скрипт](https://github.com/MarshalX/yandex-music-api/issues/656#issuecomment-2306542725) получения ссылки на загрузку в lossless качестве 149 | - @keltecc за [метод дешифрования файлов](https://github.com/llistochek/yandex-music-downloader/issues/112#issuecomment-2812535100) 150 | - @leowerd за [корректные имена исполнителей](https://github.com/llistochek/yandex-music-downloader/issues/93#issuecomment-2960210879) при загрузке сборников 151 | 152 | 153 | ## Дисклеймер 154 | Данный проект является независимой разработкой и никак не связан с компанией Яндекс. 155 | -------------------------------------------------------------------------------- /ymd/cli.py: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | import argparse 3 | import itertools 4 | import logging 5 | import re 6 | import time 7 | import typing 8 | from argparse import ArgumentTypeError 9 | from collections.abc import Callable, Generator, Iterable 10 | from pathlib import Path 11 | from typing import Optional, Union 12 | from urllib.parse import urlparse 13 | 14 | from yandex_music import Album, Playlist, Track 15 | 16 | from ymd import core 17 | 18 | DEFAULT_DELAY = 0 19 | 20 | TRACK_RE = re.compile(r"track/(\d+)") 21 | ALBUM_RE = re.compile(r"album/(\d+)$") 22 | ARTIST_RE = re.compile(r"artist/(\d+)$") 23 | PLAYLIST_RE = re.compile(r"([\w\-._@]+)/playlists/(\d+)$") 24 | 25 | FETCH_PAGE_SIZE = 10 26 | 27 | logger = logging.getLogger("yandex-music-downloader") 28 | 29 | 30 | def show_default(text: Optional[str] = None) -> str: 31 | default = "по умолчанию: %(default)s" 32 | if text is None: 33 | return default 34 | return f"{text} ({default})" 35 | 36 | 37 | def checked_int_arg( 38 | min_value: int, max_value: Optional[int] = None 39 | ) -> Callable[[str], int]: 40 | def func(astr: str) -> int: 41 | aint = int(astr) 42 | if aint >= min_value and (max_value is None or aint <= max_value): 43 | return aint 44 | error_text = f"Значение должен быть >= {min_value}" 45 | if max_value is not None: 46 | error_text += f" и <= {max_value}" 47 | raise ArgumentTypeError(error_text) 48 | 49 | return func 50 | 51 | 52 | def cover_resolution_arg(astr: str) -> int: 53 | if astr == "original": 54 | return -1 55 | return checked_int_arg(100)(astr) 56 | 57 | 58 | def lyrics_format_arg(astr: str) -> core.LyricsFormat: 59 | try: 60 | return core.LyricsFormat(astr) 61 | except ValueError: 62 | raise ArgumentTypeError(f"Допустимые значения: {','.join(core.LyricsFormat)}") 63 | 64 | 65 | def main(): 66 | parser = argparse.ArgumentParser( 67 | description="Загрузчик музыки с сервиса Яндекс.Музыка", 68 | formatter_class=argparse.RawTextHelpFormatter, 69 | ) 70 | 71 | common_group = parser.add_argument_group("Общие параметры") 72 | common_group.add_argument( 73 | "--quality", 74 | metavar="<Качество>", 75 | default=0, 76 | type=checked_int_arg(0, 2), 77 | help="Качество трека:\n0 - Низкое (AAC 64kbps)\n1 - Оптимальное (AAC 192kbps)\n2 - Лучшее (FLAC)\n(по умолчанию: %(default)s)", 78 | ) 79 | common_group.add_argument( 80 | "--skip-existing", action="store_true", help="Пропускать уже загруженные треки" 81 | ) 82 | common_group.add_argument( 83 | "--lyrics-format", 84 | type=lyrics_format_arg, 85 | default=core.LyricsFormat.NONE, 86 | help=show_default("Формат текста песни"), 87 | choices=core.LyricsFormat, 88 | ) 89 | common_group.add_argument( 90 | "--add-lyrics", action="store_true", help=argparse.SUPPRESS 91 | ) 92 | common_group.add_argument( 93 | "--embed-cover", action="store_true", help="Встраивать обложку в аудиофайл" 94 | ) 95 | common_group.add_argument( 96 | "--cover-resolution", 97 | default=core.DEFAULT_COVER_RESOLUTION, 98 | metavar="<Разрешение обложки>", 99 | type=cover_resolution_arg, 100 | help=show_default( 101 | 'Разрешение обложки (в пикселях). Передайте "original" для загрузки в оригинальном (наилучшем) разрешении' 102 | ), 103 | ) 104 | common_group.add_argument( 105 | "--delay", 106 | default=DEFAULT_DELAY, 107 | metavar="<Задержка>", 108 | type=checked_int_arg(0), 109 | help=show_default("Задержка между запросами, в секундах"), 110 | ) 111 | common_group.add_argument( 112 | "--stick-to-artist", 113 | action="store_true", 114 | help="Загружать альбомы, созданные только данным исполнителем", 115 | ) 116 | common_group.add_argument( 117 | "--only-music", 118 | action="store_true", 119 | help="Загружать только музыкальные альбомы (пропускать подкасты и аудиокниги)", 120 | ) 121 | common_group.add_argument( 122 | "--compatibility-level", 123 | metavar="<Уровень совместимости>", 124 | default=1, 125 | type=checked_int_arg( 126 | core.MIN_COMPATIBILITY_LEVEL, core.MAX_COMPATIBILITY_LEVEL 127 | ), 128 | help=show_default( 129 | f"Уровень совместимости, от {core.MIN_COMPATIBILITY_LEVEL} до {core.MAX_COMPATIBILITY_LEVEL}. См. README для подробного описания" 130 | ), 131 | ) 132 | 133 | network_group = parser.add_argument_group("Сетевые параметры") 134 | network_group.add_argument( 135 | "--timeout", 136 | metavar="<Время ожидания>", 137 | default=20, 138 | type=checked_int_arg(1), 139 | help=show_default( 140 | "Время ожидания ответа от сервера, в секундах. Увеличьте если возникают сетевые ошибки" 141 | ), 142 | ) 143 | network_group.add_argument( 144 | "--tries", 145 | metavar="<Количество попыток>", 146 | default=20, 147 | type=checked_int_arg(0), 148 | help=show_default( 149 | "Количество попыток при возникновении сетевых ошибок. 0 - бесконечное количество попыток" 150 | ), 151 | ) 152 | network_group.add_argument( 153 | "--retry-delay", 154 | metavar="<Задержка>", 155 | default=5, 156 | type=checked_int_arg(0), 157 | help=show_default("Задержка между повторными запросами при сетевых ошибках"), 158 | ) 159 | common_group.add_argument("--debug", action="store_true", help=argparse.SUPPRESS) 160 | 161 | id_group_meta = parser.add_argument_group("ID") 162 | id_group = id_group_meta.add_mutually_exclusive_group(required=True) 163 | id_group.add_argument("--artist-id", metavar="") 164 | id_group.add_argument("--album-id", metavar="") 165 | id_group.add_argument("--track-id", metavar="") 166 | id_group.add_argument( 167 | "--playlist-id", 168 | metavar="<владелец плейлиста>/<тип плейлиста>", 169 | ) 170 | id_group.add_argument("-u", "--url", help="URL исполнителя/альбома/трека/плейлиста") 171 | 172 | path_group = parser.add_argument_group("Указание пути") 173 | path_group.add_argument( 174 | "--unsafe-path", 175 | action="store_true", 176 | help="Не очищать путь от недопустимых символов", 177 | ) 178 | path_group.add_argument( 179 | "--dir", 180 | default=".", 181 | metavar="<Папка>", 182 | help=show_default("Папка для загрузки музыки"), 183 | type=Path, 184 | ) 185 | path_group.add_argument( 186 | "--path-pattern", 187 | default=core.DEFAULT_PATH_PATTERN, 188 | metavar="<Паттерн>", 189 | type=Path, 190 | help=show_default( 191 | "Поддерживает следующие заполнители:" 192 | " #number, #track-artist, #album-artist, #title," 193 | " #album, #year, #artist-id, #album-id, #track-id, #number-padded" 194 | ), 195 | ) 196 | 197 | auth_group = parser.add_argument_group("Авторизация") 198 | auth_group.add_argument( 199 | "--token", 200 | required=True, 201 | metavar="<Токен>", 202 | help="Токен для авторизации. См. README для способов получения", 203 | ) 204 | 205 | args = parser.parse_args() 206 | 207 | logging.basicConfig( 208 | format="%(asctime)s |%(levelname)s| %(name)s: %(message)s", 209 | datefmt="%H:%M:%S", 210 | level=logging.DEBUG if args.debug else logging.ERROR, 211 | ) 212 | 213 | if args.add_lyrics: 214 | print( 215 | "Аргумент --add-lyrics устарел и будет удален в будущем. Используйте --lyrics-format" 216 | ) 217 | args.lyrics_format = core.LyricsFormat.TEXT 218 | 219 | if args.url is not None: 220 | parsed_url = urlparse(args.url) 221 | path = parsed_url.path 222 | if match := ARTIST_RE.search(path): 223 | args.artist_id = match.group(1) 224 | elif match := ALBUM_RE.search(path): 225 | args.album_id = match.group(1) 226 | elif match := TRACK_RE.search(path): 227 | args.track_id = match.group(1) 228 | elif match := PLAYLIST_RE.search(path): 229 | args.playlist_id = match.group(1) + "/" + match.group(2) 230 | else: 231 | print("Параметер url указан в неверном формате") 232 | return 1 233 | 234 | client = core.init_client( 235 | token=args.token, 236 | timeout=args.timeout, 237 | max_try_count=args.tries, 238 | retry_delay=args.retry_delay, 239 | ) 240 | result_tracks: Iterable[Track] 241 | 242 | def album_tracks_gen(album_ids: Iterable[Union[int, str]]) -> Generator[Track]: 243 | for album_id in album_ids: 244 | if full_album := client.albums_with_tracks(album_id): 245 | if volumes := full_album.volumes: 246 | yield from itertools.chain.from_iterable(volumes) 247 | 248 | total_track_count = None 249 | if args.artist_id is not None: 250 | 251 | def filter_album(album: Album) -> bool: 252 | title = album.title 253 | if album.id is None or not album.available: 254 | print(f'Альбом "{title}" не доступен для скачивания') 255 | elif args.only_music and album.meta_type != "music": 256 | print(f'Альбом "{title}" пропущен т.к. не является музыкальным') 257 | elif args.stick_to_artist and album.artists[0].id != int(args.artist_id): 258 | print(f'Альбом "{title}" пропущен из-за флага --stick-to-artist') 259 | else: 260 | return True 261 | return False 262 | 263 | def albums_id_gen() -> Generator[int]: 264 | has_next = True 265 | page = 0 266 | while has_next: 267 | if albums_info := client.artists_direct_albums(args.artist_id, page): 268 | for album in albums_info.albums: 269 | if filter_album(album): 270 | assert album.id 271 | yield album.id 272 | else: 273 | nonlocal total_track_count 274 | if ( 275 | track_count := album.track_count 276 | ) and total_track_count is not None: 277 | total_track_count -= track_count 278 | else: 279 | break 280 | if pager := albums_info.pager: 281 | page = pager.page + 1 282 | has_next = pager.per_page * page < pager.total 283 | else: 284 | break 285 | 286 | result_tracks = album_tracks_gen(albums_id_gen()) 287 | artist = client.artists(args.artist_id)[0] 288 | if counts := artist.counts: 289 | total_track_count = counts.tracks 290 | 291 | elif args.album_id is not None: 292 | result_tracks = album_tracks_gen((args.album_id,)) 293 | if album := client.albums_with_tracks(args.album_id): 294 | total_track_count = album.track_count 295 | elif args.track_id is not None: 296 | track = client.tracks(args.track_id) 297 | result_tracks = track 298 | total_track_count = 1 299 | elif args.playlist_id is not None: 300 | user, kind = args.playlist_id.split("/") 301 | playlist = typing.cast(Playlist, client.users_playlists(kind, user)) 302 | total_track_count = playlist.track_count 303 | 304 | def playlist_tracks_gen() -> Generator[Track]: 305 | tracks = playlist.fetch_tracks() 306 | for i in range(0, len(tracks), FETCH_PAGE_SIZE): 307 | yield from client.tracks( 308 | [track.id for track in tracks[i : i + FETCH_PAGE_SIZE]] 309 | ) 310 | 311 | result_tracks = playlist_tracks_gen() 312 | else: 313 | raise ValueError("Invalid ID argument") 314 | 315 | track_counter = 0 316 | progress_status = "" 317 | covers_cache = {} 318 | for track in result_tracks: 319 | if total_track_count: 320 | track_counter += 1 321 | progress_status = f"[{track_counter}/{total_track_count}] " 322 | 323 | if not track.available: 324 | print(f"{progress_status}Трек {track.title} не доступен для скачивания") 325 | continue 326 | 327 | save_path = args.dir / core.prepare_base_path( 328 | args.path_pattern, 329 | track, 330 | args.unsafe_path, 331 | ) 332 | if args.skip_existing: 333 | if any( 334 | Path(str(save_path) + s).is_file() for s in core.AUDIO_FILE_SUFFIXES 335 | ): 336 | continue 337 | 338 | save_dir = save_path.parent 339 | if not save_dir.is_dir(): 340 | save_dir.mkdir(parents=True) 341 | 342 | downloadable = core.to_downloadable_track(track, args.quality, save_path) 343 | bitrate = downloadable.download_info.bitrate 344 | format_info = "[" + downloadable.download_info.file_format.codec.name 345 | if bitrate > 0: 346 | format_info += f" {bitrate}kbps" 347 | format_info += "]" 348 | print(f"{progress_status}{format_info} Загружается {downloadable.path}") 349 | core.download_track( 350 | track_info=downloadable, 351 | lyrics_format=args.lyrics_format, 352 | embed_cover=args.embed_cover, 353 | cover_resolution=args.cover_resolution, 354 | covers_cache=covers_cache, 355 | compatibility_level=args.compatibility_level, 356 | ) 357 | if args.delay > 0: 358 | time.sleep(args.delay) 359 | -------------------------------------------------------------------------------- /ymd/core.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import hashlib 3 | import re 4 | import time 5 | import typing 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | from enum import IntEnum, auto 9 | from pathlib import Path 10 | from typing import Optional, Union 11 | 12 | import mutagen 13 | from mutagen.flac import FLAC, Picture 14 | from mutagen.id3._frames import ( 15 | APIC, 16 | TALB, 17 | TCON, 18 | TDRC, 19 | TIT2, 20 | TPE1, 21 | TPE2, 22 | TPOS, 23 | TRCK, 24 | USLT, 25 | WOAF, 26 | ) 27 | from mutagen.id3._specs import ID3TimeStamp, PictureType 28 | from mutagen.mp3 import MP3 29 | from mutagen.mp4 import MP4, MP4Cover 30 | from strenum import LowercaseStrEnum 31 | from yandex_music import ( 32 | Album, 33 | Client, 34 | Track, 35 | YandexMusicModel, 36 | ) 37 | from yandex_music.exceptions import NetworkError 38 | 39 | from ymd import api 40 | from ymd.api import ( 41 | ApiTrackQuality, 42 | Container, 43 | CustomDownloadInfo, 44 | get_download_info, 45 | ) 46 | from ymd.mime_utils import MimeType, guess_mime_type 47 | 48 | UNSAFE_PATH_CLEAR_RE = re.compile(r"[/\\]+") 49 | SAFE_PATH_CLEAR_RE = re.compile(r"([^\w\-\'() ]|^\s+|\s+$)") 50 | 51 | DEFAULT_PATH_PATTERN = Path("#album-artist", "#album", "#number - #title") 52 | DEFAULT_COVER_RESOLUTION = 400 53 | 54 | MIN_COMPATIBILITY_LEVEL = 0 55 | MAX_COMPATIBILITY_LEVEL = 1 56 | 57 | AUDIO_FILE_SUFFIXES = {".mp3", ".flac", ".m4a"} 58 | TEMPORARY_FILE_NAME_TEMPLATE = ".yandex-music-downloader.{}.tmp" 59 | MAX_FILE_NAME_LENGTH_WITHOUT_SUFFIX = 255 - max( 60 | len(suffix) for suffix in AUDIO_FILE_SUFFIXES 61 | ) 62 | 63 | 64 | class CoreTrackQuality(IntEnum): 65 | LOW = 0 66 | NORMAL = auto() 67 | LOSSLESS = auto() 68 | 69 | 70 | class LyricsFormat(LowercaseStrEnum): 71 | NONE = auto() 72 | TEXT = auto() 73 | LRC = auto() 74 | 75 | 76 | CONTAINER_MUTAGEN_MAPPING: dict[Container, type[mutagen.FileType]] = { # type: ignore 77 | Container.MP3: MP3, 78 | Container.FLAC: FLAC, 79 | Container.MP4: MP4, 80 | } 81 | 82 | 83 | @dataclass 84 | class DownloadableTrack: 85 | download_info: CustomDownloadInfo 86 | path: Path 87 | track: Track 88 | 89 | 90 | @dataclass 91 | class AlbumCover: 92 | data: bytes 93 | mime_type: MimeType 94 | 95 | 96 | def init_client( 97 | token: str, timeout: int, max_try_count: int, retry_delay: int 98 | ) -> Client: 99 | assert timeout > 0 100 | assert max_try_count >= 0 101 | assert retry_delay >= 0 102 | 103 | client = Client(token) 104 | client.request.set_timeout(timeout) 105 | 106 | original_wrapper = client.request._request_wrapper 107 | 108 | def retry_wrapper(*args, **kwargs): 109 | try_count = 0 110 | while True: 111 | try: 112 | return original_wrapper(*args, **kwargs) 113 | except NetworkError as error: 114 | if max_try_count == 0 or try_count < max_try_count: 115 | try_count += 1 116 | time.sleep(retry_delay) 117 | continue 118 | raise error 119 | 120 | client.request._request_wrapper = retry_wrapper 121 | return client.init() 122 | 123 | 124 | def full_title(obj: YandexMusicModel) -> str: 125 | result = obj["title"] 126 | if result is None: 127 | return "" 128 | if version := obj["version"]: 129 | result += f" ({version})" 130 | return result 131 | 132 | 133 | def prepare_base_path( 134 | path_pattern: Path, track: Track, unsafe_path: bool = False 135 | ) -> Path: 136 | path_str = str(path_pattern) 137 | album = None 138 | album_artist = None 139 | track_artist = None 140 | track_position = None 141 | if albums := track.albums: 142 | album = albums[0] 143 | track_position = album.track_position 144 | if artists := album.artists: 145 | album_artist = artists[0] 146 | if artists := track.artists: 147 | track_artist = artists[0] 148 | repl_dict: dict[str, Union[str, int, None]] = { 149 | "#number-padded": str(track_position.index).zfill(len(str(album.track_count))) 150 | if track_position and album 151 | else None, 152 | "#album-artist": album_artist.name if album_artist else None, 153 | "#track-artist": track_artist.name if track_artist else None, 154 | "#artist-id": track_artist.id if track_artist else None, 155 | "#album-id": album.id if album else None, 156 | "#track-id": track.id, 157 | "#number": track_position.index if track_position else None, 158 | "#title": full_title(track), 159 | "#album": full_title(album) if album else None, 160 | "#year": album.year if album else None, 161 | } 162 | for placeholder, replacement in repl_dict.items(): 163 | replacement = str(replacement) 164 | if not unsafe_path: 165 | clear_re = SAFE_PATH_CLEAR_RE 166 | else: 167 | clear_re = UNSAFE_PATH_CLEAR_RE 168 | replacement = clear_re.sub("_", replacement) 169 | path_str = path_str.replace(placeholder, replacement) 170 | path = Path(path_str) 171 | trimmed_parts = [ 172 | part 173 | if len(part) <= MAX_FILE_NAME_LENGTH_WITHOUT_SUFFIX 174 | else part[:MAX_FILE_NAME_LENGTH_WITHOUT_SUFFIX] 175 | for part in path.parts 176 | ] 177 | return Path(*trimmed_parts) 178 | 179 | 180 | def set_tags( 181 | path: Path, 182 | track: Track, 183 | container: Container, 184 | lyrics: Optional[str], 185 | album_cover: Optional[AlbumCover], 186 | compatibility_level: int, 187 | ) -> None: 188 | file_type = CONTAINER_MUTAGEN_MAPPING.get(container) 189 | if file_type is None: 190 | raise ValueError(f"Unknown container: {container}") 191 | 192 | tag = file_type(path) 193 | album = track.albums[0] if track.albums else Album() 194 | album_title = full_title(album) 195 | track_title = full_title(track) 196 | track_artists = [a.name for a in track.artists if a.name] 197 | album_artists = [a.name for a in album.artists if a.name] 198 | genre = None 199 | if album.genre: 200 | genre = album.genre 201 | track_number = None 202 | disc_number = None 203 | if position := album.track_position: 204 | track_number = position.index 205 | disc_number = position.volume 206 | iso8601_release_date = None 207 | release_year: Optional[str] = None 208 | if album.release_date is not None: 209 | iso8601_release_date = dt.datetime.fromisoformat(album.release_date).astimezone( 210 | dt.timezone.utc 211 | ) 212 | release_year = str(iso8601_release_date.year) 213 | iso8601_release_date = iso8601_release_date.strftime("%Y-%m-%d %H:%M:%S") 214 | if year := album.year: 215 | release_year = str(year) 216 | track_url = f"https://music.yandex.ru/album/{album.id}/track/{track.id}" 217 | 218 | if isinstance(tag, MP3): 219 | tag["TIT2"] = TIT2(encoding=3, text=track_title) 220 | tag["TALB"] = TALB(encoding=3, text=album_title) 221 | tag["TPE1"] = TPE1(encoding=3, text=track_artists) 222 | tag["TPE2"] = TPE2(encoding=3, text=album_artists) 223 | 224 | if tdrc_text := iso8601_release_date or release_year: 225 | tag["TDRC"] = TDRC(encoding=3, text=[ID3TimeStamp(tdrc_text)]) 226 | if track_number: 227 | tag["TRCK"] = TRCK(encoding=3, text=str(track_number)) 228 | if disc_number: 229 | tag["TPOS"] = TPOS(encoding=3, text=str(disc_number)) 230 | if genre: 231 | tag["TCON"] = TCON(encoding=3, text=genre) 232 | 233 | if lyrics: 234 | tag["USLT"] = USLT(encoding=3, text=lyrics) 235 | if album_cover: 236 | tag["APIC"] = APIC( 237 | encoding=3, 238 | mime=album_cover.mime_type.value, 239 | type=3, 240 | data=album_cover.data, 241 | ) 242 | 243 | tag["WOAF"] = WOAF( 244 | encoding=3, 245 | text=track_url, 246 | ) 247 | elif isinstance(tag, MP4): 248 | tag["\xa9nam"] = track_title 249 | tag["\xa9alb"] = album_title 250 | artists_value = track_artists 251 | album_artists_value = album_artists 252 | if compatibility_level == 1: 253 | artists_value = "; ".join(track_artists) 254 | album_artists_value = "; ".join(album_artists) 255 | tag["\xa9ART"] = artists_value 256 | tag["aART"] = album_artists_value 257 | 258 | if iso8601_release_date is not None: 259 | tag["\xa9day"] = iso8601_release_date 260 | elif release_year is not None: 261 | tag["\xa9day"] = release_year 262 | if track_number: 263 | tag["trkn"] = [(track_number, 0)] 264 | if disc_number: 265 | tag["disk"] = [(disc_number, 0)] 266 | if genre: 267 | tag["\xa9gen"] = genre 268 | 269 | if lyrics: 270 | tag["\xa9lyr"] = lyrics 271 | if album_cover: 272 | mime_mp4_dict = { 273 | MimeType.JPEG: MP4Cover.FORMAT_JPEG, 274 | MimeType.PNG: MP4Cover.FORMAT_PNG, 275 | } 276 | mp4_image_format = mime_mp4_dict.get(album_cover.mime_type) 277 | if mp4_image_format is None: 278 | raise RuntimeError("Unsupported cover type") 279 | tag["covr"] = [MP4Cover(album_cover.data, imageformat=mp4_image_format)] 280 | tag["\xa9cmt"] = track_url 281 | elif isinstance(tag, FLAC): 282 | tag["title"] = track_title 283 | tag["album"] = album_title 284 | tag["artist"] = track_artists 285 | tag["albumartist"] = album_artists 286 | 287 | if date_text := iso8601_release_date or release_year: 288 | tag["date"] = date_text 289 | if track_number: 290 | tag["tracknumber"] = str(track_number) 291 | if disc_number: 292 | tag["discnumber"] = str(disc_number) 293 | if genre: 294 | tag["genre"] = genre 295 | 296 | if lyrics: 297 | tag["lyrics"] = lyrics 298 | if album_cover is not None: 299 | pic = Picture() 300 | pic.type = PictureType.COVER_FRONT 301 | pic.data = album_cover.data 302 | pic.mime = album_cover.mime_type.value 303 | tag.add_picture(pic) 304 | tag["comment"] = track_url 305 | else: 306 | raise RuntimeError("Unknown file format") 307 | 308 | tag.save() 309 | 310 | 311 | def download_track( 312 | track_info: DownloadableTrack, 313 | cover_resolution: int = DEFAULT_COVER_RESOLUTION, 314 | lyrics_format: LyricsFormat = LyricsFormat.NONE, 315 | embed_cover: bool = False, 316 | covers_cache: Optional[dict[int, AlbumCover]] = None, 317 | compatibility_level: int = 1, 318 | ): 319 | if embed_cover and covers_cache is None: 320 | raise RuntimeError("covers_cache isn't provided") 321 | covers_cache = typing.cast(dict[int, AlbumCover], covers_cache) 322 | target_path = track_info.path 323 | track = track_info.track 324 | client = typing.cast(Client, track.client) 325 | assert client 326 | 327 | text_lyrics = None 328 | if lyrics_format != LyricsFormat.NONE and (lyrics_info := track.lyrics_info): 329 | if lyrics_format == LyricsFormat.LRC and lyrics_info.has_available_sync_lyrics: 330 | lrc_path = target_path.with_suffix(".lrc") 331 | if not lrc_path.is_file() and ( 332 | track_lyrics := track.get_lyrics(format_="LRC") 333 | ): 334 | lyrics = track_lyrics.fetch_lyrics() 335 | write_via_temporary_file(lyrics.encode("utf-8"), lrc_path) 336 | elif lyrics_info.has_available_text_lyrics: 337 | if track_lyrics := track.get_lyrics(format_="TEXT"): 338 | text_lyrics = track_lyrics.fetch_lyrics() 339 | 340 | cover = None 341 | if track.cover_uri is not None: 342 | if cover_resolution == -1: 343 | cover_size = "orig" 344 | else: 345 | cover_size = f"{cover_resolution}x{cover_resolution}" 346 | cover_bytes = track.download_cover_bytes(size=cover_size) 347 | mime_type = guess_mime_type(cover_bytes) 348 | if mime_type is None: 349 | raise RuntimeError("Unknown cover mime type") 350 | album_cover = AlbumCover(data=cover_bytes, mime_type=mime_type) 351 | if embed_cover: 352 | album = track.albums[0] if track.albums else Album() 353 | if album.id and (cached_cover := covers_cache.get(album.id)): 354 | cover = cached_cover 355 | else: 356 | if album.id: 357 | cover = covers_cache[album.id] = album_cover 358 | else: 359 | mime_suffix_dict = {MimeType.JPEG: ".jpg", MimeType.PNG: ".png"} 360 | file_suffix = mime_suffix_dict.get(album_cover.mime_type) 361 | if file_suffix is None: 362 | raise RuntimeError("Unknown mime type") 363 | cover_path = target_path.parent / ("cover" + file_suffix) 364 | if not cover_path.is_file(): 365 | write_via_temporary_file(album_cover.data, cover_path) 366 | 367 | download_info = track_info.download_info 368 | track_data = api.download_track(client, download_info) 369 | 370 | write_via_temporary_file( 371 | track_data, 372 | target_path, 373 | temporary_file_hook=lambda tmp_path: set_tags( 374 | tmp_path, 375 | track, 376 | download_info.file_format.container, 377 | text_lyrics, 378 | cover, 379 | compatibility_level, 380 | ), 381 | ) 382 | 383 | 384 | def to_downloadable_track( 385 | track: Track, quality: CoreTrackQuality, base_path: Path 386 | ) -> DownloadableTrack: 387 | api_quality = ApiTrackQuality.NORMAL 388 | if quality == CoreTrackQuality.LOW: 389 | api_quality = ApiTrackQuality.LOW 390 | elif quality == CoreTrackQuality.NORMAL: 391 | api_quality = ApiTrackQuality.NORMAL 392 | elif quality == CoreTrackQuality.LOSSLESS: 393 | api_quality = ApiTrackQuality.LOSSLESS 394 | 395 | download_info = get_download_info(track, api_quality) 396 | container = download_info.file_format.container 397 | 398 | if container == Container.MP3: 399 | suffix = ".mp3" 400 | elif container == Container.MP4: 401 | suffix = ".m4a" 402 | elif container == Container.FLAC: 403 | suffix = ".flac" 404 | else: 405 | raise RuntimeError("Unknown codec") 406 | 407 | target_path = str(base_path) + suffix 408 | return DownloadableTrack( 409 | download_info=download_info, 410 | track=track, 411 | path=Path(target_path), 412 | ) 413 | 414 | 415 | def write_via_temporary_file( 416 | data: bytes, 417 | target_path: Path, 418 | temporary_file_hook: Optional[Callable[[Path], None]] = None, 419 | ) -> Path: 420 | target_name = hashlib.sha256(target_path.name.encode()).hexdigest() 421 | temporary_file = target_path.parent / ( 422 | TEMPORARY_FILE_NAME_TEMPLATE.format(target_name) 423 | ) 424 | try: 425 | temporary_file.write_bytes(data) 426 | if temporary_file_hook is not None: 427 | temporary_file_hook(temporary_file) 428 | except InterruptedError as e: 429 | temporary_file.unlink() 430 | raise e 431 | temporary_file.rename(target_path) 432 | return target_path 433 | --------------------------------------------------------------------------------