Загрузка cookies из браузера

- Номер версии поднят до 2.0.0
- Код отформатирован с помощью ruff
- Удален функционал кэширования
This commit is contained in:
Lev Plyusnin 2024-04-02 22:22:00 +07:00
parent e8cc0b1164
commit 8d095450b0
No known key found for this signature in database
GPG key ID: 21C6C2C9C0A4460D
8 changed files with 328 additions and 313 deletions

View file

@ -10,7 +10,6 @@
7. [Спасибо](#Спасибо) 7. [Спасибо](#Спасибо)
8. [Дисклеймер](#Дисклеймер) 8. [Дисклеймер](#Дисклеймер)
## О программе ## О программе
Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download). Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download).
@ -40,56 +39,43 @@ yandex-music-downloader --help
``` ```
## Получение данных для авторизации ## Получение данных для авторизации
Войдите в свой Яндекс аккаунт, затем проделайте следующие шаги: 1. Войдите в свой Яндекс аккаунт.
2. Передайте вашего браузера в качестве аргумента `--browser`
### Для Google Chrome/Chromium Если программа выдает ошибку при загрузке cookies - укажите путь к файлу
1. Перейдите на сайт Яндекс Музыки (https://music.yandex.ru) с cookies в качестве аргумента `--cookies-path`. Информацию о
2. Нажмите F12 расположении данного файла для вашего браузера вы можете найти в
3. Выберите вкладку Application интернете. Если что-то не получается - откройте issue.
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`
## Примеры использования ## Примеры использования
Во всех примерах замените `<ID сессии>` на значение куки `Session_id` Во всех примерах замените `<браузер>` на название своего браузера (Для
получения допустимых значений запустите программу с флагом `--help`)
### Скачать все треки [Twenty One Pilots](https://music.yandex.ru/artist/792433) в высоком качестве ### Скачать все треки [Twenty One Pilots](https://music.yandex.ru/artist/792433) в высоком качестве
``` ```
python3 main.py --session-id "<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) в высоком качестве, загружая тексты песен ### Скачать альбом [Nevermind](https://music.yandex.ru/album/294912) в высоком качестве, загружая тексты песен
``` ```
python3 main.py --session-id "<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) ### Скачать трек [Seven Nation Army](https://music.yandex.ru/album/11644078/track/6705392)
``` ```
python3 main.py --session-id "<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] usage: __main__.py [-h] [--hq] [--skip-existing] [--add-lyrics]
[--embed-cover] [--embed-cover] [--cover-resolution <Разрешение обложки>]
[--cover-resolution <Разрешение обложки>] [--delay <Задержка>] [--stick-to-artist] [--only-music]
[--delay <Задержка>] [--stick-to-artist]
[--only-music] [--enable-caching]
(--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 <Паттерн>]
[--path-pattern <Паттерн>] --session-id <ID --browser BROWSER [--cookies-path COOKIES_PATH]
сессии> [--spravka <Spravka>]
[--user-agent <User-Agent>] [--user-agent <User-Agent>]
Загрузчик музыки с сервиса Яндекс.Музыка Загрузчик музыки с сервиса Яндекс.Музыка
@ -104,14 +90,11 @@ options:
--embed-cover Встраивать обложку в .mp3 файл --embed-cover Встраивать обложку в .mp3 файл
--cover-resolution <Разрешение обложки> --cover-resolution <Разрешение обложки>
по умолчанию: 400 по умолчанию: 400
--delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 3) --delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 1)
--stick-to-artist Загружать альбомы, созданные только данным --stick-to-artist Загружать альбомы, созданные только данным
исполнителем исполнителем
--only-music Загружать только музыкальные альбомы (пропускать --only-music Загружать только музыкальные альбомы (пропускать
подкасты и аудиокниги) подкасты и аудиокниги)
--enable-caching Включить кэширование. Данная опция полезна при
нестабильном интернете. (кэш хранится в папке
/tmp/ymd)
ID: ID:
--artist-id <ID исполнителя> --artist-id <ID исполнителя>
@ -130,8 +113,14 @@ ID:
artist/#album/#number - #title) artist/#album/#number - #title)
Авторизация: Авторизация:
--session-id <ID сессии> --browser BROWSER Браузер из которого будут извлечены данные для
--spravka <Spravka> авторизации. Укажите браузер через который вы входили
в Яндекс Музыку. Допустимые значения: chrome, opera,
opera_gx, firefox, edge, safari, chromium, vivaldi,
librewolf
--cookies-path COOKIES_PATH
Путь к файлу с cookies. Используйте если возникает
ошибка получения cookies
--user-agent <User-Agent> --user-agent <User-Agent>
по умолчанию: Mozilla/5.0 (X11; Linux x86_64) по умолчанию: Mozilla/5.0 (X11; Linux x86_64)
AppleWebKit/537.36 (KHTML, like Gecko) AppleWebKit/537.36 (KHTML, like Gecko)
@ -139,16 +128,12 @@ ID:
``` ```
## Ошибка 400 ## Ошибка 400
Ниже приведена инструкция по устранению ошибки 400. Ниже приведена инструкция по устранению ошибки 400.
Перейдите на сайт Яндекс.Музыки, затем проделайте следующие шаги:
### Для Firefox 1. Перейдите на сайт Яндекс.Музыки
1. Нажмите на иконку замочка слева от адреса сайта 2. Прорешайте капчу
2. Нажмите `Clear cookies and site data` 3. Готово, теперь вы можете скачивать треки без ошибок
3. Нажмите `Remove`
4. Перезагрузите страницу Если проблема сохраняется - откройте issue.
5. Прорешайте капчу
6. Скопируйте значение куки `spravka` - передайте его как аргумент
`--spravka`
## Спасибо ## Спасибо
Разработчикам проекта [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-download](https://github.com/kaimi-io/yandex-music-download). Оттуда был взят [код хэширования](https://github.com/kaimi-io/yandex-music-download/blob/808443cb32be82e1f54b2f708884cb7c941b4371/src/ya.pl#L720).

View file

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

View file

@ -1,3 +1,3 @@
requests requests
requests-cache
eyed3 eyed3
browser-cookie3

View file

@ -9,158 +9,185 @@ import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import browser_cookie3
from browser_cookie3 import BrowserCookieError
from requests import Session from requests import Session
from requests_cache import CachedSession, FileCache
from ymd import core from ymd import core
from ymd.ym_api import BasicTrackInfo, PlaylistId, api 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_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_DELAY = 1
SUPPORTED_BROWSERS = [
"chrome",
"opera",
"opera_gx",
"firefox",
"edge",
"safari",
"chromium",
"vivaldi",
"librewolf",
]
CACHE_EXPIRE_AFTER = dt.timedelta(hours=8) 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+)$') TRACK_RE = re.compile(r"track/(\d+)$")
ALBUM_RE = re.compile(r'album/(\d+)$') ALBUM_RE = re.compile(r"album/(\d+)$")
ARTIST_RE = re.compile(r'artist/(\d+)$') ARTIST_RE = re.compile(r"artist/(\d+)$")
PLAYLIST_RE = re.compile(r'([\w\-._]+)/playlists/(\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: def args_playlist_id(arg: str) -> api.PlaylistId:
arr = arg.split('/') arr = arg.split("/")
return PlaylistId(owner=arr[0], kind=int(arr[1])) return PlaylistId(owner=arr[0], kind=int(arr[1]))
def help_str(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:
return default return default
return f'{text} ({default})' return f"{text} ({default})"
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Загрузчик музыки с сервиса Яндекс.Музыка') description="Загрузчик музыки с сервиса Яндекс.Музыка"
)
common_group = parser.add_argument_group('Общие параметры') 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.add_argument( common_group.add_argument(
'--delay', "--hq", action="store_true", help="Загружать треки в высоком качестве"
default=DEFAULT_DELAY, )
metavar='<Задержка>', 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, type=int,
help=help_str('Задержка между запросами, в секундах')) help=show_default(None),
common_group.add_argument('--stick-to-artist', )
action='store_true', common_group.add_argument(
help='Загружать альбомы, созданные' "--delay",
' только данным исполнителем') default=DEFAULT_DELAY,
common_group.add_argument('--only-music', metavar="<Задержка>",
action='store_true', type=int,
help='Загружать только музыкальные альбомы' help=show_default("Задержка между запросами, в секундах"),
' (пропускать подкасты и аудиокниги)') )
common_group.add_argument('--enable-caching', common_group.add_argument(
action='store_true', "--stick-to-artist",
help='Включить кэширование. Данная опция полезна' action="store_true",
' при нестабильном интернете.' help="Загружать альбомы, созданные" " только данным исполнителем",
f' (кэш хранится в папке {CACHE_DIR})') )
common_group.add_argument('--debug', common_group.add_argument(
action='store_true', "--only-music",
help=argparse.SUPPRESS) 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 = id_group_meta.add_mutually_exclusive_group(required=True)
id_group.add_argument('--artist-id', metavar='<ID исполнителя>') id_group.add_argument("--artist-id", metavar="<ID исполнителя>")
id_group.add_argument('--album-id', metavar='<ID альбома>') id_group.add_argument("--album-id", metavar="<ID альбома>")
id_group.add_argument('--track-id', metavar='<ID трека>') id_group.add_argument("--track-id", metavar="<ID трека>")
id_group.add_argument('--playlist-id', id_group.add_argument(
"--playlist-id",
type=args_playlist_id, type=args_playlist_id,
metavar='<владелец плейлиста>/<тип плейлиста>') metavar="<владелец плейлиста>/<тип плейлиста>",
id_group.add_argument('-u', )
'--url', id_group.add_argument("-u", "--url", help="URL исполнителя/альбома/трека/плейлиста")
help='URL исполнителя/альбома/трека/плейлиста')
path_group = parser.add_argument_group('Указание пути') 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.add_argument( path_group.add_argument(
'--path-pattern', "--unsafe-path",
default=core.DEFAULT_PATH_PATTERN, action="store_true",
metavar='<Паттерн>', help="Не очищать путь от недопустимых символов",
)
path_group.add_argument(
"--dir",
default=".",
metavar="<Папка>",
help=show_default("Папка для загрузки музыки"),
type=Path, type=Path,
help=help_str('Поддерживает следующие заполнители:' )
' #number, #artist, #album-artist, #title,' path_group.add_argument(
' #album, #year, #artist-id, #album-id, #track-id')) "--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 = parser.add_argument_group("Авторизация")
auth_group.add_argument('--session-id', auth_group.add_argument(
"--browser",
required=True, required=True,
metavar='<ID сессии>') help=(
auth_group.add_argument('--spravka', metavar='<Spravka>') "Браузер из которого будут извлечены данные для авторизации."
auth_group.add_argument('--user-agent', " Укажите браузер через который вы входили в Яндекс Музыку."
f" Допустимые значения: {', '.join(SUPPORTED_BROWSERS)}"
),
)
auth_group.add_argument(
"--cookies-path",
help="Путь к файлу с cookies. Используйте если возникает ошибка получения cookies",
)
auth_group.add_argument(
"--user-agent",
default=DEFAULT_USER_AGENT, default=DEFAULT_USER_AGENT,
metavar='<User-Agent>', metavar="<User-Agent>",
help=help_str()) help=show_default(),
)
args = parser.parse_args() args = parser.parse_args()
logging.basicConfig( logging.basicConfig(
format='%(asctime)s |%(levelname)s| %(name)s: %(message)s', format="%(asctime)s |%(levelname)s| %(name)s: %(message)s",
datefmt='%H:%M:%S', datefmt="%H:%M:%S",
level=logging.DEBUG if args.debug else logging.ERROR) level=logging.DEBUG if args.debug else logging.ERROR,
)
def response_hook(resp, **kwargs): def response_hook(resp, **kwargs):
del kwargs del kwargs
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
target_headers = ['application/json', 'text/xml'] target_headers = ["application/json", "text/xml"]
if any(h in resp.headers['Content-Type'] for h in target_headers): if any(h in resp.headers["Content-Type"] for h in target_headers):
logger.debug(resp.text) logger.debug(resp.text)
if not resp.ok: if not resp.ok:
print(f'Код ошибки: {resp.status_code}') print(f"Код ошибки: {resp.status_code}")
if resp.status_code == 400: if resp.status_code == 400:
print( 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) sys.exit(3)
if not getattr(resp, 'from_cache', False):
time.sleep(args.delay) time.sleep(args.delay)
def setup_session(session: Session): try:
core.setup_session(session, args.session_id, args.user_agent, cookies = getattr(browser_cookie3, args.browser)(cookie_file=args.cookies_path)
args.spravka) except BrowserCookieError:
session.hooks = {'response': response_hook} print(f"Не удалось получить cookies для браузера {args.browser}")
return 1
session = cached_session = Session() session = Session()
if args.enable_caching: core.setup_session(session, cookies, DEFAULT_USER_AGENT)
cached_session = CachedSession(backend=FileCache(cache_name=CACHE_DIR), session.hooks = {"response": response_hook}
expire_after=CACHE_EXPIRE_AFTER)
setup_session(session)
setup_session(cached_session)
result_tracks: list[BasicTrackInfo] = [] result_tracks: list[BasicTrackInfo] = []
@ -172,47 +199,48 @@ def main():
elif match := TRACK_RE.search(args.url): elif match := TRACK_RE.search(args.url):
args.track_id = match.group(1) args.track_id = match.group(1)
elif match := PLAYLIST_RE.search(args.url): elif match := PLAYLIST_RE.search(args.url):
args.playlist_id = PlaylistId(owner=match.group(1), args.playlist_id = PlaylistId(
kind=int(match.group(2))) owner=match.group(1), kind=int(match.group(2))
)
else: else:
print('Параметер url указан в неверном формате') print("Параметер url указан в неверном формате")
return 1 return 1
if args.artist_id is not None: 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 albums_count = 0
for album in artist_info.albums: for album in artist_info.albums:
if args.stick_to_artist and album.artists[ if args.stick_to_artist and album.artists[0].name != artist_info.name:
0].name != artist_info.name: print(
print(f'Альбом "{album.title}" пропущен' f'Альбом "{album.title}" пропущен' " из-за флага --stick-to-artist"
' из-за флага --stick-to-artist') )
continue continue
if args.only_music and album.meta_type != 'music': if args.only_music and album.meta_type != "music":
print(f'Альбом "{album.title}" пропущен' print(
' т.к. не является музыкальным') f'Альбом "{album.title}" пропущен' " т.к. не является музыкальным"
)
continue 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) result_tracks.extend(full_album.tracks)
albums_count += 1 albums_count += 1
print(artist_info.name) print(artist_info.name)
print(f'Альбомов: {albums_count}') print(f"Альбомов: {albums_count}")
elif args.album_id is not None: 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) print(album.title)
result_tracks = album.tracks result_tracks = album.tracks
elif args.track_id is not None: elif args.track_id is not None:
result_tracks = [ result_tracks = [api.get_full_track_info(session, args.track_id)]
api.get_full_track_info(cached_session, args.track_id)
]
elif args.playlist_id is not None: 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] = {} covers_cache: dict[str, bytes] = {}
for track in result_tracks: for track in result_tracks:
save_path = args.dir / core.prepare_track_path(args.path_pattern, save_path = args.dir / core.prepare_track_path(
track, args.unsafe_path) args.path_pattern, track, args.unsafe_path
)
if args.skip_existing and save_path.is_file(): if args.skip_existing and save_path.is_file():
continue continue
@ -220,12 +248,14 @@ def main():
if not save_dir.is_dir(): if not save_dir.is_dir():
save_dir.mkdir(parents=True) save_dir.mkdir(parents=True)
print(f'Загружается {save_path}') print(f"Загружается {save_path}")
core.download_track(session=session, core.download_track(
session=session,
track=track, track=track,
target_path=save_path, target_path=save_path,
hq=args.hq, hq=args.hq,
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,
)

View file

@ -1,5 +1,6 @@
import re import re
import urllib.parse import urllib.parse
from http.cookiejar import CookieJar
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -10,44 +11,47 @@ from requests import Session
from ymd import http_utils from ymd import http_utils
from ymd.ym_api import BasicTrackInfo, FullTrackInfo, api from ymd.ym_api import BasicTrackInfo, FullTrackInfo, api
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\-\'() ]+")
DEFAULT_PATH_PATTERN = Path('#album-artist', '#album', '#number - #title') DEFAULT_PATH_PATTERN = Path("#album-artist", "#album", "#number - #title")
DEFAULT_COVER_RESOLUTION = 400 DEFAULT_COVER_RESOLUTION = 400
def prepare_track_path(path_pattern: Path, def prepare_track_path(
track: BasicTrackInfo, path_pattern: Path, track: BasicTrackInfo, unsafe_path: bool = False
unsafe_path: bool = False) -> Path: ) -> Path:
path_str = str(path_pattern) path_str = str(path_pattern)
album = track.album album = track.album
artist = album.artists[0] artist = album.artists[0]
repl_dict = { repl_dict = {
'#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': track.number, "#number": track.number,
'#artist': artist.name, "#artist": artist.name,
'#title': track.title, "#title": track.title,
'#album': album.title, "#album": album.title,
'#year': album.year "#year": album.year,
} }
for placeholder, replacement in repl_dict.items(): for placeholder, replacement in repl_dict.items():
replacement = str(replacement) replacement = str(replacement)
if not unsafe_path: 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 = path_str.replace(placeholder, replacement)
path_str += '.mp3' path_str += ".mp3"
return Path(path_str) return Path(path_str)
def set_id3_tags(path: Path, track: BasicTrackInfo, lyrics: Optional[str], def set_id3_tags(
album_cover: Optional[bytes]) -> None: path: Path,
track: BasicTrackInfo,
lyrics: Optional[str],
album_cover: Optional[bytes],
) -> None:
if track.album.release_date is not None: if track.album.release_date is not None:
release_date = eyed3.core.Date( release_date = eyed3.core.Date(*track.album.release_date.timetuple()[:6])
*track.album.release_date.timetuple()[:6])
else: else:
release_date = track.album.year release_date = track.album.year
audiofile = eyed3.load(path) audiofile = eyed3.load(path)
@ -68,33 +72,28 @@ def set_id3_tags(path: Path, track: BasicTrackInfo, lyrics: Optional[str],
if lyrics is not None: if lyrics is not None:
tag.lyrics.set(lyrics) tag.lyrics.set(lyrics)
if album_cover is not None: 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() tag.save()
def setup_session(session: Session, def setup_session(session: Session, cookie_jar: CookieJar, user_agent: str) -> Session:
session_id: str, session.cookies = cookie_jar # type: ignore
user_agent: str, session.headers["User-Agent"] = user_agent
spravka: Optional[str] = None) -> Session: session.headers["X-Retpath-Y"] = urllib.parse.quote_plus("https://music.yandex.ru")
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')
return session return session
def download_track(session: Session, def download_track(
session: Session,
track: BasicTrackInfo, track: BasicTrackInfo,
target_path: Path, target_path: Path,
covers_cache: dict[str, bytes], covers_cache: dict[str, bytes],
cover_resolution: int = DEFAULT_COVER_RESOLUTION, cover_resolution: int = DEFAULT_COVER_RESOLUTION,
hq: bool = False, hq: bool = False,
add_lyrics: bool = False, add_lyrics: bool = False,
embed_cover: bool = False): embed_cover: bool = False,
):
album = track.album album = track.album
url = api.get_track_download_url(session, track, hq) url = api.get_track_download_url(session, track, hq)
@ -116,9 +115,10 @@ def download_track(session: Session,
cover = cached_cover cover = cached_cover
else: else:
cover = covers_cache[album.id] = http_utils.download_bytes( cover = covers_cache[album.id] = http_utils.download_bytes(
session, cover_url) session, cover_url
)
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(session, cover_url, cover_path) http_utils.download_file(session, cover_url, cover_path)

View file

@ -5,7 +5,7 @@ from requests import Session
def download_file(session: Session, url: str, path: Path) -> None: def download_file(session: Session, url: str, path: Path) -> None:
resp = session.get(url) 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): for chunk in resp.iter_content(chunk_size=1024):
f.write(chunk) f.write(chunk)

View file

@ -5,54 +5,50 @@ from requests import Session
from .models import * from .models import *
MD5_SALT = 'XGRlBW9FXlekgbPrRHuSiA' MD5_SALT = "XGRlBW9FXlekgbPrRHuSiA"
def get_track_download_url(session: Session, track: BasicTrackInfo, def get_track_download_url(session: Session, track: BasicTrackInfo, hq: bool) -> str:
hq: bool) -> str: resp = session.get(
resp = session.get('https://music.yandex.ru/api/v2.1/handlers/track' "https://music.yandex.ru/api/v2.1/handlers/track"
f'/{track.id}:{track.album.id}' f"/{track.id}:{track.album.id}"
'/web-album_track-track-track-main/download/m' "/web-album_track-track-track-main/download/m"
f'?hq={int(hq)}') f"?hq={int(hq)}"
url_info_src = resp.json()['src'] )
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) url_info = ET.fromstring(resp.text)
path = url_info.find('path').text[1:] path = url_info.find("path").text[1:]
s = url_info.find('s').text s = url_info.find("s").text
ts = url_info.find('ts').text ts = url_info.find("ts").text
host = url_info.find('host').text host = url_info.find("host").text
path_hash = hashlib.md5((MD5_SALT + path + s).encode()).hexdigest() 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: def get_full_track_info(session: Session, track_id: str) -> FullTrackInfo:
params = {'track': track_id, 'lang': 'ru'} params = {"track": track_id, "lang": "ru"}
resp = session.get('https://music.yandex.ru/handlers/track.jsx', resp = session.get("https://music.yandex.ru/handlers/track.jsx", params=params)
params=params)
return FullTrackInfo.from_json(resp.json()) return FullTrackInfo.from_json(resp.json())
def get_full_album_info(session: Session, album_id: str) -> FullAlbumInfo: def get_full_album_info(session: Session, album_id: str) -> FullAlbumInfo:
params = {'album': album_id, 'lang': 'ru'} params = {"album": album_id, "lang": "ru"}
resp = session.get('https://music.yandex.ru/handlers/album.jsx', resp = session.get("https://music.yandex.ru/handlers/album.jsx", params=params)
params=params)
return FullAlbumInfo.from_json(resp.json()) return FullAlbumInfo.from_json(resp.json())
def get_artist_info(session: Session, artist_id: str) -> FullArtistInfo: def get_artist_info(session: Session, artist_id: str) -> FullArtistInfo:
params = {'artist': artist_id, 'what': 'albums', 'lang': 'ru'} params = {"artist": artist_id, "what": "albums", "lang": "ru"}
resp = session.get('https://music.yandex.ru/handlers/artist.jsx', resp = session.get("https://music.yandex.ru/handlers/artist.jsx", params=params)
params=params)
return FullArtistInfo.from_json(resp.json()) return FullArtistInfo.from_json(resp.json())
def get_playlist(session: Session, def get_playlist(session: Session, playlist: PlaylistId) -> list[BasicTrackInfo]:
playlist: PlaylistId) -> list[BasicTrackInfo]: params = {"owner": playlist.owner, "kinds": playlist.kind, "lang": "ru"}
params = {'owner': playlist.owner, 'kinds': playlist.kind, 'lang': 'ru'} resp = session.get("https://music.yandex.ru/handlers/playlist.jsx", params=params)
resp = session.get('https://music.yandex.ru/handlers/playlist.jsx', raw_tracks = resp.json()["playlist"].get("tracks", [])
params=params)
raw_tracks = resp.json()['playlist'].get('tracks', [])
tracks = map(BasicTrackInfo.from_json, raw_tracks) tracks = map(BasicTrackInfo.from_json, raw_tracks)
tracks = [t for t in tracks if t is not None] tracks = [t for t in tracks if t is not None]
return tracks return tracks

View file

@ -2,7 +2,7 @@ import datetime as dt
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
TITLE_TEMPLATE = '{title} ({version})' TITLE_TEMPLATE = "{title} ({version})"
@dataclass @dataclass
@ -13,15 +13,14 @@ class CoverInfo:
if self.cover_url_template is None: if self.cover_url_template is None:
return return
return self.cover_url_template.replace('%%', return self.cover_url_template.replace("%%", f"{resolution}x{resolution}")
f'{resolution}x{resolution}')
@classmethod @classmethod
def from_json(cls, data: dict) -> 'CoverInfo': def from_json(cls, data: dict) -> "CoverInfo":
og_image = data.get("ogImage") og_image = data.get("ogImage")
if og_image is None or og_image == "": if og_image is None or og_image == "":
return cls(None) return cls(None)
return cls(f'https://{og_image}') return cls(f"https://{og_image}")
@dataclass @dataclass
@ -36,19 +35,19 @@ class BasicArtistInfo:
name: str name: str
@classmethod @classmethod
def from_json(cls, data: dict) -> 'BasicArtistInfo': def from_json(cls, data: dict) -> "BasicArtistInfo":
return cls(id=data['id'], name=data['name']) return cls(id=data["id"], name=data["name"])
@dataclass @dataclass
class FullArtistInfo(BasicArtistInfo): class FullArtistInfo(BasicArtistInfo):
albums: list['BasicAlbumInfo'] albums: list["BasicAlbumInfo"]
cover_info: CoverInfo cover_info: CoverInfo
@classmethod @classmethod
def from_json(cls, data: dict) -> 'FullArtistInfo': def from_json(cls, data: dict) -> "FullArtistInfo":
base = BasicArtistInfo.from_json(data['artist']) base = BasicArtistInfo.from_json(data["artist"])
albums = map(BasicAlbumInfo.from_json, data.get('albums', [])) albums = map(BasicAlbumInfo.from_json, data.get("albums", []))
albums = [a for a in albums if a is not None] albums = [a for a in albums if a is not None]
cover_info = CoverInfo.from_json(data) cover_info = CoverInfo.from_json(data)
return cls(**base.__dict__, cover_info=cover_info, albums=albums) return cls(**base.__dict__, cover_info=cover_info, albums=albums)
@ -64,17 +63,19 @@ class BasicAlbumInfo:
meta_type: str meta_type: str
@classmethod @classmethod
def from_json(cls, data: dict) -> Optional['BasicAlbumInfo']: def from_json(cls, data: dict) -> Optional["BasicAlbumInfo"]:
artists = parse_artists(data['artists']) artists = parse_artists(data["artists"])
title = parse_title(data) title = parse_title(data)
if release_date := data.get('releaseDate'): if release_date := data.get("releaseDate"):
release_date = dt.datetime.fromisoformat(release_date) release_date = dt.datetime.fromisoformat(release_date)
return cls(id=data['id'], return cls(
id=data["id"],
title=title, title=title,
year=data.get('year'), year=data.get("year"),
meta_type=data['metaType'], meta_type=data["metaType"],
artists=artists, artists=artists,
release_date=release_date) release_date=release_date,
)
@dataclass @dataclass
@ -90,43 +91,46 @@ class BasicTrackInfo:
cover_info: CoverInfo cover_info: CoverInfo
@classmethod @classmethod
def from_json(cls, data: dict) -> Optional['BasicTrackInfo']: def from_json(cls, data: dict) -> Optional["BasicTrackInfo"]:
if not data['available']: if not data["available"]:
return None return None
track_id = str(data['id']) track_id = str(data["id"])
title = parse_title(data) title = parse_title(data)
albums_data = data['albums'] albums_data = data["albums"]
artists = parse_artists(data['artists']) artists = parse_artists(data["artists"])
track_position = {'index': 1, 'volume': 1} track_position = {"index": 1, "volume": 1}
if len(albums_data): if len(albums_data):
album_data = albums_data[0] album_data = albums_data[0]
album = BasicAlbumInfo.from_json(album_data) album = BasicAlbumInfo.from_json(album_data)
track_position = album_data.get('trackPosition', track_position) track_position = album_data.get("trackPosition", track_position)
else: else:
album = BasicAlbumInfo(id=track_id, album = BasicAlbumInfo(
id=track_id,
title=title, title=title,
release_date=None, release_date=None,
year=None, year=None,
meta_type='music', meta_type="music",
artists=artists) artists=artists,
)
if album is None: if album is None:
raise ValueError raise ValueError
cover_info = CoverInfo.from_json(data) cover_info = CoverInfo.from_json(data)
has_lyrics = data.get('lyricsInfo', {}).get('hasAvailableTextLyrics', has_lyrics = data.get("lyricsInfo", {}).get("hasAvailableTextLyrics", False)
False) return cls(
return cls(title=title, title=title,
id=track_id, id=track_id,
real_id=data['realId'], real_id=data["realId"],
number=track_position['index'], number=track_position["index"],
disc_number=track_position['volume'], disc_number=track_position["volume"],
artists=artists, artists=artists,
album=album, album=album,
has_lyrics=has_lyrics, has_lyrics=has_lyrics,
cover_info=cover_info) cover_info=cover_info,
)
@property @property
def url(self) -> str: 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 @dataclass
@ -134,9 +138,9 @@ class FullTrackInfo(BasicTrackInfo):
lyrics: str lyrics: str
@classmethod @classmethod
def from_json(cls, data: dict) -> 'FullTrackInfo': def from_json(cls, data: dict) -> "FullTrackInfo":
base = BasicTrackInfo.from_json(data['track']) base = BasicTrackInfo.from_json(data["track"])
lyrics = data['lyric'][0]['fullLyrics'] lyrics = data["lyric"][0]["fullLyrics"]
return cls(**base.__dict__, lyrics=lyrics) return cls(**base.__dict__, lyrics=lyrics)
@ -145,9 +149,9 @@ class FullAlbumInfo(BasicAlbumInfo):
tracks: list[BasicTrackInfo] tracks: list[BasicTrackInfo]
@classmethod @classmethod
def from_json(cls, data: dict) -> 'FullAlbumInfo': def from_json(cls, data: dict) -> "FullAlbumInfo":
base = BasicAlbumInfo.from_json(data) base = BasicAlbumInfo.from_json(data)
tracks = data.get('volumes', []) tracks = data.get("volumes", [])
tracks = [t for v in tracks for t in v] tracks = [t for v in tracks for t in v]
tracks = map(BasicTrackInfo.from_json, tracks) tracks = map(BasicTrackInfo.from_json, tracks)
tracks = [t for t in tracks if t is not None] tracks = [t for t in tracks if t is not None]
@ -158,7 +162,7 @@ def parse_artists(data: list) -> list[BasicArtistInfo]:
artists = [] artists = []
for artist in data: for artist in data:
artists.append(artist) artists.append(artist)
if decomposed := artist.get('decomposed'): if decomposed := artist.get("decomposed"):
for d_artist in decomposed: for d_artist in decomposed:
if isinstance(d_artist, dict): if isinstance(d_artist, dict):
artists.append(d_artist) artists.append(d_artist)
@ -166,7 +170,7 @@ def parse_artists(data: list) -> list[BasicArtistInfo]:
def parse_title(data: dict) -> str: def parse_title(data: dict) -> str:
title = data['title'] title = data["title"]
if version := data.get('version'): if version := data.get("version"):
title = TITLE_TEMPLATE.format(title=title, version=version) title = TITLE_TEMPLATE.format(title=title, version=version)
return title return title