Авторизация через мобильный токен

This commit is contained in:
Lev Plyusnin 2024-11-10 14:47:48 +07:00
parent 4de447950b
commit cc472b21f7
No known key found for this signature in database
GPG key ID: 21C6C2C9C0A4460D
9 changed files with 177 additions and 507 deletions

11
MIGRATION.md Normal file
View file

@ -0,0 +1,11 @@
# Переход на новую версию
## v2 -> v3
- Для авторизации используйте аргумент `--token`. Аргументы `--browser`,
`--user-agent`, `--cookies-path` больше не являются валидными,
авторизация через cookies невозможна.
- Удален аргумент `--hq`. Используйте `--quality 1`.
- Удален аргумент `--disable-ipv6`
- Задержка между запросами по умолчанию равна 0. Вы можете убрать
аргумент `--delay`, на мобильный токен не распространяются жесткие
ограничения на количество запросов.

View file

@ -1,14 +1,17 @@
# yandex-music-downloader # yandex-music-downloader
> Внимание! В версии v3 был изменен способ авторизации и некоторые
> аргументы. Смотрите [MIGRATION.md](MIGRATION.md) для получения информации
> об изменениях
## Содержание ## Содержание
1. [О программе](#О-программе) 1. [О программе](#О-программе)
2. [Установка](#Установка) 2. [Установка](#Установка)
3. [Получение данных для авторизации](#Получение-данных-для-авторизации) 3. [Получение данных для авторизации](#Получение-данных-для-авторизации)
4. [Примеры использования](#Примеры-использования) 4. [Примеры использования](#Примеры-использования)
5. [Использование](#Использование) 5. [Спасибо](#Спасибо)
6. [Что делать при ошибке 400?](#Ошибка-400) 6. [Использование](#Использование)
7. [Спасибо](#Спасибо) 7. [Дисклеймер](#Дисклеймер)
8. [Дисклеймер](#Дисклеймер)
## О программе ## О программе
Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download). Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download).
@ -25,7 +28,7 @@
- Название трека - Название трека
- Исполнитель - Исполнитель
- Дополнительные исполнители - Дополнительные исполнители
- Год выпуска альбома - Дата выпуска альбома
- Обложка альбома - Обложка альбома
- Название альбома - Название альбома
- Текст песни (при использовании флага `--add-lyrics`) - Текст песни (при использовании флага `--add-lyrics`)
@ -39,47 +42,36 @@ yandex-music-downloader --help
``` ```
## Получение данных для авторизации ## Получение данных для авторизации
1. Войдите в свой Яндекс аккаунт. https://yandex-music.readthedocs.io/en/main/token.html
2. Передайте название вашего браузера в качестве аргумента `--browser`
Если программа выдает ошибку при загрузке cookies - укажите путь к файлу
с cookies в качестве аргумента `--cookies-path`. Информацию о
расположении данного файла для вашего браузера вы можете найти в
интернете. Если что-то не получается - откройте issue.
## Примеры использования ## Примеры использования
Во всех примерах замените `<браузер>` на название своего браузера (для Во всех примерах замените `<Токен>` на ваш токен.
получения допустимых значений запустите программу с флагом `--help`)
### Скачать все треки [Twenty One Pilots](https://music.yandex.ru/artist/792433) в высоком качестве ### Скачать все треки [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) в высоком качестве, загружая тексты песен ### Скачать альбом [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) ### Скачать трек [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] usage: yandex-music-downloader [-h] [--quality <Качество>] [--skip-existing]
[--embed-cover] [--add-lyrics] [--embed-cover]
[--cover-resolution <Разрешение обложки>] [--cover-resolution <Разрешение обложки>]
[--delay <Задержка>] [--stick-to-artist] [--delay <Задержка>] [--stick-to-artist]
[--only-music] [--disable-ipv6] [--only-music]
(--artist-id <ID исполнителя> | --album-id <ID альбома> | --track-id <ID трека> | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL) (--artist-id <ID исполнителя> | --album-id <ID альбома> | --track-id <ID трека> | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL)
[--unsafe-path] [--dir <Папка>] [--unsafe-path] [--dir <Папка>]
[--path-pattern <Паттерн>] --browser BROWSER [--path-pattern <Паттерн>] --token <Токен>
[--cookies-path COOKIES_PATH]
[--user-agent <User-Agent>]
Загрузчик музыки с сервиса Яндекс.Музыка Загрузчик музыки с сервиса Яндекс.Музыка
@ -87,19 +79,18 @@ options:
-h, --help show this help message and exit -h, --help show this help message and exit
Общие параметры: Общие параметры:
--hq Загружать треки в высоком качестве --quality <Качество> Качество трека:
0 - Низкое (mp3 128kbps)
1 - Высокое (mp3 320kbps)
(по умолчанию: 0)
--skip-existing Пропускать уже загруженные треки --skip-existing Пропускать уже загруженные треки
--add-lyrics Загружать тексты песен --add-lyrics Загружать тексты песен
--embed-cover Встраивать обложку в .mp3 файл --embed-cover Встраивать обложку в .mp3 файл
--cover-resolution <Разрешение обложки> --cover-resolution <Разрешение обложки>
по умолчанию: 400 по умолчанию: 400
--delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 3) --delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 0)
--stick-to-artist Загружать альбомы, созданные только данным --stick-to-artist Загружать альбомы, созданные только данным исполнителем
исполнителем --only-music Загружать только музыкальные альбомы (пропускать подкасты и аудиокниги)
--only-music Загружать только музыкальные альбомы (пропускать
подкасты и аудиокниги)
--disable-ipv6 Отключить IPv6. Используйте при проблемах с
подключением
ID: ID:
--artist-id <ID исполнителя> --artist-id <ID исполнителя>
@ -112,36 +103,14 @@ ID:
--unsafe-path Не очищать путь от недопустимых символов --unsafe-path Не очищать путь от недопустимых символов
--dir <Папка> Папка для загрузки музыки (по умолчанию: .) --dir <Папка> Папка для загрузки музыки (по умолчанию: .)
--path-pattern <Паттерн> --path-pattern <Паттерн>
Поддерживает следующие заполнители: #number, #artist, Поддерживает следующие заполнители: #number, #artist, #album-artist, #title, #album, #year, #artist-id, #album-id, #track-id, #number-padded (по умолчанию: #album-artist/#album/#number - #title)
#album-artist, #title, #album, #year, #artist-id,
#album-id, #track-id, #track-number (по умолчанию:
#album-artist/#album/#number - #title)
Авторизация: Авторизация:
--browser BROWSER Браузер из которого будут извлечены данные для --token <Токен> Токен для авторизации. См. README для способов получения
авторизации. Укажите браузер через который вы входили
в Яндекс Музыку. Допустимые значения: chrome, opera,
opera_gx, firefox, edge, safari, chromium, vivaldi,
librewolf
--cookies-path COOKIES_PATH
Путь к файлу с cookies. Используйте если возникает
ошибка получения cookies
--user-agent <User-Agent>
по умолчанию: Mozilla/5.0 (X11; Linux x86_64)
AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/106.0.0.0 Safari/537.36
``` ```
## Ошибка 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)
## Дисклеймер ## Дисклеймер
Данный проект является независимой разработкой и никак не связан с компанией Яндекс. Данный проект является независимой разработкой и никак не связан с компанией Яндекс.

View file

@ -7,14 +7,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "yandex-music-downloader" name = "yandex-music-downloader"
version = "2.0.2" version = "3.0.0b0"
description = "Загрузчик музыки с сервиса Яндекс.Музыка" description = "Загрузчик музыки с сервиса Яндекс.Музыка"
requires-python = ">=3.9" requires-python = ">=3.9"
readme = "README.md" readme = "README.md"
dependencies = [ dependencies = [
"requests", "yandex-music",
"eyed3", "eyed3"
"browser-cookie3"
] ]
[project.scripts] [project.scripts]

View file

@ -1,39 +1,20 @@
#!/bin/python3 #!/bin/python3
import argparse import argparse
import datetime as dt import itertools
import logging import logging
import re import re
import sys
import tempfile
import time import time
import typing
from collections.abc import Generator, Iterable
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import browser_cookie3 from yandex_music import Album, Client, Playlist, Track
from browser_cookie3 import BrowserCookieError
from requests import Session
from ymd import core 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 = 0
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"
TRACK_RE = re.compile(r"track/(\d+)") TRACK_RE = re.compile(r"track/(\d+)")
ALBUM_RE = re.compile(r"album/(\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") 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: def show_default(text: Optional[str] = None) -> str:
default = "по умолчанию: %(default)s" default = "по умолчанию: %(default)s"
if text is None: if text is None:
@ -57,12 +33,17 @@ def show_default(text: Optional[str] = None) -> str:
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Загрузчик музыки с сервиса Яндекс.Музыка" description="Загрузчик музыки с сервиса Яндекс.Музыка",
formatter_class=argparse.RawTextHelpFormatter,
) )
common_group = parser.add_argument_group("Общие параметры") common_group = parser.add_argument_group("Общие параметры")
common_group.add_argument( 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( common_group.add_argument(
"--skip-existing", action="store_true", help="Пропускать уже загруженные треки" "--skip-existing", action="store_true", help="Пропускать уже загруженные треки"
@ -98,11 +79,6 @@ def main():
help="Загружать только музыкальные альбомы" help="Загружать только музыкальные альбомы"
" (пропускать подкасты и аудиокниги)", " (пропускать подкасты и аудиокниги)",
) )
common_group.add_argument(
"--disable-ipv6",
action="store_true",
help="Отключить IPv6. Используйте при проблемах с подключением",
)
common_group.add_argument("--debug", action="store_true", help=argparse.SUPPRESS) 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")
@ -112,7 +88,6 @@ def main():
id_group.add_argument("--track-id", metavar="<ID трека>") id_group.add_argument("--track-id", metavar="<ID трека>")
id_group.add_argument( id_group.add_argument(
"--playlist-id", "--playlist-id",
type=args_playlist_id,
metavar="<владелец плейлиста>/<тип плейлиста>", metavar="<владелец плейлиста>/<тип плейлиста>",
) )
id_group.add_argument("-u", "--url", help="URL исполнителя/альбома/трека/плейлиста") id_group.add_argument("-u", "--url", help="URL исполнителя/альбома/трека/плейлиста")
@ -138,29 +113,16 @@ def main():
help=show_default( help=show_default(
"Поддерживает следующие заполнители:" "Поддерживает следующие заполнители:"
" #number, #artist, #album-artist, #title," " #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 = parser.add_argument_group("Авторизация")
auth_group.add_argument( auth_group.add_argument(
"--browser", "--token",
required=True, required=True,
help=( metavar="<Токен>",
"Браузер из которого будут извлечены данные для авторизации." help="Токен для авторизации. См. README для способов получения",
" Укажите браузер через который вы входили в Яндекс Музыку."
f" Допустимые значения: {', '.join(SUPPORTED_BROWSERS)}"
),
)
auth_group.add_argument(
"--cookies-path",
help="Путь к файлу с cookies. Используйте если возникает ошибка получения cookies",
)
auth_group.add_argument(
"--user-agent",
default=DEFAULT_USER_AGENT,
metavar="<User-Agent>",
help=show_default(),
) )
args = parser.parse_args() args = parser.parse_args()
@ -171,30 +133,6 @@ def main():
level=logging.DEBUG if args.debug else logging.ERROR, 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: if args.url is not None:
parsed_url = urlparse(args.url) parsed_url = urlparse(args.url)
path = parsed_url.path path = parsed_url.path
@ -205,65 +143,77 @@ def main():
elif match := TRACK_RE.search(path): elif match := TRACK_RE.search(path):
args.track_id = match.group(1) args.track_id = match.group(1)
elif match := PLAYLIST_RE.search(path): elif match := PLAYLIST_RE.search(path):
args.playlist_id = PlaylistId( args.playlist_id = match.group(1) + "/" + match.group(2)
owner=match.group(1), kind=int(match.group(2))
)
else: else:
print("Параметер url указан в неверном формате") print("Параметер url указан в неверном формате")
return 1 return 1
domain = parsed_url.hostname
session = Session() client = Client(args.token).init()
core.setup_networking(enable_ipv6=not args.disable_ipv6) result_tracks: Iterable[Track] = []
core.setup_session(session, cookies, DEFAULT_USER_AGENT, domain)
session.hooks = {"response": response_hook} def album_tracks_gen(album_ids: Iterable[int | str]) -> Generator[Track]:
client = YandexMusicApi(session, domain) 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: if args.artist_id is not None:
artist_info = client.get_artist_info(args.artist_id)
albums_count = 0 def filter_album(album: Album) -> bool:
for album in artist_info.albums: title = album.title
if args.stick_to_artist and album.artists[0].name != artist_info.name: if album.id is None or not album.available:
print( print(f'Альбом "{title}" не доступен для скачивания')
f'Альбом "{album.title}" пропущен' " из-за флага --stick-to-artist" elif args.only_music and album.meta_type != "music":
) print(f'Альбом "{title}" пропущен' " т.к. не является музыкальным")
continue elif args.stick_to_artist and album.artists[0].id != int(args.artist_id):
if args.only_music and album.meta_type != "music": print(f'Альбом "{title}" пропущен' " из-за флага --stick-to-artist")
print( else:
f'Альбом "{album.title}" пропущен' " т.к. не является музыкальным" return True
) return False
continue
full_album = client.get_full_album_info(album.id) def albums_gen() -> Generator[Album]:
result_tracks.extend(full_album.tracks) has_next = True
albums_count += 1 page = 0
print(artist_info.name) while has_next:
print(f"Альбомов: {albums_count}") 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: elif args.album_id is not None:
album = client.get_full_album_info(args.album_id) result_tracks = album_tracks_gen((args.album_id,))
print(album.title)
result_tracks = album.tracks
elif args.track_id is not None: elif args.track_id is not None:
track = client.get_full_track_info(args.track_id) track = client.tracks(args.track_id)
if track is not None: result_tracks = track
result_tracks = [track]
else:
logger.info("Трек не доступен для скачивания")
return 1
elif args.playlist_id is not None: 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] = {} result_tracks = playlist_tracks_gen()
track_number = 0
track_number_pad = len(str(len(result_tracks))) covers_cache: dict[int, bytes] = {}
for track in result_tracks: 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( save_path = args.dir / core.prepare_track_path(
args.path_pattern, args.path_pattern,
track, track,
args.unsafe_path, args.unsafe_path,
str(track_number).zfill(track_number_pad),
) )
if args.skip_existing and save_path.is_file(): if args.skip_existing and save_path.is_file():
continue continue
@ -274,12 +224,13 @@ def main():
print(f"Загружается {save_path}") print(f"Загружается {save_path}")
core.download_track( core.download_track(
client=client,
track=track, track=track,
target_path=save_path, target_path=save_path,
hq=args.hq, quality=args.quality,
add_lyrics=args.add_lyrics, add_lyrics=args.add_lyrics,
embed_cover=args.embed_cover, embed_cover=args.embed_cover,
cover_resolution=args.cover_resolution, cover_resolution=args.cover_resolution,
covers_cache=covers_cache, covers_cache=covers_cache,
) )
if args.delay > 0:
time.sleep(args.delay)

View file

@ -1,17 +1,12 @@
import re import re
import urllib.parse import typing
from http.cookiejar import CookieJar from datetime import datetime as dt
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Union
import eyed3 import eyed3
import requests
from eyed3.id3.frames import ImageFrame from eyed3.id3.frames import ImageFrame
from requests import Session from yandex_music import Track, YandexMusicObject
from ymd import http_utils
from ymd.ym_api import BasicTrackInfo, FullTrackInfo
from ymd.ym_api.api import YandexMusicApi
ENCODED_BY = "https://github.com/llistochek/yandex-music-downloader" ENCODED_BY = "https://github.com/llistochek/yandex-music-downloader"
FILENAME_CLEAR_RE = re.compile(r"[^\w\-\'() ]+") FILENAME_CLEAR_RE = re.compile(r"[^\w\-\'() ]+")
@ -20,25 +15,34 @@ DEFAULT_PATH_PATTERN = Path("#album-artist", "#album", "#number - #title")
DEFAULT_COVER_RESOLUTION = 400 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( def prepare_track_path(
path_pattern: Path, path_pattern: Path, track: Track, unsafe_path: bool = False
track: BasicTrackInfo,
unsafe_path: bool = False,
track_number_str: str = "",
) -> Path: ) -> Path:
path_str = str(path_pattern) path_str = str(path_pattern)
album = track.album album = track.albums[0]
artist = album.artists[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, "#album-artist": album.artists[0].name,
"#artist-id": artist.name, "#artist-id": artist.name,
"#album-id": album.id, "#album-id": album.id,
"#track-id": track.id, "#track-id": track.id,
"#number-padded": track_number_str, "#number": track_position.index if track_position else None,
"#number": track.number,
"#artist": artist.name, "#artist": artist.name,
"#title": track.title, "#title": full_title(track),
"#album": album.title, "#album": full_title(album),
"#year": album.year, "#year": album.year,
} }
for placeholder, replacement in repl_dict.items(): for placeholder, replacement in repl_dict.items():
@ -52,29 +56,31 @@ def prepare_track_path(
def set_id3_tags( def set_id3_tags(
path: Path, path: Path,
track: BasicTrackInfo, track: Track,
lyrics: Optional[str], lyrics: Optional[str],
album_cover: Optional[bytes], album_cover: Optional[bytes],
domain: str,
) -> None: ) -> None:
if track.album.release_date is not None: album = track.albums[0]
release_date = eyed3.core.Date(*track.album.release_date.timetuple()[:6]) if album.release_date is not None:
datetime = dt.fromisoformat(album.release_date)
release_date = eyed3.core.Date(*datetime.timetuple()[:6])
else: else:
release_date = track.album.year release_date = album.year
audiofile = eyed3.load(path) audiofile = eyed3.load(path)
assert audiofile assert audiofile
tag = audiofile.initTag() tag = audiofile.initTag()
tag.artist = chr(0).join(a.name for a in track.artists) tag.artist = chr(0).join(a.name for a in track.artists if a.name)
tag.album_artist = track.album.artists[0].name tag.album_artist = album.artists[0].name
tag.album = track.album.title tag.album = full_title(album)
tag.title = track.title tag.title = full_title(track)
tag.track_num = track.number if position := album.track_position:
tag.disc_num = track.disc_number tag.track_num = position.index
tag.disc_num = position.volume
tag.release_date = tag.original_release_date = release_date tag.release_date = tag.original_release_date = release_date
tag.encoded_by = ENCODED_BY 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: if lyrics is not None:
tag.lyrics.set(lyrics) tag.lyrics.set(lyrics)
@ -84,56 +90,46 @@ def set_id3_tags(
tag.save() 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( def download_track(
client: YandexMusicApi, track: Track,
track: BasicTrackInfo,
target_path: Path, target_path: Path,
covers_cache: dict[str, bytes],
cover_resolution: int = DEFAULT_COVER_RESOLUTION, cover_resolution: int = DEFAULT_COVER_RESOLUTION,
hq: bool = False, quality: int = 0,
add_lyrics: bool = False, add_lyrics: bool = False,
embed_cover: 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) download_info = track.get_download_info()
http_utils.download_file(client.session, url, target_path) 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 lyrics = None
if add_lyrics and track.has_lyrics: if add_lyrics and (lyrics_info := track.lyrics_info):
if isinstance(track, FullTrackInfo): if lyrics_info.has_available_text_lyrics:
lyrics = track.lyrics if track_lyrics := track.get_lyrics(format="TEXT"):
else: lyrics = track_lyrics.fetch_lyrics()
full_track = client.get_full_track_info(track.id)
if full_track is not None:
lyrics = full_track.lyrics
cover = None cover = None
cover_url = track.cover_info.cover_url(cover_resolution) if track.cover_uri is not None:
if cover_url is not None: cover_size = f"{cover_resolution}x{cover_resolution}"
if embed_cover: 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 cover = cached_cover
else: else:
cover = covers_cache[album.id] = http_utils.download_bytes( cover_bytes = track.download_cover_bytes(size=cover_size)
client.session, cover_url if album_id:
) cover = covers_cache[album_id] = cover_bytes
else: else:
cover_path = target_path.parent / "cover.jpg" cover_path = target_path.parent / "cover.jpg"
if not cover_path.is_file(): 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)

View file

@ -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

View file

@ -1,2 +0,0 @@
from .api import *
from .models import *

View file

@ -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

View file

@ -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