From df695de78ab8504c2a177d652bf92e830b8fc337 Mon Sep 17 00:00:00 2001 From: Lev Date: Mon, 31 Oct 2022 21:47:12 +0700 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BD=D0=B0=D1=87=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 19 +++ README.md | 107 ++++++++++++++++ main.py | 311 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 4 files changed, 439 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 main.py create mode 100644 requirements.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed6afde --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022 Lev Plyusnin + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4358b65 --- /dev/null +++ b/README.md @@ -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 | --album-id | --track-id | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL) [--dir <Папка>] [--path-pattern <Паттерн>] + --session-id --spravka <Справка> --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 + --album-id + --track-id + --playlist-id <владелец плейлиста>/<тип плейлиста> + -u URL, --url URL URL исполнителя/альбома/трека/плейлиста + +Указание пути: + --dir <Папка> Папка для загрузки музыки (по умолчанию: .) + --path-pattern <Паттерн> + Поддерживает следующие заполнители: #number, #artist, #title, #album, #year (по умолчанию: #artist/#album/#number - #title) + +Авторизация: + --session-id + --spravka <Справка> + --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). + +## Дисклеймер +Данный проект является независимой разработкой и никак не связан с компанией Яндекс. diff --git a/main.py b/main.py new file mode 100644 index 0000000..3908d38 --- /dev/null +++ b/main.py @@ -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_group.add_argument('--album-id', metavar='') + id_group.add_argument('--track-id', metavar='') + 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='') + auth_group.add_argument('--spravka', required=True, metavar='<Справка>') + auth_group.add_argument('--user-agent', default=DEFAULT_USER_AGENT, metavar='') + + 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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..66afbb2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests +eyed3