Русский
Русский
English
Статистика
Реклама

Yandex music

Spothiefy как переехать из Яндекс.Музыки быстро, бесплатно

19.07.2020 14:16:16 | Автор: admin
Итак, в июле жизнь в стране наконец стала меняться к лучшему, ведь произошло то, чего многие жители с нетерпением ждали: Spotify запущен в России и ряде других стран.

Но потоковая музыка появилась не вчера и наверняка есть такие, кто подсел на иглу Яндекса и пользуется подпиской на Яндекс.Музыку, которая впоследствии стала Яндекс.Плюсом.

Слушать песни стало удобно, подбираторы научились подбирать хорошие треки и это привело к накоплению библиотеки с плейлистами и прочих удобных штук, которые в новом сервисе нужно заново добавлять.

Eсли хочется попробовать, но вам тоже лень, то я расскажу как перенести пожитки быстро, бесплатно. Нужно всего лишь немного питонов с батарейками.

Пётр и крышка спотифая



С помощью нехитрых приспособлений за пару дней у меня получилось сделать импортёр треков в Spotify и не потратить денег на soundiiz, на который почему-то внезапно возросла нагрузка.

Но есть нюансы.

API


Spotify предоставляет какую-никакую документацию для своего сервиса Web API, и в том числе есть API для добавления к себе в библиотеку как плейлистов, так и избранных треков.

В свою очередь у Яндекс.Музыки публичного API нет, но возможно конкуренция подстегнёт их предоставить доступ для сторонних разработчиков, потому что это нужно, удобно и полезно.
Поэтому здесь мы немного пройдём по кривой дорожке, и воспользуемся приёмами с сомнительной репутацией.

А что Deezer?
У Deezer, к слову, публичный API управления библиотекой музыки тоже есть. Но нет готовой библиотеки для Python, которой можно было бы быстро и удобно воспользоваться.


Spotify


Здесь всё просто. Чтобы стать разработчиком, нужно получить ключ приложения в консоли.
Там предложат добавить Redirect URI для OAuth, который можно установить любым, т.к. он нужен только для сервисов, обслуживающих сразу кучу людей, а в нашем случае всё происходит локально.

Яндекс.Музыка


Нужен логин и пароль для аккаунта, но если включена двухфакторная аутентификация, указывать надо Яндекс.Пароль из Яндекс.Ключа.

Работа приложения


Не хочется останавливаться на запуске приложений на Python, разворачивании виртуального окружения и т.п., поэтому опишу, как происходит импорт. Ссылка на репозиторий с программным кодом в конце статьи.

Треки из API всех платформ приходят в разном формате, поэтому они приводятся к одинаковому представлению с минимально необходимым набором свойств:
class Track:    title = property(lambda self: self.__title)    album = property(lambda self: self.__albums[0] if len(self.__albums) > 0 else None)    artist = property(lambda self: self.__artists[0] if len(self.__artists) > 0 else None)    albums = property(lambda self: self.__albums)    artists = property(lambda self: self.__artists)


Плейлисты (включая избранное) тоже имеют одинаковый формат, и включают в себя итератор треков, чтобы удобно было использовать в циклах:
class Playlist:    class __iterator:        def __init__(self, playlist):            pass  # заглушка для компактности        def __next__(self):            pass  # заглушка для компактности    title = property(lambda self: self.__title)    tracks = property(lambda self: self.__tracks)    is_public = property(lambda self: self.__is_public)    def __len__(self):        return len(self.__tracks)    def __iter__(self):        return Playlist.__iterator(self)    def __getitem__(self, index):        return self.__tracks[index]


За взаимодействие с сервисами отвечает класс MusicProvider:
class MusicProvider:    favorites = property(lambda self: self.__favorites)    playlists = property(lambda self: self.__playlists)


Класс YandexMusic (MusicProvider) при инициализации загружает информацию по всем плейлистам и всем трекам в плейлисте Мне понравилось.

Spotify (MusicProvider) этого не делает, но содержит методы для импорта:
class Spotify(MusicProvider):    def import_playlist(self, playlist):        pass  # заглушка для компактности    def import_favorites(self, playlist):        pass  # заглушка для компактности


