Изначальный коммит

This commit is contained in:
Lev 2022-10-31 21:47:12 +07:00
commit df695de78a
No known key found for this signature in database
GPG key ID: 21C6C2C9C0A4460D
4 changed files with 439 additions and 0 deletions

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2022 Lev Plyusnin <me@llistochek.ru>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# yandex-music-downloader
## Содержание
1. [О программе](#О программе)
2. [Установка](#Установка)
3. [Получение данных для авторизации](#Получение данных для авторизации)
4. [Использование](#Использование)
5. [Спасибо](#Спасибо)
6. [Дисклеймер](#Дисклеймер)
## О программе
Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download).
### Возможности
- Возможность загрузки:
- Всех треков исполнителя
- Всех треков из альбома
- Всех треков их плейлиста
- Отдельного трека
- Загрузка всех метаданных трека/альбома:
- Номер трека
- Номер диска
- Название трека
- Исполнитель
- Дополнительные исполнители
- Год выпуска альбома
- Обложка альбома
- Название альбома
- Текст песни (при использовании флага `--add-lyrics`)
- Поддержка паттерна для пути сохранения музыки
- Загрузка без задержек между запросами
## Установка
```
git clone https://gitlab.com/llistochek/yandex-music-download
cd yandex-music-download
pip install -r requirements.txt
python3 main.py
```
## Получение данных для авторизации
Войдите в свой Яндекс аккаунт, затем проделайте следующие шаги:
### Для Google Chrome/Chromium
1. Перейдите на сайт Яндекс Музыки (https://music.yandex.ru)
2. Нажмите F12
3. Выберите вкладку Application
4. Выберите пункт Cookies->https://music.yandex.ru
5. Скопируйте значение куки (кликните на значение куки 2 раза -> Ctrl+C):
- Куки `spravka` - это аргумент `--spravka`
- Куки `Session_id` - это аргумент `--sesion-id`
### Для Firefox
1. Перейдите на сайт Яндекс Музыки (https://music.yandex.ru)
2. Нажмите F12
3. Выберите вкладку Storage
4. Выберите пункт Куки->https://music.yandex.ru
5. Скопируйте значение куки (кликните на значение куки 2 раза -> Ctrl+C):
- Куки `spravka` - это аргумент `--spravka`
- Куки `Session_id` - это аргумент `--sesion-id`
## Использование
```
usage: main.py [-h] [--hq] [--skip-existing] [--cover-resolution <Разрешение обложки>] [--add-lyrics] [--delay <Задержка>] [--log-level {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET,VERBOSE}]
(--artist-id <ID исполнителя> | --album-id <ID альбома> | --track-id <ID трека> | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL) [--dir <Папка>] [--path-pattern <Паттерн>]
--session-id <ID сессии> --spravka <Справка> --user-agent <User-Agent>
Загрузчик музыки с сервиса Яндекс.Музыка
optional arguments:
-h, --help show this help message and exit
Общие параметры:
--hq Загружать треки в высоком качестве (по умолчанию: False)
--skip-existing Пропускать уже загруженные треки (по умолчанию: False)
--cover-resolution <Разрешение обложки>
(по умолчанию: 400)
--add-lyrics Загружать тексты песен (по умолчанию: False)
--delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 0)
--log-level {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET,VERBOSE}
ID:
--artist-id <ID исполнителя>
--album-id <ID альбома>
--track-id <ID трека>
--playlist-id <владелец плейлиста>/<тип плейлиста>
-u URL, --url URL URL исполнителя/альбома/трека/плейлиста
Указание пути:
--dir <Папка> Папка для загрузки музыки (по умолчанию: .)
--path-pattern <Паттерн>
Поддерживает следующие заполнители: #number, #artist, #title, #album, #year (по умолчанию: #artist/#album/#number - #title)
Авторизация:
--session-id <ID сессии>
--spravka <Справка>
--user-agent <User-Agent>
```
## Спасибо
Разработчикам проекта [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).
## Дисклеймер
Данный проект является независимой разработкой и никак не связан с компанией Яндекс.

311
main.py Normal file
View file

@ -0,0 +1,311 @@
#!/bin/python3
import logging
import argparse
import requests
import json
import hashlib
import io
import time
import sys
import os
import urllib.parse
import re
import eyed3
import datetime
from typing import Optional
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from requests import Session
MD5_SALT = 'XGRlBW9FXlekgbPrRHuSiA'
ENCODED_BY = 'https://gitlab.com/llistochek/yandex-music-downloader'
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_PATH_PATTERN = '#artist/#album/#number - #title'
DEFAULT_COVER_RESOLUTION = 400
DEFAULT_LOG_LEVEL = 'INFO'
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+)$')
@dataclass
class PlaylistId:
owner: str
kind: int
@dataclass
class BasicAlbumInfo:
id: str
title: str
year: int
@staticmethod
def from_json(json: dict):
return BasicAlbumInfo(id=json['id'], title=json['title'], year=json['year'])
@dataclass
class BasicTrackInfo:
title: str
id: str
real_id: str
album: BasicAlbumInfo
number: int
disc_number: int
artists_names: list[str]
url_template: str
has_lyrics: bool
@staticmethod
def from_json(json: dict):
album_json = json['albums'][0]
artists_names = [a['name'] for a in json['artists']]
track_position = album_json['trackPosition']
album = BasicAlbumInfo.from_json(album_json)
url_template = 'https://' + json['ogImage']
has_lyrics = json['lyricsInfo']['hasAvailableTextLyrics']
return BasicTrackInfo(title=json['title'], id=str(json['id']), real_id=json['realId'],
number=track_position['index'], disc_number=track_position['volume'],
artists_names=artists_names, album=album, url_template=url_template,
has_lyrics=has_lyrics)
def pic_url(self, resolution: int) -> str:
return self.url_template.replace('%%', f'{resolution}x{resolution}')
@dataclass
class FullTrackInfo(BasicTrackInfo):
lyrics: str
@staticmethod
def from_json(json: dict):
base = BasicTrackInfo.from_json(json['track'])
lyrics = json['lyric'][0]['fullLyrics']
return FullTrackInfo(**base.__dict__, lyrics=lyrics)
@dataclass
class FullAlbumInfo(BasicAlbumInfo):
tracks: list[BasicTrackInfo]
@staticmethod
def from_json(json: dict):
base = BasicAlbumInfo.from_json(json)
tracks = json.get('volumes', [])
tracks = [t for v in tracks for t in v] # Array flattening
tracks = map(BasicTrackInfo.from_json, tracks)
tracks = list(tracks)
return FullAlbumInfo(**base.__dict__, tracks=tracks)
@dataclass
class ArtistInfo:
id: str
name: str
albums: list[BasicAlbumInfo]
url_template: str
@staticmethod
def from_json(json: dict):
artist = json['artist']
albums = map(BasicAlbumInfo.from_json, json.get('albums', []))
albums = list(albums)
url_template = 'https://' + artist['ogImage']
return ArtistInfo(id=str(artist['id']), name=artist['name'],
albums=albums, url_template=url_template)
def pic_url(self, resolution: int) -> str:
return self.url_template.replace('%%', f'{resolution}x{resolution}')
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)
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 'https://%s/get-mp3/%s/%s/%s?track-id=%s' \
% (host, path_hash, ts, path, track.id)
def download_file(session: Session, url: str, filename: str) -> None:
resp = session.get(url)
with open(filename, 'wb') as f:
for chunk in resp.iter_content(chunk_size=1024):
f.write(chunk)
def get_full_track_info(session: Session, track_id: str) -> FullTrackInfo:
resp = session.get(f'https://music.yandex.ru/handlers/track.jsx?track={track_id}&lang=ru')
return FullTrackInfo.from_json(resp.json())
def get_full_album_info(session: Session, album_id: str) -> FullAlbumInfo:
resp = session.get(f'https://music.yandex.ru/handlers/album.jsx?album={album_id}&lang=ru')
return FullAlbumInfo.from_json(resp.json())
def get_artist_info(session: Session, artist_id: str) -> ArtistInfo:
resp = session.get('https://music.yandex.ru/handlers/artist.jsx'
f'?artist={artist_id}&what=albums&lang=ru')
return ArtistInfo.from_json(resp.json())
def get_playlist(session: Session, playlist: PlaylistId) -> list[BasicTrackInfo]:
resp = session.get('https://music.yandex.ru/handlers/playlist.jsx'
f'?owner={playlist.owner}&kinds={playlist.kind}&lang=ru')
raw_tracks = resp.json()['playlist']['tracks']
return [BasicTrackInfo.from_json(t) for t in raw_tracks]
def prepare_track_path(directory: str, path: str, track: BasicTrackInfo) -> str:
path_part = path.replace('#number', str(track.number)) \
.replace('#artist', track.artists_names[0]) \
.replace('#title', track.title) \
.replace('#album', track.album.title) \
.replace('#year', str(track.album.year)) \
+ '.mp3'
return os.path.join(directory + '/' + path_part)
def set_id3_tags(path: str, track: BasicTrackInfo, lyrics: Optional[str]) -> None:
audiofile = eyed3.load(path)
audiofile.tag = tag = eyed3.id3.tag.Tag(version=(2, 4, 0))
tag.artist = '; '.join(track.artists_names)
tag.album = track.album.title
tag.title = track.title
tag.album_artist = track.artists_names[0]
tag.track_num = track.number
tag.release_date = tag.original_release_date = tag.recording_date \
= track.album.year
tag.disc_num = track.disc_number
tag.encoded_by = ENCODED_BY
if lyrics is not None:
tag.lyrics.set(lyrics)
tag.save()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Загрузчик музыки с сервиса Яндекс.Музыка')
def help_str(text: str) -> str:
return text + ' (по умолчанию: %(default)s)'
common_group = parser.add_argument_group('Общие параметры')
common_group.add_argument('--hq', action='store_true',
help=help_str('Загружать треки в высоком качестве'))
common_group.add_argument('--skip-existing', action='store_true',
help=help_str('Пропускать уже загруженные треки'))
common_group.add_argument('--cover-resolution', default=DEFAULT_COVER_RESOLUTION,
metavar='<Разрешение обложки>', help=help_str(''))
common_group.add_argument('--add-lyrics', action='store_true',
help=help_str('Загружать тексты песен'))
common_group.add_argument('--delay', default=DEFAULT_DELAY, metavar='<Задержка>',
help=help_str('Задержка между запросами, в секундах'))
common_group.add_argument('--log-level', default=DEFAULT_LOG_LEVEL,
choices=logging._nameToLevel.keys())
def args_playlist_id(arg: str) -> PlaylistId:
arr = arg.split('/')
return PlaylistId(owner=arr[0], kind=int(arr[1]))
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', type=args_playlist_id,
metavar='<владелец плейлиста>/<тип плейлиста>')
id_group.add_argument('-u', '--url', help='URL исполнителя/альбома/трека/плейлиста')
path_group = parser.add_argument_group('Указание пути')
path_group.add_argument('--dir', default='.', metavar='<Папка>',
help=help_str('Папка для загрузки музыки'))
path_group.add_argument('--path-pattern', default=DEFAULT_PATH_PATTERN,
metavar='<Паттерн>',
help=help_str('Поддерживает следующие заполнители:'
' #number, #artist, #title, #album, #year'))
auth_group = parser.add_argument_group('Авторизация')
auth_group.add_argument('--session-id', required=True, metavar='<ID сессии>')
auth_group.add_argument('--spravka', required=True, metavar='<Справка>')
auth_group.add_argument('--user-agent', default=DEFAULT_USER_AGENT, metavar='<User-Agent>')
args = parser.parse_args()
logging.basicConfig(format='%(levelname)s -> %(message)s', level=args.log_level.upper())
def response_hook(resp, *_args, **_kwargs):
time.sleep(args.delay)
pass
session = Session()
session.hooks = {'response': response_hook}
session.cookies.set('Session_id', args.session_id, domain='yandex.ru')
session.cookies.set('spravka', args.spravka, domain='yandex.ru')
session.headers['User-Agent'] = args.user_agent
session.headers['X-Retpath-Y'] = urllib.parse.quote_plus('https://music.yandex.ru')
result_tracks: list[BasicTrackInfo] = []
if args.url is not None:
if match := ARTIST_RE.search(args.url):
args.artist_id = match.group(1)
elif match := ALBUM_RE.search(args.url):
args.album_id = match.group(1)
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)))
else:
print('Параметер url указан в неверном формате')
sys.exit(1)
if args.artist_id is not None:
artist_info = get_artist_info(session, args.artist_id)
albums = [get_full_album_info(session, a.id) for a in artist_info.albums]
for album in albums:
result_tracks.extend(album.tracks)
print(f'{artist_info.name}:')
print(f'Альбомов: {len(albums)}')
elif args.album_id is not None:
album = get_full_album_info(session, args.album_id)
result_tracks = album.tracks
elif args.track_id is not None:
result_tracks = [get_full_track_info(session, args.track_id)]
elif args.playlist_id is not None:
result_tracks = get_playlist(session, args.playlist_id)
print(f'Треков: {len(result_tracks)}')
for track in result_tracks:
save_path = prepare_track_path(args.dir, args.path_pattern, track)
if os.path.isfile(save_path) and args.skip_existing:
continue
save_dir = os.path.dirname(save_path)
cover_path = save_dir + '/cover.jpg'
if not os.path.isdir(save_dir):
os.makedirs(save_dir)
url = get_track_download_url(session, track, args.hq)
logging.info('Загружается %s', save_path)
download_file(session, url, save_path)
lyrics = None
if args.add_lyrics and track.has_lyrics:
if isinstance(track, FullTrackInfo):
lyrics = track.lyrics
else:
full_info = get_full_track_info(session, track.id)
lyrics = full_info.lyrics
set_id3_tags(save_path, track, lyrics)
if not os.path.isfile(cover_path):
download_file(session, track.pic_url(args.cover_resolution), cover_path)

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
requests
eyed3