From cc472b21f7e5b0fc70d7015a99837dd10230e5fa Mon Sep 17 00:00:00 2001 From: Lev Plyusnin Date: Sun, 10 Nov 2024 14:47:48 +0700 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MIGRATION.md | 11 +++ README.md | 85 ++++++------------ pyproject.toml | 7 +- ymd/cli.py | 197 ++++++++++++++++------------------------- ymd/core.py | 128 +++++++++++++------------- ymd/http_utils.py | 14 --- ymd/ym_api/__init__.py | 2 - ymd/ym_api/api.py | 66 -------------- ymd/ym_api/models.py | 174 ------------------------------------ 9 files changed, 177 insertions(+), 507 deletions(-) create mode 100644 MIGRATION.md delete mode 100644 ymd/http_utils.py delete mode 100644 ymd/ym_api/__init__.py delete mode 100644 ymd/ym_api/api.py delete mode 100644 ymd/ym_api/models.py diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..409f7cd --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,11 @@ +# Переход на новую версию + +## v2 -> v3 +- Для авторизации используйте аргумент `--token`. Аргументы `--browser`, +`--user-agent`, `--cookies-path` больше не являются валидными, +авторизация через cookies невозможна. +- Удален аргумент `--hq`. Используйте `--quality 1`. +- Удален аргумент `--disable-ipv6` +- Задержка между запросами по умолчанию равна 0. Вы можете убрать +аргумент `--delay`, на мобильный токен не распространяются жесткие +ограничения на количество запросов. diff --git a/README.md b/README.md index 2a1693e..5a1a36c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # yandex-music-downloader +> Внимание! В версии v3 был изменен способ авторизации и некоторые +> аргументы. Смотрите [MIGRATION.md](MIGRATION.md) для получения информации +> об изменениях + ## Содержание 1. [О программе](#О-программе) 2. [Установка](#Установка) 3. [Получение данных для авторизации](#Получение-данных-для-авторизации) 4. [Примеры использования](#Примеры-использования) -5. [Использование](#Использование) -6. [Что делать при ошибке 400?](#Ошибка-400) -7. [Спасибо](#Спасибо) -8. [Дисклеймер](#Дисклеймер) +5. [Спасибо](#Спасибо) +6. [Использование](#Использование) +7. [Дисклеймер](#Дисклеймер) ## О программе Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download). @@ -25,7 +28,7 @@ - Название трека - Исполнитель - Дополнительные исполнители - - Год выпуска альбома + - Дата выпуска альбома - Обложка альбома - Название альбома - Текст песни (при использовании флага `--add-lyrics`) @@ -39,47 +42,36 @@ yandex-music-downloader --help ``` ## Получение данных для авторизации -1. Войдите в свой Яндекс аккаунт. -2. Передайте название вашего браузера в качестве аргумента `--browser` - -Если программа выдает ошибку при загрузке cookies - укажите путь к файлу -с cookies в качестве аргумента `--cookies-path`. Информацию о -расположении данного файла для вашего браузера вы можете найти в -интернете. Если что-то не получается - откройте issue. - +https://yandex-music.readthedocs.io/en/main/token.html ## Примеры использования -Во всех примерах замените `<браузер>` на название своего браузера (для -получения допустимых значений запустите программу с флагом `--help`) +Во всех примерах замените `<Токен>` на ваш токен. ### Скачать все треки [Twenty One Pilots](https://music.yandex.ru/artist/792433) в высоком качестве ``` -yandex-music-downloader --browser "<браузер>" --hq --url "https://music.yandex.ru/artist/792433" +yandex-music-downloader --token "<Токен>" --quality 1 --url "https://music.yandex.ru/artist/792433" ``` ### Скачать альбом [Nevermind](https://music.yandex.ru/album/294912) в высоком качестве, загружая тексты песен ``` -yandex-music-downloader --browser "<браузер>" --hq --add-lyrics --url "https://music.yandex.ru/album/294912" +yandex-music-downloader --token "<Токен>" --quality 1 --add-lyrics --url "https://music.yandex.ru/album/294912" ``` ### Скачать трек [Seven Nation Army](https://music.yandex.ru/album/11644078/track/6705392) ``` -yandex-music-downloader --browser "<браузер>" --url "https://music.yandex.ru/album/11644078/track/6705392" +yandex-music-downloader --token "<Токен>" --url "https://music.yandex.ru/album/11644078/track/6705392" ``` ## Использование - ``` -usage: yandex-music-downloader [-h] [--hq] [--skip-existing] [--add-lyrics] - [--embed-cover] +usage: yandex-music-downloader [-h] [--quality <Качество>] [--skip-existing] + [--add-lyrics] [--embed-cover] [--cover-resolution <Разрешение обложки>] [--delay <Задержка>] [--stick-to-artist] - [--only-music] [--disable-ipv6] + [--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 ] + [--path-pattern <Паттерн>] --token <Токен> Загрузчик музыки с сервиса Яндекс.Музыка @@ -87,19 +79,18 @@ options: -h, --help show this help message and exit Общие параметры: - --hq Загружать треки в высоком качестве + --quality <Качество> Качество трека: + 0 - Низкое (mp3 128kbps) + 1 - Высокое (mp3 320kbps) + (по умолчанию: 0) --skip-existing Пропускать уже загруженные треки --add-lyrics Загружать тексты песен --embed-cover Встраивать обложку в .mp3 файл --cover-resolution <Разрешение обложки> по умолчанию: 400 - --delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 3) - --stick-to-artist Загружать альбомы, созданные только данным - исполнителем - --only-music Загружать только музыкальные альбомы (пропускать - подкасты и аудиокниги) - --disable-ipv6 Отключить IPv6. Используйте при проблемах с - подключением + --delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 0) + --stick-to-artist Загружать альбомы, созданные только данным исполнителем + --only-music Загружать только музыкальные альбомы (пропускать подкасты и аудиокниги) ID: --artist-id @@ -112,36 +103,14 @@ ID: --unsafe-path Не очищать путь от недопустимых символов --dir <Папка> Папка для загрузки музыки (по умолчанию: .) --path-pattern <Паттерн> - Поддерживает следующие заполнители: #number, #artist, - #album-artist, #title, #album, #year, #artist-id, - #album-id, #track-id, #track-number (по умолчанию: - #album-artist/#album/#number - #title) + Поддерживает следующие заполнители: #number, #artist, #album-artist, #title, #album, #year, #artist-id, #album-id, #track-id, #number-padded (по умолчанию: #album-artist/#album/#number - #title) Авторизация: - --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) - Chrome/106.0.0.0 Safari/537.36 + --token <Токен> Токен для авторизации. См. README для способов получения ``` -## Ошибка 400 -Ниже приведена инструкция по устранению ошибки 400. - -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). +Разработчикам проекта [yandex-music-api](https://github.com/MarshalX/yandex-music-api) ## Дисклеймер Данный проект является независимой разработкой и никак не связан с компанией Яндекс. diff --git a/pyproject.toml b/pyproject.toml index 775c25e..8131e0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,13 @@ build-backend = "setuptools.build_meta" [project] name = "yandex-music-downloader" -version = "2.0.2" +version = "3.0.0b0" description = "Загрузчик музыки с сервиса Яндекс.Музыка" requires-python = ">=3.9" readme = "README.md" dependencies = [ - "requests", - "eyed3", - "browser-cookie3" + "yandex-music", + "eyed3" ] [project.scripts] diff --git a/ymd/cli.py b/ymd/cli.py index 0bf3df1..9b54dea 100644 --- a/ymd/cli.py +++ b/ymd/cli.py @@ -1,39 +1,20 @@ #!/bin/python3 import argparse -import datetime as dt +import itertools import logging import re -import sys -import tempfile import time +import typing +from collections.abc import Generator, Iterable from pathlib import Path from typing import Optional from urllib.parse import urlparse -import browser_cookie3 -from browser_cookie3 import BrowserCookieError -from requests import Session +from yandex_music import Album, Client, Playlist, Track from ymd import core -from ymd.ym_api import BasicTrackInfo, PlaylistId, YandexMusicApi, 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_DOMAIN = "music.yandex.ru" -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" +DEFAULT_DELAY = 0 TRACK_RE = re.compile(r"track/(\d+)") ALBUM_RE = re.compile(r"album/(\d+)$") @@ -43,11 +24,6 @@ PLAYLIST_RE = re.compile(r"([\w\-._]+)/playlists/(\d+)$") logger = logging.getLogger("yandex-music-downloader") -def args_playlist_id(arg: str) -> api.PlaylistId: - arr = arg.split("/") - return PlaylistId(owner=arr[0], kind=int(arr[1])) - - def show_default(text: Optional[str] = None) -> str: default = "по умолчанию: %(default)s" if text is None: @@ -57,12 +33,17 @@ def show_default(text: Optional[str] = None) -> str: def main(): parser = argparse.ArgumentParser( - description="Загрузчик музыки с сервиса Яндекс.Музыка" + description="Загрузчик музыки с сервиса Яндекс.Музыка", + formatter_class=argparse.RawTextHelpFormatter, ) common_group = parser.add_argument_group("Общие параметры") common_group.add_argument( - "--hq", action="store_true", help="Загружать треки в высоком качестве" + "--quality", + metavar="<Качество>", + default=0, + type=int, + help="Качество трека:\n0 - Низкое (mp3 128kbps)\n1 - Высокое (mp3 320kbps)\n(по умолчанию: %(default)s)", ) common_group.add_argument( "--skip-existing", action="store_true", help="Пропускать уже загруженные треки" @@ -98,11 +79,6 @@ def main(): help="Загружать только музыкальные альбомы" " (пропускать подкасты и аудиокниги)", ) - common_group.add_argument( - "--disable-ipv6", - action="store_true", - help="Отключить IPv6. Используйте при проблемах с подключением", - ) common_group.add_argument("--debug", action="store_true", help=argparse.SUPPRESS) id_group_meta = parser.add_argument_group("ID") @@ -112,7 +88,6 @@ def main(): 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 исполнителя/альбома/трека/плейлиста") @@ -138,29 +113,16 @@ def main(): help=show_default( "Поддерживает следующие заполнители:" " #number, #artist, #album-artist, #title," - " #album, #year, #artist-id, #album-id, #track-id, #track-number" + " #album, #year, #artist-id, #album-id, #track-id, #number-padded" ), ) auth_group = parser.add_argument_group("Авторизация") auth_group.add_argument( - "--browser", + "--token", 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(), + metavar="<Токен>", + help="Токен для авторизации. См. README для способов получения", ) args = parser.parse_args() @@ -171,30 +133,6 @@ def main(): 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): - logger.debug(resp.text) - if not resp.ok: - 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" - ) - sys.exit(3) - time.sleep(args.delay) - - try: - cookies = getattr(browser_cookie3, args.browser)(cookie_file=args.cookies_path) - except BrowserCookieError: - print(f"Не удалось получить cookies для браузера {args.browser}") - return 1 - - result_tracks: list[BasicTrackInfo] = [] - - domain = DEFAULT_DOMAIN if args.url is not None: parsed_url = urlparse(args.url) path = parsed_url.path @@ -205,65 +143,77 @@ def main(): elif match := TRACK_RE.search(path): args.track_id = match.group(1) elif match := PLAYLIST_RE.search(path): - args.playlist_id = PlaylistId( - owner=match.group(1), kind=int(match.group(2)) - ) + args.playlist_id = match.group(1) + "/" + match.group(2) else: print("Параметер url указан в неверном формате") return 1 - domain = parsed_url.hostname - session = Session() - core.setup_networking(enable_ipv6=not args.disable_ipv6) - core.setup_session(session, cookies, DEFAULT_USER_AGENT, domain) - session.hooks = {"response": response_hook} - client = YandexMusicApi(session, domain) + client = Client(args.token).init() + result_tracks: Iterable[Track] = [] + + def album_tracks_gen(album_ids: Iterable[int | str]) -> Generator[Track]: + for album_id in album_ids: + if full_album := client.albums_with_tracks(album_id): + if volumes := full_album.volumes: + yield from itertools.chain.from_iterable(volumes) if args.artist_id is not None: - artist_info = client.get_artist_info(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" - ) - continue - if args.only_music and album.meta_type != "music": - print( - f'Альбом "{album.title}" пропущен' " т.к. не является музыкальным" - ) - continue - full_album = client.get_full_album_info(album.id) - result_tracks.extend(full_album.tracks) - albums_count += 1 - print(artist_info.name) - print(f"Альбомов: {albums_count}") + + def filter_album(album: Album) -> bool: + title = album.title + if album.id is None or not album.available: + print(f'Альбом "{title}" не доступен для скачивания') + elif args.only_music and album.meta_type != "music": + print(f'Альбом "{title}" пропущен' " т.к. не является музыкальным") + elif args.stick_to_artist and album.artists[0].id != int(args.artist_id): + print(f'Альбом "{title}" пропущен' " из-за флага --stick-to-artist") + else: + return True + return False + + def albums_gen() -> Generator[Album]: + has_next = True + page = 0 + while has_next: + if albums := client.artists_direct_albums(args.artist_id, page): + yield from albums.albums + else: + break + if pager := albums.pager: + page = pager.page + 1 + has_next = pager.per_page * page < pager.total + else: + break + + result_tracks = album_tracks_gen( + a.id for a in albums_gen() if filter_album(a) and a.id is not None + ) elif args.album_id is not None: - album = client.get_full_album_info(args.album_id) - print(album.title) - result_tracks = album.tracks + result_tracks = album_tracks_gen((args.album_id,)) elif args.track_id is not None: - track = client.get_full_track_info(args.track_id) - if track is not None: - result_tracks = [track] - else: - logger.info("Трек не доступен для скачивания") - return 1 + track = client.tracks(args.track_id) + result_tracks = track elif args.playlist_id is not None: - result_tracks = client.get_playlist(args.playlist_id) + user, kind = args.playlist_id.split("/") + playlist = typing.cast(Playlist, client.users_playlists(kind, user)) - print(f"Треков: {len(result_tracks)}") + def playlist_tracks_gen() -> Generator[Track]: + tracks = playlist.fetch_tracks() + for track in tracks: + yield track.fetch_track() - covers_cache: dict[str, bytes] = {} - track_number = 0 - track_number_pad = len(str(len(result_tracks))) + result_tracks = playlist_tracks_gen() + + covers_cache: dict[int, bytes] = {} for track in result_tracks: - track_number += 1 + if not track.available: + print(f"Трек {track.title} не доступен для скачивания") + continue + save_path = args.dir / core.prepare_track_path( args.path_pattern, track, args.unsafe_path, - str(track_number).zfill(track_number_pad), ) if args.skip_existing and save_path.is_file(): continue @@ -274,12 +224,13 @@ def main(): print(f"Загружается {save_path}") core.download_track( - client=client, track=track, target_path=save_path, - hq=args.hq, + quality=args.quality, add_lyrics=args.add_lyrics, embed_cover=args.embed_cover, cover_resolution=args.cover_resolution, covers_cache=covers_cache, ) + if args.delay > 0: + time.sleep(args.delay) diff --git a/ymd/core.py b/ymd/core.py index ed35c1d..5e148ad 100644 --- a/ymd/core.py +++ b/ymd/core.py @@ -1,17 +1,12 @@ import re -import urllib.parse -from http.cookiejar import CookieJar +import typing +from datetime import datetime as dt from pathlib import Path -from typing import Optional +from typing import Optional, Union import eyed3 -import requests from eyed3.id3.frames import ImageFrame -from requests import Session - -from ymd import http_utils -from ymd.ym_api import BasicTrackInfo, FullTrackInfo -from ymd.ym_api.api import YandexMusicApi +from yandex_music import Track, YandexMusicObject ENCODED_BY = "https://github.com/llistochek/yandex-music-downloader" FILENAME_CLEAR_RE = re.compile(r"[^\w\-\'() ]+") @@ -20,25 +15,34 @@ DEFAULT_PATH_PATTERN = Path("#album-artist", "#album", "#number - #title") DEFAULT_COVER_RESOLUTION = 400 +def full_title(obj: YandexMusicObject) -> str | None: + result = obj["title"] + if result is None: + return + if version := obj["version"]: + result += f" ({version})" + return result + + def prepare_track_path( - path_pattern: Path, - track: BasicTrackInfo, - unsafe_path: bool = False, - track_number_str: str = "", + path_pattern: Path, track: Track, unsafe_path: bool = False ) -> Path: path_str = str(path_pattern) - album = track.album + album = track.albums[0] artist = album.artists[0] - repl_dict = { + track_position = album.track_position + repl_dict: dict[str, Union[str, int, None]] = { + "#number-padded": str(track_position.index).zfill(len(str(album.track_count))) + if track_position + else None, "#album-artist": album.artists[0].name, "#artist-id": artist.name, "#album-id": album.id, "#track-id": track.id, - "#number-padded": track_number_str, - "#number": track.number, + "#number": track_position.index if track_position else None, "#artist": artist.name, - "#title": track.title, - "#album": album.title, + "#title": full_title(track), + "#album": full_title(album), "#year": album.year, } for placeholder, replacement in repl_dict.items(): @@ -52,29 +56,31 @@ def prepare_track_path( def set_id3_tags( path: Path, - track: BasicTrackInfo, + track: Track, lyrics: Optional[str], album_cover: Optional[bytes], - domain: str, ) -> None: - if track.album.release_date is not None: - release_date = eyed3.core.Date(*track.album.release_date.timetuple()[:6]) + album = track.albums[0] + if album.release_date is not None: + datetime = dt.fromisoformat(album.release_date) + release_date = eyed3.core.Date(*datetime.timetuple()[:6]) else: - release_date = track.album.year + release_date = album.year audiofile = eyed3.load(path) assert audiofile tag = audiofile.initTag() - tag.artist = chr(0).join(a.name for a in track.artists) - tag.album_artist = track.album.artists[0].name - tag.album = track.album.title - tag.title = track.title - tag.track_num = track.number - tag.disc_num = track.disc_number + tag.artist = chr(0).join(a.name for a in track.artists if a.name) + tag.album_artist = album.artists[0].name + tag.album = full_title(album) + tag.title = full_title(track) + if position := album.track_position: + tag.track_num = position.index + tag.disc_num = position.volume tag.release_date = tag.original_release_date = release_date tag.encoded_by = ENCODED_BY - tag.audio_file_url = f"https://{domain}/album/{track.album.id}/track/{track.id}" + tag.audio_file_url = f"https://music.yandex.ru/album/{album.id}/track/{track.id}" if lyrics is not None: tag.lyrics.set(lyrics) @@ -84,56 +90,46 @@ def set_id3_tags( tag.save() -def setup_networking(enable_ipv6: bool): - requests.packages.urllib3.util.connection.HAS_IPV6 = enable_ipv6 # type: ignore - - -def setup_session( - session: Session, cookie_jar: CookieJar, user_agent: str, domain: str -) -> Session: - session.cookies = cookie_jar # type: ignore - session.headers["User-Agent"] = user_agent - session.headers["X-Retpath-Y"] = urllib.parse.quote_plus(f"https://{domain}") - return session - - def download_track( - client: YandexMusicApi, - track: BasicTrackInfo, + track: Track, target_path: Path, - covers_cache: dict[str, bytes], cover_resolution: int = DEFAULT_COVER_RESOLUTION, - hq: bool = False, + quality: int = 0, add_lyrics: bool = False, embed_cover: bool = False, + covers_cache: Optional[dict[int, bytes]] = None, ): - album = track.album + if embed_cover and covers_cache is None: + raise RuntimeError("covers_cache isn't provided") + covers_cache = typing.cast(dict[int, bytes], covers_cache) + album = track.albums[0] - url = client.get_track_download_url(track, hq) - http_utils.download_file(client.session, url, target_path) + download_info = track.get_download_info() + download_info = [e for e in download_info if e.codec == "mp3"] + download_info.sort(key=lambda e: e.bitrate_in_kbps) + target_info = download_info[-1] if quality == 1 else download_info[0] + track.download(str(target_path), bitrate_in_kbps=target_info.bitrate_in_kbps) lyrics = None - if add_lyrics and track.has_lyrics: - if isinstance(track, FullTrackInfo): - lyrics = track.lyrics - else: - full_track = client.get_full_track_info(track.id) - if full_track is not None: - lyrics = full_track.lyrics + if add_lyrics and (lyrics_info := track.lyrics_info): + if lyrics_info.has_available_text_lyrics: + if track_lyrics := track.get_lyrics(format="TEXT"): + lyrics = track_lyrics.fetch_lyrics() cover = None - cover_url = track.cover_info.cover_url(cover_resolution) - if cover_url is not None: + if track.cover_uri is not None: + cover_size = f"{cover_resolution}x{cover_resolution}" if embed_cover: - if cached_cover := covers_cache.get(album.id): + album_id = album.id + if album_id and (cached_cover := covers_cache.get(album_id)): cover = cached_cover else: - cover = covers_cache[album.id] = http_utils.download_bytes( - client.session, cover_url - ) + cover_bytes = track.download_cover_bytes(size=cover_size) + if album_id: + cover = covers_cache[album_id] = cover_bytes else: cover_path = target_path.parent / "cover.jpg" if not cover_path.is_file(): - http_utils.download_file(client.session, cover_url, cover_path) + track.download_cover(str(cover_path), cover_size) - set_id3_tags(target_path, track, lyrics, cover, client.domain) + set_id3_tags(target_path, track, lyrics, cover) diff --git a/ymd/http_utils.py b/ymd/http_utils.py deleted file mode 100644 index c616e51..0000000 --- a/ymd/http_utils.py +++ /dev/null @@ -1,14 +0,0 @@ -from pathlib import Path - -from requests import Session - - -def download_file(session: Session, url: str, path: Path) -> None: - resp = session.get(url) - with open(path, "wb") as f: - for chunk in resp.iter_content(chunk_size=1024): - f.write(chunk) - - -def download_bytes(session: Session, url: str) -> bytes: - return session.get(url).content diff --git a/ymd/ym_api/__init__.py b/ymd/ym_api/__init__.py deleted file mode 100644 index cd23f61..0000000 --- a/ymd/ym_api/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .api import * -from .models import * diff --git a/ymd/ym_api/api.py b/ymd/ym_api/api.py deleted file mode 100644 index 9b6bd8c..0000000 --- a/ymd/ym_api/api.py +++ /dev/null @@ -1,66 +0,0 @@ -import hashlib -import xml.etree.ElementTree as ET - -from requests import Session - -from .models import * - -MD5_SALT = "XGRlBW9FXlekgbPrRHuSiA" - - -class YandexMusicApi: - session: Session - domain: str - - def __init__(self, session: Session, domain: str) -> None: - self.session = session - self.domain = domain - - def get_track_download_url(self, track: BasicTrackInfo, hq: bool) -> str: - resp = self.session.get( - f"https://{self.domain}/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 = self.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_hash = hashlib.md5((MD5_SALT + path + s).encode()).hexdigest() - return f"https://{host}/get-mp3/{path_hash}/{ts}/{path}?track-id={track.id}" - - def get_full_track_info(self, track_id: str) -> Optional[FullTrackInfo]: - params = {"track": track_id, "lang": "ru"} - resp = self.session.get( - f"https://{self.domain}/handlers/track.jsx", params=params - ) - return FullTrackInfo.from_json(resp.json()) - - def get_full_album_info(self, album_id: str) -> FullAlbumInfo: - params = {"album": album_id, "lang": "ru"} - resp = self.session.get( - f"https://{self.domain}/handlers/album.jsx", params=params - ) - return FullAlbumInfo.from_json(resp.json()) - - def get_artist_info(self, artist_id: str) -> FullArtistInfo: - params = {"artist": artist_id, "what": "albums", "lang": "ru"} - resp = self.session.get( - f"https://{self.domain}/handlers/artist.jsx", params=params - ) - return FullArtistInfo.from_json(resp.json()) - - def get_playlist(self, playlist: PlaylistId) -> list[BasicTrackInfo]: - params = {"owner": playlist.owner, "kinds": playlist.kind, "lang": "ru"} - resp = self.session.get( - f"https://{self.domain}/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 deleted file mode 100644 index 8ab03a6..0000000 --- a/ymd/ym_api/models.py +++ /dev/null @@ -1,174 +0,0 @@ -import datetime as dt -from dataclasses import dataclass -from typing import Optional - -TITLE_TEMPLATE = "{title} ({version})" - - -@dataclass -class CoverInfo: - cover_url_template: Optional[str] - - def cover_url(self, resolution: int) -> Optional[str]: - if self.cover_url_template is None: - return - - return self.cover_url_template.replace("%%", f"{resolution}x{resolution}") - - @classmethod - 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}") - - -@dataclass -class PlaylistId: - owner: str - kind: int - - -@dataclass -class BasicArtistInfo: - id: str - name: str - - @classmethod - def from_json(cls, data: dict) -> "BasicArtistInfo": - return cls(id=data["id"], name=data["name"]) - - -@dataclass -class FullArtistInfo(BasicArtistInfo): - 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", [])) - 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) - - -@dataclass -class BasicAlbumInfo: - id: str - title: str - release_date: Optional[dt.datetime] - year: Optional[int] - artists: list[BasicArtistInfo] - meta_type: str - - @classmethod - def from_json(cls, data: dict) -> Optional["BasicAlbumInfo"]: - artists = parse_artists(data["artists"]) - title = parse_title(data) - 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, - ) - - -@dataclass -class BasicTrackInfo: - title: str - id: str - real_id: str - album: BasicAlbumInfo - number: int - disc_number: int - artists: list[BasicArtistInfo] - has_lyrics: bool - cover_info: CoverInfo - - @classmethod - def from_json(cls, data: dict) -> Optional["BasicTrackInfo"]: - if not data.get("available", False): - return None - track_id = str(data["id"]) - title = parse_title(data) - 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) - else: - 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, - ) - - -@dataclass -class FullTrackInfo(BasicTrackInfo): - lyrics: str - - @classmethod - def from_json(cls, data: dict) -> Optional["FullTrackInfo"]: - base = BasicTrackInfo.from_json(data["track"]) - if base is None: - return None - lyrics = data["lyric"][0]["fullLyrics"] - return cls(**base.__dict__, lyrics=lyrics) - - -@dataclass -class FullAlbumInfo(BasicAlbumInfo): - tracks: list[BasicTrackInfo] - - @classmethod - def from_json(cls, data: dict) -> "FullAlbumInfo": - base = BasicAlbumInfo.from_json(data) - 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] - return cls(**base.__dict__, tracks=tracks) - - -def parse_artists(data: list) -> list[BasicArtistInfo]: - artists = [] - for artist in data: - artists.append(artist) - if decomposed := artist.get("decomposed"): - for d_artist in decomposed: - if isinstance(d_artist, dict): - artists.append(d_artist) - return [BasicArtistInfo.from_json(a) for a in artists] - - -def parse_title(data: dict) -> str: - title = data["title"] - if version := data.get("version"): - title = TITLE_TEMPLATE.format(title=title, version=version) - return title