Авторизация через мобильный токен
This commit is contained in:
parent
4de447950b
commit
cc472b21f7
9 changed files with 177 additions and 507 deletions
11
MIGRATION.md
Normal file
11
MIGRATION.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Переход на новую версию
|
||||||
|
|
||||||
|
## v2 -> v3
|
||||||
|
- Для авторизации используйте аргумент `--token`. Аргументы `--browser`,
|
||||||
|
`--user-agent`, `--cookies-path` больше не являются валидными,
|
||||||
|
авторизация через cookies невозможна.
|
||||||
|
- Удален аргумент `--hq`. Используйте `--quality 1`.
|
||||||
|
- Удален аргумент `--disable-ipv6`
|
||||||
|
- Задержка между запросами по умолчанию равна 0. Вы можете убрать
|
||||||
|
аргумент `--delay`, на мобильный токен не распространяются жесткие
|
||||||
|
ограничения на количество запросов.
|
||||||
85
README.md
85
README.md
|
|
@ -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)
|
||||||
|
|
||||||
## Дисклеймер
|
## Дисклеймер
|
||||||
Данный проект является независимой разработкой и никак не связан с компанией Яндекс.
|
Данный проект является независимой разработкой и никак не связан с компанией Яндекс.
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
197
ymd/cli.py
197
ymd/cli.py
|
|
@ -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)
|
||||||
|
|
|
||||||
128
ymd/core.py
128
ymd/core.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from .api import *
|
|
||||||
from .models import *
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Reference in a new issue