Внутри происходит поиск треков в базе Spotify с использованием данных о песнях, полученных из Яндекс.Музыки.
После того, как все треки плейлиста найдены, он создаётся (если это не Liked Songs) с тем же названием и в него добавляются все найденные мелодии.

Для плейлистов и сохранённых треков требуются разные разрешения:
  • playlist-modify-private для создания/модификации плейлистов
  • user-library-modify для добавления звуковых дорожек в избранное

Есть проблема: длина строки запроса ограничена, поэтому когда в плейлисте огромное количество песен, запрос завершается с ошибкой даже не начавшись. Чтобы избежать этой ситуации, список треков нарезается на части по 50 штук и добавление происходит несколькими запросами.

Метод search из API Spotify поддерживает ключевые слова для поиска по альбомам/исполнителям/названиям, чем и будем беззастенчиво пользоваться.

Поиск в Spotify


У Spotify большая база треков, но там есть не вся музыка. Можно легко догадаться, что множество отсутствующей в Spotify музыки пересекается с множеством базы композиций Яндекс.Музыки. Часть музыкальных дорожек может быть каверами/ремиксами и прочими извращениями, а часть просто внесёнными неправильно: не тот альбом, или порядок музыкантов разный.

Ещё проблем добавляет разный подход к составлению информации о треках: у Spotify альбом может быть только один, а Яндекс.Музыка отправляет массив альбомов. Исполнителей уже может быть несколько и там, и там.
Deezer предоставляет один альбом и одного исполнителя, но это уже другая история.

Поэтому используется следующий подход, чтобы и рыбку съесть, и на стул присесть:
  • Для всех альбомов выполняется поиск по точному совпадению ключевых слов track:, artist:, album:.
    Чаще всего этого достаточно.
  • Если трек не найден (или альбом у Яндекс.Музыки не указан), происходит попытка поиска без альбома.
  • Если трек не не найден, происходит поиск со следующим исполнителем.


Ненайденные мелодии, и нестандартные условия поиска звуковых дорожек выводятся в лог. Можно посмотреть, что именно добавилось и при необходимости обработать вручную.

Примечание


Этот проект по большому счёту необходим для одноразовой задачи, разрабатывался в свободное время, не для использования в промышленных приложениях, поэтому для бывалого специалиста код может показаться отвратительным.

Однако, при разработке практики PEP8 более-менее пытались соблюдаться, и общий размер программы довольно мал.

Исходные тексты программы


Актуальная версия Python на момент написания: 3.8.4

Использованные материалы:


  • Иллюстрация Поросёнка Петра: Книга Поросёнок Пётр и машина, Петрушевская Людмила
  • Логотип Яндекс.Музыки: ООО ЯНДЕКС
  • Логотип Spotify: Spotify AB
Подробнее..

Из песочницы Реализация offline режима для Yandex.Music

08.08.2020 16:12:48 | Автор: admin

Введение


Сегодня мы будем рассматривать такой достаточно известный музыкальный сервис, как Yandex.Music. Хороший в целом сервис, но с существенным недостатком невозможностью работы оффлайн. Мы попробуем исправить это досадное недоразумение, используя подручные инструменты.


Инструментарий


Итак, нам понадобится:


  • Относительно свежий python: 3.7 и выше
  • Всякая асинхронщина: aiohttp и aiofile
  • Классический инструмент для работы с html-API: BeautifulSoup
  • Для развлечения пользователя во время процесса: tqdm
  • Для заполнения тэгов: mutagen

Авторизация


Неавторизованным пользователям сервиса доступны только отрезки песен длинной до 30 секунд. Этого явно недостаточно для качественного прослушивания. Авторизоваться мы будем самым что ни на есть естественным способом, через веб-форму с получением печенюшек. В этом нам поможет opener для выполнения запросов и HTMLParser для парсинга форм.


