diff --git a/README.md b/README.md index ff86cb5..0bc4cee 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ 7. [Спасибо](#Спасибо) 8. [Дисклеймер](#Дисклеймер) - ## О программе Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download). @@ -40,57 +39,44 @@ yandex-music-downloader --help ``` ## Получение данных для авторизации -Войдите в свой Яндекс аккаунт, затем проделайте следующие шаги: +1. Войдите в свой Яндекс аккаунт. +2. Передайте вашего браузера в качестве аргумента `--browser` -### Для Google Chrome/Chromium -1. Перейдите на сайт Яндекс Музыки (https://music.yandex.ru) -2. Нажмите F12 -3. Выберите вкладку Application -4. Выберите пункт Cookies->https://music.yandex.ru -5. Скопируйте значение куки (кликните на значение куки 2 раза -> Ctrl+C): - - Куки `Session_id` - это аргумент `--session-id` - - -### Для Firefox -1. Перейдите на сайт Яндекс Музыки (https://music.yandex.ru) -2. Нажмите F12 -3. Выберите вкладку Storage -4. Выберите пункт Куки->https://music.yandex.ru -5. Скопируйте значение куки (кликните на значение куки 2 раза -> Ctrl+C): - - Куки `Session_id` - это аргумент `--session-id` +Если программа выдает ошибку при загрузке cookies - укажите путь к файлу +с cookies в качестве аргумента `--cookies-path`. Информацию о +расположении данного файла для вашего браузера вы можете найти в +интернете. Если что-то не получается - откройте issue. ## Примеры использования -Во всех примерах замените `` на значение куки `Session_id` +Во всех примерах замените `<браузер>` на название своего браузера (Для +получения допустимых значений запустите программу с флагом `--help`) ### Скачать все треки [Twenty One Pilots](https://music.yandex.ru/artist/792433) в высоком качестве ``` -python3 main.py --session-id "" --hq --url "https://music.yandex.ru/artist/792433" +python3 main.py --browser "<браузер>" --hq --url "https://music.yandex.ru/artist/792433" ``` ### Скачать альбом [Nevermind](https://music.yandex.ru/album/294912) в высоком качестве, загружая тексты песен ``` -python3 main.py --session-id "" --hq --add-lyrics --url "https://music.yandex.ru/album/294912" +python3 main.py --browser "<браузер>" --hq --add-lyrics --url "https://music.yandex.ru/album/294912" ``` ### Скачать трек [Seven Nation Army](https://music.yandex.ru/album/11644078/track/6705392) ``` -python3 main.py --session-id "" --url "https://music.yandex.ru/album/11644078/track/6705392" +python3 main.py --browser "<браузер>" --url "https://music.yandex.ru/album/11644078/track/6705392" ``` ## Использование ``` -usage: yandex-music-downloader [-h] [--hq] [--skip-existing] [--add-lyrics] - [--embed-cover] - [--cover-resolution <Разрешение обложки>] - [--delay <Задержка>] [--stick-to-artist] - [--only-music] [--enable-caching] - (--artist-id | --album-id | --track-id | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL) - [--unsafe-path] [--dir <Папка>] - [--path-pattern <Паттерн>] --session-id [--spravka ] - [--user-agent ] +usage: __main__.py [-h] [--hq] [--skip-existing] [--add-lyrics] + [--embed-cover] [--cover-resolution <Разрешение обложки>] + [--delay <Задержка>] [--stick-to-artist] [--only-music] + (--artist-id | --album-id | --track-id | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL) + [--unsafe-path] [--dir <Папка>] [--path-pattern <Паттерн>] + --browser BROWSER [--cookies-path COOKIES_PATH] + [--user-agent ] Загрузчик музыки с сервиса Яндекс.Музыка @@ -104,14 +90,11 @@ options: --embed-cover Встраивать обложку в .mp3 файл --cover-resolution <Разрешение обложки> по умолчанию: 400 - --delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 3) + --delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 1) --stick-to-artist Загружать альбомы, созданные только данным исполнителем --only-music Загружать только музыкальные альбомы (пропускать подкасты и аудиокниги) - --enable-caching Включить кэширование. Данная опция полезна при - нестабильном интернете. (кэш хранится в папке - /tmp/ymd) ID: --artist-id @@ -130,8 +113,14 @@ ID: artist/#album/#number - #title) Авторизация: - --session-id - --spravka + --browser BROWSER Браузер из которого будут извлечены данные для + авторизации. Укажите браузер через который вы входили + в Яндекс Музыку. Допустимые значения: chrome, opera, + opera_gx, firefox, edge, safari, chromium, vivaldi, + librewolf + --cookies-path COOKIES_PATH + Путь к файлу с cookies. Используйте если возникает + ошибка получения cookies --user-agent по умолчанию: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) @@ -139,16 +128,12 @@ ID: ``` ## Ошибка 400 Ниже приведена инструкция по устранению ошибки 400. -Перейдите на сайт Яндекс.Музыки, затем проделайте следующие шаги: -### Для Firefox -1. Нажмите на иконку замочка слева от адреса сайта -2. Нажмите `Clear cookies and site data` -3. Нажмите `Remove` -4. Перезагрузите страницу -5. Прорешайте капчу -6. Скопируйте значение куки `spravka` - передайте его как аргумент - `--spravka` +1. Перейдите на сайт Яндекс.Музыки +2. Прорешайте капчу +3. Готово, теперь вы можете скачивать треки без ошибок + +Если проблема сохраняется - откройте issue. ## Спасибо Разработчикам проекта [yandex-music-download](https://github.com/kaimi-io/yandex-music-download). Оттуда был взят [код хэширования](https://github.com/kaimi-io/yandex-music-download/blob/808443cb32be82e1f54b2f708884cb7c941b4371/src/ya.pl#L720). diff --git a/pyproject.toml b/pyproject.toml index fd51d1f..c7f5384 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,14 @@ build-backend = "setuptools.build_meta" [project] name = "yandex-music-downloader" -version = "1.0.4" +version = "2.0.0" description = "Загрузчик музыки с сервиса Яндекс.Музыка" requires-python = ">=3.9" readme = "README.md" dependencies = [ "requests", - "requests-cache", - "eyed3" + "eyed3", + "browser-cookie3" ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index f8a53aa..19a52fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ requests -requests-cache eyed3 +browser-cookie3 diff --git a/ymd/cli.py b/ymd/cli.py index c1082db..bdd4645 100644 --- a/ymd/cli.py +++ b/ymd/cli.py @@ -9,158 +9,185 @@ import time from pathlib import Path from typing import Optional +import browser_cookie3 +from browser_cookie3 import BrowserCookieError from requests import Session -from requests_cache import CachedSession, FileCache from ymd import core from ymd.ym_api import BasicTrackInfo, PlaylistId, api -DEFAULT_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36' -DEFAULT_DELAY = 3 +DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36" +DEFAULT_DELAY = 1 +SUPPORTED_BROWSERS = [ + "chrome", + "opera", + "opera_gx", + "firefox", + "edge", + "safari", + "chromium", + "vivaldi", + "librewolf", +] CACHE_EXPIRE_AFTER = dt.timedelta(hours=8) -CACHE_DIR = Path(tempfile.gettempdir()) / 'ymd' +CACHE_DIR = Path(tempfile.gettempdir()) / "ymd" -TRACK_RE = re.compile(r'track/(\d+)$') -ALBUM_RE = re.compile(r'album/(\d+)$') -ARTIST_RE = re.compile(r'artist/(\d+)$') -PLAYLIST_RE = re.compile(r'([\w\-._]+)/playlists/(\d+)$') +TRACK_RE = re.compile(r"track/(\d+)$") +ALBUM_RE = re.compile(r"album/(\d+)$") +ARTIST_RE = re.compile(r"artist/(\d+)$") +PLAYLIST_RE = re.compile(r"([\w\-._]+)/playlists/(\d+)$") -logger = logging.getLogger('yandex-music-downloader') +logger = logging.getLogger("yandex-music-downloader") def args_playlist_id(arg: str) -> api.PlaylistId: - arr = arg.split('/') + arr = arg.split("/") return PlaylistId(owner=arr[0], kind=int(arr[1])) -def help_str(text: Optional[str] = None) -> str: - default = 'по умолчанию: %(default)s' +def show_default(text: Optional[str] = None) -> str: + default = "по умолчанию: %(default)s" if text is None: return default - return f'{text} ({default})' + return f"{text} ({default})" def main(): parser = argparse.ArgumentParser( - description='Загрузчик музыки с сервиса Яндекс.Музыка') + description="Загрузчик музыки с сервиса Яндекс.Музыка" + ) - common_group = parser.add_argument_group('Общие параметры') - common_group.add_argument('--hq', - action='store_true', - help='Загружать треки в высоком качестве') - common_group.add_argument('--skip-existing', - action='store_true', - help='Пропускать уже загруженные треки') - common_group.add_argument('--add-lyrics', - action='store_true', - help='Загружать тексты песен') - common_group.add_argument('--embed-cover', - action='store_true', - help='Встраивать обложку в .mp3 файл') - common_group.add_argument('--cover-resolution', - default=core.DEFAULT_COVER_RESOLUTION, - metavar='<Разрешение обложки>', - type=int, - help=help_str(None)) + common_group = parser.add_argument_group("Общие параметры") common_group.add_argument( - '--delay', - default=DEFAULT_DELAY, - metavar='<Задержка>', + "--hq", action="store_true", help="Загружать треки в высоком качестве" + ) + common_group.add_argument( + "--skip-existing", action="store_true", help="Пропускать уже загруженные треки" + ) + common_group.add_argument( + "--add-lyrics", action="store_true", help="Загружать тексты песен" + ) + common_group.add_argument( + "--embed-cover", action="store_true", help="Встраивать обложку в .mp3 файл" + ) + common_group.add_argument( + "--cover-resolution", + default=core.DEFAULT_COVER_RESOLUTION, + metavar="<Разрешение обложки>", type=int, - help=help_str('Задержка между запросами, в секундах')) - common_group.add_argument('--stick-to-artist', - action='store_true', - help='Загружать альбомы, созданные' - ' только данным исполнителем') - common_group.add_argument('--only-music', - action='store_true', - help='Загружать только музыкальные альбомы' - ' (пропускать подкасты и аудиокниги)') - common_group.add_argument('--enable-caching', - action='store_true', - help='Включить кэширование. Данная опция полезна' - ' при нестабильном интернете.' - f' (кэш хранится в папке {CACHE_DIR})') - common_group.add_argument('--debug', - action='store_true', - help=argparse.SUPPRESS) + help=show_default(None), + ) + common_group.add_argument( + "--delay", + default=DEFAULT_DELAY, + metavar="<Задержка>", + type=int, + help=show_default("Задержка между запросами, в секундах"), + ) + common_group.add_argument( + "--stick-to-artist", + action="store_true", + help="Загружать альбомы, созданные" " только данным исполнителем", + ) + common_group.add_argument( + "--only-music", + action="store_true", + help="Загружать только музыкальные альбомы" + " (пропускать подкасты и аудиокниги)", + ) + common_group.add_argument("--debug", action="store_true", help=argparse.SUPPRESS) - id_group_meta = parser.add_argument_group('ID') + id_group_meta = parser.add_argument_group("ID") id_group = id_group_meta.add_mutually_exclusive_group(required=True) - id_group.add_argument('--artist-id', metavar='') - id_group.add_argument('--album-id', metavar='') - id_group.add_argument('--track-id', metavar='') - id_group.add_argument('--playlist-id', - type=args_playlist_id, - metavar='<владелец плейлиста>/<тип плейлиста>') - id_group.add_argument('-u', - '--url', - help='URL исполнителя/альбома/трека/плейлиста') + id_group.add_argument("--artist-id", metavar="") + id_group.add_argument("--album-id", metavar="") + id_group.add_argument("--track-id", metavar="") + id_group.add_argument( + "--playlist-id", + type=args_playlist_id, + metavar="<владелец плейлиста>/<тип плейлиста>", + ) + id_group.add_argument("-u", "--url", help="URL исполнителя/альбома/трека/плейлиста") - path_group = parser.add_argument_group('Указание пути') - path_group.add_argument('--unsafe-path', - action='store_true', - help='Не очищать путь от недопустимых символов') - path_group.add_argument('--dir', - default='.', - metavar='<Папка>', - help=help_str('Папка для загрузки музыки'), - type=Path) + path_group = parser.add_argument_group("Указание пути") path_group.add_argument( - '--path-pattern', - default=core.DEFAULT_PATH_PATTERN, - metavar='<Паттерн>', + "--unsafe-path", + action="store_true", + help="Не очищать путь от недопустимых символов", + ) + path_group.add_argument( + "--dir", + default=".", + metavar="<Папка>", + help=show_default("Папка для загрузки музыки"), type=Path, - help=help_str('Поддерживает следующие заполнители:' - ' #number, #artist, #album-artist, #title,' - ' #album, #year, #artist-id, #album-id, #track-id')) + ) + path_group.add_argument( + "--path-pattern", + default=core.DEFAULT_PATH_PATTERN, + metavar="<Паттерн>", + type=Path, + help=show_default( + "Поддерживает следующие заполнители:" + " #number, #artist, #album-artist, #title," + " #album, #year, #artist-id, #album-id, #track-id" + ), + ) - auth_group = parser.add_argument_group('Авторизация') - auth_group.add_argument('--session-id', - required=True, - metavar='') - auth_group.add_argument('--spravka', metavar='') - auth_group.add_argument('--user-agent', - default=DEFAULT_USER_AGENT, - metavar='', - help=help_str()) + auth_group = parser.add_argument_group("Авторизация") + auth_group.add_argument( + "--browser", + required=True, + help=( + "Браузер из которого будут извлечены данные для авторизации." + " Укажите браузер через который вы входили в Яндекс Музыку." + f" Допустимые значения: {', '.join(SUPPORTED_BROWSERS)}" + ), + ) + auth_group.add_argument( + "--cookies-path", + help="Путь к файлу с cookies. Используйте если возникает ошибка получения cookies", + ) + auth_group.add_argument( + "--user-agent", + default=DEFAULT_USER_AGENT, + metavar="", + help=show_default(), + ) args = parser.parse_args() logging.basicConfig( - format='%(asctime)s |%(levelname)s| %(name)s: %(message)s', - datefmt='%H:%M:%S', - level=logging.DEBUG if args.debug else logging.ERROR) + format="%(asctime)s |%(levelname)s| %(name)s: %(message)s", + datefmt="%H:%M:%S", + level=logging.DEBUG if args.debug else logging.ERROR, + ) def response_hook(resp, **kwargs): del kwargs if logger.isEnabledFor(logging.DEBUG): - target_headers = ['application/json', 'text/xml'] - if any(h in resp.headers['Content-Type'] for h in target_headers): + target_headers = ["application/json", "text/xml"] + if any(h in resp.headers["Content-Type"] for h in target_headers): logger.debug(resp.text) if not resp.ok: - print(f'Код ошибки: {resp.status_code}') + print(f"Код ошибки: {resp.status_code}") if resp.status_code == 400: print( - 'Информация по устранению данной ошибки: https://github.com/llistochek/yandex-music-downloader#%D0%BE%D1%88%D0%B8%D0%B1%D0%BA%D0%B0-400' + "Информация по устранению данной ошибки: https://github.com/llistochek/yandex-music-downloader#%D0%BE%D1%88%D0%B8%D0%B1%D0%BA%D0%B0-400" ) sys.exit(3) - if not getattr(resp, 'from_cache', False): - time.sleep(args.delay) + time.sleep(args.delay) - def setup_session(session: Session): - core.setup_session(session, args.session_id, args.user_agent, - args.spravka) - session.hooks = {'response': response_hook} + try: + cookies = getattr(browser_cookie3, args.browser)(cookie_file=args.cookies_path) + except BrowserCookieError: + print(f"Не удалось получить cookies для браузера {args.browser}") + return 1 - session = cached_session = Session() - if args.enable_caching: - cached_session = CachedSession(backend=FileCache(cache_name=CACHE_DIR), - expire_after=CACHE_EXPIRE_AFTER) - - setup_session(session) - setup_session(cached_session) + session = Session() + core.setup_session(session, cookies, DEFAULT_USER_AGENT) + session.hooks = {"response": response_hook} result_tracks: list[BasicTrackInfo] = [] @@ -172,47 +199,48 @@ def main(): elif match := TRACK_RE.search(args.url): args.track_id = match.group(1) elif match := PLAYLIST_RE.search(args.url): - args.playlist_id = PlaylistId(owner=match.group(1), - kind=int(match.group(2))) + args.playlist_id = PlaylistId( + owner=match.group(1), kind=int(match.group(2)) + ) else: - print('Параметер url указан в неверном формате') + print("Параметер url указан в неверном формате") return 1 if args.artist_id is not None: - artist_info = api.get_artist_info(cached_session, args.artist_id) + artist_info = api.get_artist_info(session, args.artist_id) albums_count = 0 for album in artist_info.albums: - if args.stick_to_artist and album.artists[ - 0].name != artist_info.name: - print(f'Альбом "{album.title}" пропущен' - ' из-за флага --stick-to-artist') + if args.stick_to_artist and album.artists[0].name != artist_info.name: + print( + f'Альбом "{album.title}" пропущен' " из-за флага --stick-to-artist" + ) continue - if args.only_music and album.meta_type != 'music': - print(f'Альбом "{album.title}" пропущен' - ' т.к. не является музыкальным') + if args.only_music and album.meta_type != "music": + print( + f'Альбом "{album.title}" пропущен' " т.к. не является музыкальным" + ) continue - full_album = api.get_full_album_info(cached_session, album.id) + full_album = api.get_full_album_info(session, album.id) result_tracks.extend(full_album.tracks) albums_count += 1 print(artist_info.name) - print(f'Альбомов: {albums_count}') + print(f"Альбомов: {albums_count}") elif args.album_id is not None: - album = api.get_full_album_info(cached_session, args.album_id) + album = api.get_full_album_info(session, args.album_id) print(album.title) result_tracks = album.tracks elif args.track_id is not None: - result_tracks = [ - api.get_full_track_info(cached_session, args.track_id) - ] + result_tracks = [api.get_full_track_info(session, args.track_id)] elif args.playlist_id is not None: - result_tracks = api.get_playlist(cached_session, args.playlist_id) + result_tracks = api.get_playlist(session, args.playlist_id) - print(f'Треков: {len(result_tracks)}') + print(f"Треков: {len(result_tracks)}") covers_cache: dict[str, bytes] = {} for track in result_tracks: - save_path = args.dir / core.prepare_track_path(args.path_pattern, - track, args.unsafe_path) + save_path = args.dir / core.prepare_track_path( + args.path_pattern, track, args.unsafe_path + ) if args.skip_existing and save_path.is_file(): continue @@ -220,12 +248,14 @@ def main(): if not save_dir.is_dir(): save_dir.mkdir(parents=True) - print(f'Загружается {save_path}') - core.download_track(session=session, - track=track, - target_path=save_path, - hq=args.hq, - add_lyrics=args.add_lyrics, - embed_cover=args.embed_cover, - cover_resolution=args.cover_resolution, - covers_cache=covers_cache) + print(f"Загружается {save_path}") + core.download_track( + session=session, + track=track, + target_path=save_path, + hq=args.hq, + add_lyrics=args.add_lyrics, + embed_cover=args.embed_cover, + cover_resolution=args.cover_resolution, + covers_cache=covers_cache, + ) diff --git a/ymd/core.py b/ymd/core.py index d3127c8..514cd8d 100644 --- a/ymd/core.py +++ b/ymd/core.py @@ -1,5 +1,6 @@ import re import urllib.parse +from http.cookiejar import CookieJar from pathlib import Path from typing import Optional @@ -10,44 +11,47 @@ from requests import Session from ymd import http_utils from ymd.ym_api import BasicTrackInfo, FullTrackInfo, api -ENCODED_BY = 'https://github.com/llistochek/yandex-music-downloader' -FILENAME_CLEAR_RE = re.compile(r'[^\w\-\'() ]+') +ENCODED_BY = "https://github.com/llistochek/yandex-music-downloader" +FILENAME_CLEAR_RE = re.compile(r"[^\w\-\'() ]+") -DEFAULT_PATH_PATTERN = Path('#album-artist', '#album', '#number - #title') +DEFAULT_PATH_PATTERN = Path("#album-artist", "#album", "#number - #title") DEFAULT_COVER_RESOLUTION = 400 -def prepare_track_path(path_pattern: Path, - track: BasicTrackInfo, - unsafe_path: bool = False) -> Path: +def prepare_track_path( + path_pattern: Path, track: BasicTrackInfo, unsafe_path: bool = False +) -> Path: path_str = str(path_pattern) album = track.album artist = album.artists[0] repl_dict = { - '#album-artist': album.artists[0].name, - '#artist-id': artist.name, - '#album-id': album.id, - '#track-id': track.id, - '#number': track.number, - '#artist': artist.name, - '#title': track.title, - '#album': album.title, - '#year': album.year + "#album-artist": album.artists[0].name, + "#artist-id": artist.name, + "#album-id": album.id, + "#track-id": track.id, + "#number": track.number, + "#artist": artist.name, + "#title": track.title, + "#album": album.title, + "#year": album.year, } for placeholder, replacement in repl_dict.items(): replacement = str(replacement) if not unsafe_path: - replacement = FILENAME_CLEAR_RE.sub('_', replacement) + replacement = FILENAME_CLEAR_RE.sub("_", replacement) path_str = path_str.replace(placeholder, replacement) - path_str += '.mp3' + path_str += ".mp3" return Path(path_str) -def set_id3_tags(path: Path, track: BasicTrackInfo, lyrics: Optional[str], - album_cover: Optional[bytes]) -> None: +def set_id3_tags( + path: Path, + track: BasicTrackInfo, + lyrics: Optional[str], + album_cover: Optional[bytes], +) -> None: if track.album.release_date is not None: - release_date = eyed3.core.Date( - *track.album.release_date.timetuple()[:6]) + release_date = eyed3.core.Date(*track.album.release_date.timetuple()[:6]) else: release_date = track.album.year audiofile = eyed3.load(path) @@ -68,33 +72,28 @@ def set_id3_tags(path: Path, track: BasicTrackInfo, lyrics: Optional[str], if lyrics is not None: tag.lyrics.set(lyrics) if album_cover is not None: - tag.images.set(ImageFrame.FRONT_COVER, album_cover, 'image/jpeg') + tag.images.set(ImageFrame.FRONT_COVER, album_cover, "image/jpeg") tag.save() -def setup_session(session: Session, - session_id: str, - user_agent: str, - spravka: Optional[str] = None) -> Session: - session.cookies.set('Session_id', session_id, domain='yandex.ru') - if spravka: - session.cookies.set('spravka', spravka, domain='yandex.ru') - session.headers['User-Agent'] = user_agent - session.headers['X-Retpath-Y'] = urllib.parse.quote_plus( - 'https://music.yandex.ru') +def setup_session(session: Session, cookie_jar: CookieJar, user_agent: str) -> Session: + session.cookies = cookie_jar # type: ignore + session.headers["User-Agent"] = user_agent + session.headers["X-Retpath-Y"] = urllib.parse.quote_plus("https://music.yandex.ru") return session -def download_track(session: Session, - track: BasicTrackInfo, - target_path: Path, - covers_cache: dict[str, bytes], - cover_resolution: int = DEFAULT_COVER_RESOLUTION, - hq: bool = False, - add_lyrics: bool = False, - embed_cover: bool = False): - +def download_track( + session: Session, + track: BasicTrackInfo, + target_path: Path, + covers_cache: dict[str, bytes], + cover_resolution: int = DEFAULT_COVER_RESOLUTION, + hq: bool = False, + add_lyrics: bool = False, + embed_cover: bool = False, +): album = track.album url = api.get_track_download_url(session, track, hq) @@ -116,9 +115,10 @@ def download_track(session: Session, cover = cached_cover else: cover = covers_cache[album.id] = http_utils.download_bytes( - session, cover_url) + session, cover_url + ) else: - cover_path = target_path.parent / 'cover.jpg' + cover_path = target_path.parent / "cover.jpg" if not cover_path.is_file(): http_utils.download_file(session, cover_url, cover_path) diff --git a/ymd/http_utils.py b/ymd/http_utils.py index 7d60471..c616e51 100644 --- a/ymd/http_utils.py +++ b/ymd/http_utils.py @@ -5,7 +5,7 @@ from requests import Session def download_file(session: Session, url: str, path: Path) -> None: resp = session.get(url) - with open(path, 'wb') as f: + with open(path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024): f.write(chunk) diff --git a/ymd/ym_api/api.py b/ymd/ym_api/api.py index ceff4b8..4c51ce0 100644 --- a/ymd/ym_api/api.py +++ b/ymd/ym_api/api.py @@ -5,54 +5,50 @@ from requests import Session from .models import * -MD5_SALT = 'XGRlBW9FXlekgbPrRHuSiA' +MD5_SALT = "XGRlBW9FXlekgbPrRHuSiA" -def get_track_download_url(session: Session, track: BasicTrackInfo, - hq: bool) -> str: - resp = session.get('https://music.yandex.ru/api/v2.1/handlers/track' - f'/{track.id}:{track.album.id}' - '/web-album_track-track-track-main/download/m' - f'?hq={int(hq)}') - url_info_src = resp.json()['src'] +def get_track_download_url(session: Session, track: BasicTrackInfo, hq: bool) -> str: + resp = session.get( + "https://music.yandex.ru/api/v2.1/handlers/track" + f"/{track.id}:{track.album.id}" + "/web-album_track-track-track-main/download/m" + f"?hq={int(hq)}" + ) + url_info_src = resp.json()["src"] - resp = session.get('https:' + url_info_src) + resp = session.get("https:" + url_info_src) url_info = ET.fromstring(resp.text) - path = url_info.find('path').text[1:] - s = url_info.find('s').text - ts = url_info.find('ts').text - host = url_info.find('host').text + path = url_info.find("path").text[1:] + s = url_info.find("s").text + ts = url_info.find("ts").text + host = url_info.find("host").text path_hash = hashlib.md5((MD5_SALT + path + s).encode()).hexdigest() - return f'https://{host}/get-mp3/{path_hash}/{ts}/{path}?track-id={track.id}' + return f"https://{host}/get-mp3/{path_hash}/{ts}/{path}?track-id={track.id}" def get_full_track_info(session: Session, track_id: str) -> FullTrackInfo: - params = {'track': track_id, 'lang': 'ru'} - resp = session.get('https://music.yandex.ru/handlers/track.jsx', - params=params) + params = {"track": track_id, "lang": "ru"} + resp = session.get("https://music.yandex.ru/handlers/track.jsx", params=params) return FullTrackInfo.from_json(resp.json()) def get_full_album_info(session: Session, album_id: str) -> FullAlbumInfo: - params = {'album': album_id, 'lang': 'ru'} - resp = session.get('https://music.yandex.ru/handlers/album.jsx', - params=params) + params = {"album": album_id, "lang": "ru"} + resp = session.get("https://music.yandex.ru/handlers/album.jsx", params=params) return FullAlbumInfo.from_json(resp.json()) def get_artist_info(session: Session, artist_id: str) -> FullArtistInfo: - params = {'artist': artist_id, 'what': 'albums', 'lang': 'ru'} - resp = session.get('https://music.yandex.ru/handlers/artist.jsx', - params=params) + params = {"artist": artist_id, "what": "albums", "lang": "ru"} + resp = session.get("https://music.yandex.ru/handlers/artist.jsx", params=params) return FullArtistInfo.from_json(resp.json()) -def get_playlist(session: Session, - playlist: PlaylistId) -> list[BasicTrackInfo]: - params = {'owner': playlist.owner, 'kinds': playlist.kind, 'lang': 'ru'} - resp = session.get('https://music.yandex.ru/handlers/playlist.jsx', - params=params) - raw_tracks = resp.json()['playlist'].get('tracks', []) +def get_playlist(session: Session, playlist: PlaylistId) -> list[BasicTrackInfo]: + params = {"owner": playlist.owner, "kinds": playlist.kind, "lang": "ru"} + resp = session.get("https://music.yandex.ru/handlers/playlist.jsx", params=params) + raw_tracks = resp.json()["playlist"].get("tracks", []) tracks = map(BasicTrackInfo.from_json, raw_tracks) tracks = [t for t in tracks if t is not None] return tracks diff --git a/ymd/ym_api/models.py b/ymd/ym_api/models.py index 9fa60f5..b8a0ddd 100644 --- a/ymd/ym_api/models.py +++ b/ymd/ym_api/models.py @@ -2,7 +2,7 @@ import datetime as dt from dataclasses import dataclass from typing import Optional -TITLE_TEMPLATE = '{title} ({version})' +TITLE_TEMPLATE = "{title} ({version})" @dataclass @@ -13,15 +13,14 @@ class CoverInfo: if self.cover_url_template is None: return - return self.cover_url_template.replace('%%', - f'{resolution}x{resolution}') + return self.cover_url_template.replace("%%", f"{resolution}x{resolution}") @classmethod - def from_json(cls, data: dict) -> 'CoverInfo': + def from_json(cls, data: dict) -> "CoverInfo": og_image = data.get("ogImage") if og_image is None or og_image == "": return cls(None) - return cls(f'https://{og_image}') + return cls(f"https://{og_image}") @dataclass @@ -36,19 +35,19 @@ class BasicArtistInfo: name: str @classmethod - def from_json(cls, data: dict) -> 'BasicArtistInfo': - return cls(id=data['id'], name=data['name']) + def from_json(cls, data: dict) -> "BasicArtistInfo": + return cls(id=data["id"], name=data["name"]) @dataclass class FullArtistInfo(BasicArtistInfo): - albums: list['BasicAlbumInfo'] + albums: list["BasicAlbumInfo"] cover_info: CoverInfo @classmethod - def from_json(cls, data: dict) -> 'FullArtistInfo': - base = BasicArtistInfo.from_json(data['artist']) - albums = map(BasicAlbumInfo.from_json, data.get('albums', [])) + def from_json(cls, data: dict) -> "FullArtistInfo": + base = BasicArtistInfo.from_json(data["artist"]) + albums = map(BasicAlbumInfo.from_json, data.get("albums", [])) albums = [a for a in albums if a is not None] cover_info = CoverInfo.from_json(data) return cls(**base.__dict__, cover_info=cover_info, albums=albums) @@ -64,17 +63,19 @@ class BasicAlbumInfo: meta_type: str @classmethod - def from_json(cls, data: dict) -> Optional['BasicAlbumInfo']: - artists = parse_artists(data['artists']) + def from_json(cls, data: dict) -> Optional["BasicAlbumInfo"]: + artists = parse_artists(data["artists"]) title = parse_title(data) - if release_date := data.get('releaseDate'): + if release_date := data.get("releaseDate"): release_date = dt.datetime.fromisoformat(release_date) - return cls(id=data['id'], - title=title, - year=data.get('year'), - meta_type=data['metaType'], - artists=artists, - release_date=release_date) + return cls( + id=data["id"], + title=title, + year=data.get("year"), + meta_type=data["metaType"], + artists=artists, + release_date=release_date, + ) @dataclass @@ -90,43 +91,46 @@ class BasicTrackInfo: cover_info: CoverInfo @classmethod - def from_json(cls, data: dict) -> Optional['BasicTrackInfo']: - if not data['available']: + def from_json(cls, data: dict) -> Optional["BasicTrackInfo"]: + if not data["available"]: return None - track_id = str(data['id']) + track_id = str(data["id"]) title = parse_title(data) - albums_data = data['albums'] - artists = parse_artists(data['artists']) - track_position = {'index': 1, 'volume': 1} + albums_data = data["albums"] + artists = parse_artists(data["artists"]) + track_position = {"index": 1, "volume": 1} if len(albums_data): album_data = albums_data[0] album = BasicAlbumInfo.from_json(album_data) - track_position = album_data.get('trackPosition', track_position) + track_position = album_data.get("trackPosition", track_position) else: - album = BasicAlbumInfo(id=track_id, - title=title, - release_date=None, - year=None, - meta_type='music', - artists=artists) + album = BasicAlbumInfo( + id=track_id, + title=title, + release_date=None, + year=None, + meta_type="music", + artists=artists, + ) if album is None: raise ValueError cover_info = CoverInfo.from_json(data) - has_lyrics = data.get('lyricsInfo', {}).get('hasAvailableTextLyrics', - False) - return cls(title=title, - id=track_id, - real_id=data['realId'], - number=track_position['index'], - disc_number=track_position['volume'], - artists=artists, - album=album, - has_lyrics=has_lyrics, - cover_info=cover_info) + has_lyrics = data.get("lyricsInfo", {}).get("hasAvailableTextLyrics", False) + return cls( + title=title, + id=track_id, + real_id=data["realId"], + number=track_position["index"], + disc_number=track_position["volume"], + artists=artists, + album=album, + has_lyrics=has_lyrics, + cover_info=cover_info, + ) @property def url(self) -> str: - return f'https://music.yandex.ru/album/{self.album.id}/track/{self.id}' + return f"https://music.yandex.ru/album/{self.album.id}/track/{self.id}" @dataclass @@ -134,9 +138,9 @@ class FullTrackInfo(BasicTrackInfo): lyrics: str @classmethod - def from_json(cls, data: dict) -> 'FullTrackInfo': - base = BasicTrackInfo.from_json(data['track']) - lyrics = data['lyric'][0]['fullLyrics'] + def from_json(cls, data: dict) -> "FullTrackInfo": + base = BasicTrackInfo.from_json(data["track"]) + lyrics = data["lyric"][0]["fullLyrics"] return cls(**base.__dict__, lyrics=lyrics) @@ -145,9 +149,9 @@ class FullAlbumInfo(BasicAlbumInfo): tracks: list[BasicTrackInfo] @classmethod - def from_json(cls, data: dict) -> 'FullAlbumInfo': + def from_json(cls, data: dict) -> "FullAlbumInfo": base = BasicAlbumInfo.from_json(data) - tracks = data.get('volumes', []) + tracks = data.get("volumes", []) tracks = [t for v in tracks for t in v] tracks = map(BasicTrackInfo.from_json, tracks) tracks = [t for t in tracks if t is not None] @@ -158,7 +162,7 @@ def parse_artists(data: list) -> list[BasicArtistInfo]: artists = [] for artist in data: artists.append(artist) - if decomposed := artist.get('decomposed'): + if decomposed := artist.get("decomposed"): for d_artist in decomposed: if isinstance(d_artist, dict): artists.append(d_artist) @@ -166,7 +170,7 @@ def parse_artists(data: list) -> list[BasicArtistInfo]: def parse_title(data: dict) -> str: - title = data['title'] - if version := data.get('version'): + title = data["title"] + if version := data.get("version"): title = TITLE_TEMPLATE.format(title=title, version=version) return title