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