def resolve_cookie(login: str, password: str) -> str:    cookies = CookieJar()    opener = urllib.request.build_opener(        urllib.request.HTTPCookieProcessor(cookies),        urllib.request.HTTPRedirectHandler())    response = opener.open("https://passport.yandex.ru")    doc = response.read()    parser = FormParser()    parser.feed(doc.decode("utf-8"))    parser.close()    parser.params["login"] = login    response = opener.open(parser.url or response.url, urllib.parse.urlencode(parser.params).encode("utf-8"))    doc = response.read()    parser = FormParser()    parser.feed(doc.decode("utf-8"))    parser.close()    parser.params["login"] = login    parser.params["passwd"] = password    response = opener.open(parser.url or response.url, urllib.parse.urlencode(parser.params).encode("utf-8"))    cookie_data = {}    for item in cookies:        if item.domain == ".yandex.ru":            cookie_data[item.name] = item.value    if "yandex_login" not in cookie_data:        keys = ", ".join(cookie_data.keys())        raise Exception(f"Invalid cookie_data {keys}")    return "; ".join(map(lambda v: f"{v[0]}={v[1]}", cookie_data.items()))

По адресу https://passport.yandex.ru нас ожидает первая форма с полем login. Если будет указан актуальный логин, то следующая форма предложит ввести пароль, что мы незамедлительно сделаем. Результат отправки формы нас особо не интересует. Наша цель кукисы. Если среди полученых кук окажется yandex_login, то авторизация прошла успешно. Вариант с двухфакторной аутентификацией мы пока не рассматриваем.


Yandex Music (HTML) API


Следующим нашим шагом будет получение списка избранных пользователем исполнителей. Чтобы показать наш стиль и молодёжность, для запросов применим aiohttp. Полученный html распарсим с помощью BeautifulSoup. Это классический инструмент, который используют ребята, заинтересованные в получении контента с чужих веб-страниц.


class YandexMusicApi:    host = "music.yandex.ru"    base_url = f"https://{host}"    def __init__(self, cookie: str):        self.headers = Headers(self.host, cookie)    async def _request(self, end_point: str):        async with aiohttp.ClientSession() as session:            url = f"{self.base_url}/{end_point}"            async with session.request(method="GET", url=url) as response:                return await response.read()    async def get_favorite_artists(self, login: str) -> List[Artist]:        body = await self._request(f"users/{login}/artists")        soup = BeautifulSoup(body, "lxml")        artists_soup = soup.find("div", class_="page-users__artists")        if artists_soup is None:            caption = soup.find("div", class_="page-users__caption")            if caption:                raise Exception(caption.contents[0])        result = []        for artist_soup in artists_soup.find_all("div", class_="artist"):            title_soup = artist_soup.find("div", class_="artist__name")            title = title_soup.attrs["title"]            title_href_soup = title_soup.find("a")            id_ = int(title_href_soup.attrs["href"].split("/")[-1])            result.append(Artist(id_, title))        return result

Здесь всё довольно просто, на странице https://music.yandex.ru/users/<login>/artists в блоке page-users__artists отображается требуемый нам список. Имя исполнителя можно обнаружить в аттрибуте title блока artist__name. Id исполнителя лёгким движением split извлекается из адреса ссылки.
Аналогичные действия позволят нам получить список альбомов исполнителя, а также список треков в альбоме.


Проникновение в хранилище


В этом месте мы уже потираем наши шаловливые ручки в ожидании прослушивания своей музыкальной коллекции. Но если бы всё было так просто, то я бы не писал эту статью. Нас ожидает новый противник yandex-хранилище. Для проникновения в хранилище потребуется воспроизвести жонглирование ссылками, которое происходит на страницах музыкального ресурса. При внимательном изучении вкладки Network браузерного инструментария выяснилось, что все элементы запроса легко выводятся за исключением одного участка в адресе https://{host}/get-mp3/{sign}/{ts}/{path}, который после непродолжительного гугления получил имя sign. Соль для сигнатуры (XGRlBW9FXlekgbPrRHuSiA) тоже была обнаружена на просторах интернета. Скорее всего не составит большого труда извлечь это значение из исходников страницы, но это уже не требуется.


    async def get_track_url(self, album_id: int, track_id: int) -> str:        async with aiohttp.ClientSession() as session:            url = f"{self.base_url}/api/v2.1/handlers/track/{track_id}:{album_id}/" \                  f"web-album-track-track-main/download/m?hq=0&external-domain={self.host}&overembed=no&__t={timestamp()}"            page = f"album/{album_id}"            headers = self.headers.build(page)            async with session.request(method="GET", url=url, headers=headers) as response:                body = await response.json()                src = body["src"]                src += f"&format=json&external-domain={self.host}&overembed=no&__t={timestamp()}"                result = parse.urlparse(src)                headers = self.headers.build(page, {                    ":authority": "storage.mds.yandex.net",                    ":method": "GET",                    ":path": f"{result.path}/{result.query}",                    ":scheme": "https",                }, True)                async with session.request(method="GET", url=src, headers=headers) as response:                    body = await response.json()                    host = body["host"]                    path = body["path"]                    s = body["s"]                    ts = body["ts"]                    sign = md5(f"XGRlBW9FXlekgbPrRHuSiA{path[1::]}{s}".encode("utf-8")).hexdigest()                    url = f"https://{host}/get-mp3/{sign}/{ts}/{path}"                    return url

Время жизни ссылки ограничено, поэтому нужно незамедлительно перейти к скачиванию.


Скачивание


На этом этапе уже немного надоедает всё это дело, поэтому берём в одну руку ранее используемое нами aiohttp, в другую aiofile и реализуем скачивание в лоб.


    async def download_file(cls, url: str, filename: str):        async with aiohttp.ClientSession() as session:            async with session.request(method="GET", url=url) as response:                data = await response.read()                async with AIOFile(filename, "wb") as afp:                    await afp.write(data)                    await afp.fsync()

Опытные программисты могут предложить реализовать скачивание с использованием буфера и записью во временный файл. Но в данном случае, учитывая, что максимальный размер mp3, как правило, укладывается в оперативную память, то при достаточно быстром интернете можно этим не заморачиваться. Ладно, не надо кидать в меня подручными материалами, я всё понял. На самом деле это одно из ключевых мест нашего проекта, которое в следующей итерации требуется привести к адекватному виду. В идеале ещё стоит добавить повторные запросы при возникновении сетевых ошибок.


Раскручиваем шарманку


Далее, нам нужно поставить закачку на поток. Методично перебираем исполнителей, альбомы, треки. Создаём директории, скачиваем альбомный арт, скачиваем сами треки. Прописываем тэги (почему-то у полученных mp3 они отсутствовали).


    async def download_artist(self, artist: Artist, depth: Depth = Depth.NORMAL):        artist_progress = tqdm(total=0, desc=artist.title, position=1, ascii=True)        albums = await self.api.get_artist_albums(artist.id)        artist_progress.total = len(albums)        artist_progress.refresh()        for album in albums:            album_dir = os.path.join(self.target_dir, normalize(artist.title), f"{album.year} - {normalize(album.title)}")            if depth < Depth.ALBUMS and os.path.exists(album_dir):                artist_progress.update()                continue            album_progress = tqdm(total=0, desc=f"> {album.title}", position=0, ascii=True)            tracks = await self.api.get_album_tracks(album.id)            album_progress.total = len(tracks)            album_progress.refresh()            os.makedirs(album_dir, exist_ok=True)            if album.cover:                album_progress.total += 1                cover_filename = os.path.join(album_dir, "cover.jpg")                if not os.path.exists(cover_filename):                    await self.download_file(album.cover, cover_filename)                album_progress.update()            for track in tracks:                target_filename = os.path.join(album_dir, f"{track.num:02d}. {normalize(track.title)}.mp3")                if depth >= Depth.TRACKS or not os.path.exists(target_filename):                    url = await self.api.get_track_url(track.album_id, track.id)                    await self.download_file(url, target_filename)                    self.write_tags(target_filename, {                        "title": track.title,                        "tracknumber": str(track.num),                        "artist": artist.title,                        "album": album.title,                        "date": str(album.year),                    })                album_progress.update()            album_progress.close()            artist_progress.update()        artist_progress.close()

После первой загрузки, я с удивлением обнаружил, что любимая мною группа AC/DC оказалась разделена на две директории. Это послужило поводом к реализации метода normalize:


def normalize(name: str) -> str:    return name.replace("/", "-")

Как несложно заметить, процесс скачивания можно (точнее нужно) распараллелить. Почва для этого подготовлена благодаря использованию асинхронных библиотек для сетевых запросов и записи в файлы. Для этого можно использовать asyncio.Semaphore в комбинации с asyncio.gather.
Но реализация асинхронной очереди не является предметом данной статьи, поэтому пока и так сойдёт.


Запуск


Теперь, когда у нас всё уже практически готово, нужно подумать о том, как это запускать. Явки и пароли мы будем вычитывать из файла .credentials, ненавязчиво бросая эксепшен в случае его отсутствия. Полученную куку бережно сохраним в файл .cookie для дальнейшего использования.


def resolve_cookie() -> str:    base_dir = os.path.normpath(os.path.join(os.path.dirname(__file__), os.path.pardir))    cookie_file = os.path.join(base_dir, ".cookie")    if os.path.exists(cookie_file):        with open(cookie_file, "rt") as file:            return file.read()    credentials_file = os.path.join(base_dir, ".credentials")    if os.path.exists(credentials_file):        config = configparser.ConfigParser()        config.read(credentials_file)        login = config["yandex"]["login"]        password = config["yandex"]["password"]    else:        raise Exception(f"""Create \"{credentials_file}\" with content[yandex]login=<user_login>password=<user_password>""")    cookie = auth.resolve_cookie(login, password)    with open(cookie_file, "wt") as file:        file.write(cookie)    return cookie

В этом месте стоит немного украсить жизнь пользователя, позволив ему выбрать директорию для хранения музыкальной коллекции, а так же возможность скачивания треков отдельного исполнителя. Для разбора аргументов командной строки используем стандартный инструмент argparse, который отличается простотой конфигурации.
Опишем немного опциональных параметров:


  • -a (--artist), Id исполнителя, если указан, то скачиваются только треки этого исполнителя
  • -o (--output), директория для хранения музыкальной коллекции, по умолчанию Music в домашней директории пользователя.
  • -d (--depth), параметр родился как костыль, вызванный возможным прерыванием процесса
    • При значении по-умолчанию 0 (NORMAL) проверяется наличие директории с альбомом, и, если она существует, то загрузка альбома пропускается
    • Значение 1 (ALBUMS) перебирает все треки в альбоме и скачивает недостающие
    • Значение 2 (TRACKS) скачивает и перезаписывает треки, даже если они уже присутствуют в файловой системе

async def main():    parser = argparse.ArgumentParser()    parser.add_argument("-a", "--artist", help="Artist ID")    parser.add_argument("-o", "--output", default=f"{Path.home()}/Music",                        help=f"Output directory, default {Path.home()}/Music")    parser.add_argument("-d", "--depth", default=0, type=int,                        help=f"Exists files check depth, {enum_print(Depth)}")    args = parser.parse_args()    cookie = resolve_cookie()    api = YandexMusicApi(cookie)    agent = YandexMusicAgent(api, args.output)    if args.artist:        artist = await api.get_artist(args.artist)        await agent.download_artist(artist, args.depth)    else:        email = re.compile(".*?yandex_login=(.*?);.*?", re.M).match(cookie).group(1)        await agent.download_favorites(email, args.depth)

И вот, наконец-то, мы можем всё это запустить:


if __name__ == "__main__":    asyncio.run(main())

Спасибо за внимание. Теперь вы знаете, как реализовать API которого нет, скачать нескачиваемое и стать счастливым обладателем собственной музыкальной коллекции.


Результат можно увидеть в репозитории yandex.music.agent

Подробнее..
Категории: Python , Api , Asyncio , Yandex music

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru