The Daily Newsstand · Free, Always
Monday, September 14, 2026

Как я перенёс 7 000 треков из Яндекс Музыки в Spotify, хотя у одного нет API, а другой урезал квоту

Translate

Понадобилось переехать с Яндекс Музыки на Spotify вместе с библиотекой: 7 000 лайков, сотня плейлистов, альбомы, подписки на артистов. Казалось, задача на десять минут — сервисов переноса полно. Оказалось, что все они держатся на честном слове, а у Spotify с 2025 года такие ограничения для приложений, что «просто взять API» не получится. В итоге взял открытый скрипт с GitHub, переписал в нём половину и по дороге разобрался, как устроены лимиты обоих сервисов. Делюсь разбором и кодом.

Почему не работают готовые сервисы

У Яндекс Музыки нет открытого API. Зарегистрировать OAuth-приложение со scope на музыку невозможно — Яндекс такого не выдаёт. Поэтому каждый сервис-посредник делает одно из двух:

  • Просит логин и пароль от Яндекс ID и логинится за вас с своих серверов. Так работают TuneMyMusic, Soundiiz, MusConv. С двухфакторкой или входом по QR это не проходит, а у Soundiiz в саппорте прямо написано: «Yandex does not provide an official API, the workaround doesn’t work for all users». На Trustpilot есть отзывы, где после переноса приходило письмо от Яндекса о подозрительном входе из Северной Каролины.

  • Парсит публичную страницу плейлиста — так делают телеграм-боты вроде YMusicExport. Работает только для открытых плейлистов, «Мне нравится» так не вытащить, и боты регулярно ловят баны.

Плюс лимиты бесплатных версий: 200–600 треков, дальше платно и только иностранной картой.

Есть третий путь, которым пользуется библиотека yandex-music-api: OAuth-токен самого приложения Яндекс Музыки. Открываете oauth.yandex.ru/authorize?response_type=token&client_id=23cabbbdc6cd418abb4b39c32c41195d, логинитесь как обычно — и получаете в адресной строке токен с годовым сроком жизни. client_id здесь — публичный идентификатор официального клиента, не секрет. Пароль никуда не уходит, токен отзывается в Яндекс ID. С этим токеном библиотека отдаёт лайки, плейлисты, альбомы и артистов с полными метаданными, включая duration_ms — это пригодится.

Что изменилось у Spotify

Со Spotify ситуация обратная: API официальный и хорошо документированный, но в 2025 году Spotify резко закрутил гайки для сторонних приложений.

Любое новое приложение стартует в Development mode. Из документации:

  • максимум 5 авторизованных пользователей, каждого нужно вручную добавить в allowlist;

  • владелец приложения обязан иметь Premium;

  • есть квота запросов на аккаунт разработчика — при превышении API отдаёт 429 с "reason": "QUOTA_EXCEEDED" и Retry-After в сутки.

Выйти из Development mode в Extended quota mode с 15 мая 2025 могут только юрлица с действующим продуктом и минимум 250 000 MAU. Для pet-проекта это закрытая дверь. Практический вывод: любой «сервис переноса в Spotify», который вы можете написать, по правилам обслужит пять человек — включая вас. Единственная легальная схема — каждый пользователь создаёт своё приложение и работает под своей квотой.

Квота на практике — примерно 700 треков в сутки: каждый трек это один запрос search, плюс один запрос на сохранение пачки. Уперлись — ждите 24 часа.

Отсюда требования к скрипту: он обязан переживать суточную блокировку, продолжать с того же места и не делать лишних запросов.

Скрипт: что было и что стало

Основа — MarshalX/yandex2spotify, простой скрипт 2020 года: взять лайки из Яндекса, для каждого сделать search в Spotify, взять первый результат, сохранить. Для библиотеки в 200 треков — прекрасно. Для 7 000 — четыре проблемы.

1. Rate limit: почему нельзя отдавать 429 на откуп urllib3

Spotipy умеет ретраить через urllib3.Retry, и по умолчанию 429 в списке status_forcelist. Звучит удобно, но на суточной блокировке это катастрофа: urllib3 отретраит с экспоненциальным бэкоффом, исчерпает попытки и бросит исключение, в котором уже нет заголовка Retry-After — понять, сколько ждать, невозможно.

Поэтому 429 исключён из forcelist намеренно, а обработка вынесена в декоратор:

# 429 исключён: пусть urllib3 его НЕ перехватывает, иначе spotipy отдаёт
# исключение без Retry-After. 5xx по-прежнему ретраятся штатно.
RETRY_STATUS_FORCELIST = (500, 502, 503, 504)
MAX_RATE_LIMIT_WAIT = 300

def handle_spotify_exception(func):
    def wrapper(*args, **kwargs):
        while True:
            try:
                return func(*args, **kwargs)
            except SpotifyException as exception:
                if exception.http_status != 429:
                    raise
                headers = exception.headers or {}
                raw = headers.get('retry-after') or headers.get('Retry-After')
                if raw is None:
                    raise RateLimitExceeded(None)   # длительность неизвестна — не крутимся вслепую
                retry_after = int(float(raw))
                if retry_after > MAX_RATE_LIMIT_WAIT:
                    raise RateLimitExceeded(retry_after)   # суточная квота — выходим
                sleep(retry_after + 1)                     # обычный throttling — ждём
    return wrapper

Короткие троттлинги (секунды) скрипт пересиживает сам. Всё, что больше пяти минут, — это квота: скрипт печатает, когда возвращаться, и выходит с кодом 2.

2. Журнал прогресса вместо «запустить заново»

Раз скрипт живёт несколько дней, ему нужна память. Append-only JSONL с fsync после каждой записи — переживает Ctrl+C, падения и выключение ноутбука:

class Progress:
    def mark(self, key, not_found=None):
        if key is None or key in self.done:
            return
        self.done.add(key)
        rec = {'t': 'item', 'k': key}
        if not_found is not None:
            self.not_found[key] = not_found   # название — для итогового отчёта
            rec['nf'] = not_found
        self._append(rec)   # json.dumps + flush + os.fsync

Ключи — like:<id>, pl:<kind>:<id>, album:<id>, artist:<id>. Важная деталь: трек помечается «сделанным» только после того, как Spotify подтвердил запись пачки, а не после успешного поиска. Иначе при падении между поиском и сохранением трек теряется навсегда.

Плейлисты журналируются отдельно (kind → spotify_id), чтобы при повторном запуске не создать дубли, а обложка — ещё отдельнее: если её загрузка упала на 429, при следующем запуске плейлист переиспользуется, а обложка догружается.

Список «не найдено» тоже пишется в журнал с названием трека. Без этого отчёт в конце многодневного переноса показывал бы только последний день — я на это наступил.

3. Матчинг: первый результат поиска — это лотерея

search("Земфира Искала") возвращает десять кандидатов, и первый — не обязательно то, что нужно. Караоке-версии, каверы, live-записи, «Tribute to» — всё это выше оригинала бывает регулярно. Для платного сервиса это жалобы, для себя — мусор в библиотеке.

Яндекс отдаёт duration_ms, Spotify — тоже. Длительность — сигнал, независимый от языка написания артиста («Земфира» у Яндекса и «Zemfira» у Spotify — обычная история). Плюс сверка имени артиста, плюс название как tie-breaker:

def pick_match(item, candidates):
    best_id, best_score = None, 0
    for cand in candidates:
        score = 0
        if abs(cand['duration_ms'] - item.duration_ms) <= 3000:   # ±3 с
            score += 2
        if _names_overlap(item.artists, cand['artists']):
            score += 2
        if label_key(item.title) == label_key(cand['name']):
            score += 1
        if score > best_score:
            best_id, best_score = cand['id'], score
    return best_id if best_score >= 2 else None

Одного совпадения названия недостаточно (у караоке-версии оно такое же), а длительности или артиста — достаточно. Для альбомов вместо длительности — число треков: total_tracks у Spotify против track_count у Яндекса, это отсекает deluxe-издания и трибьюты. Ничего не набрало два балла — честно в «не найдено», а не первое попавшееся.

Отдельная ловушка в _names_overlap: сравнение подстрокой нужно для случаев вида KINO (Кино), но артист с именем «Ю» совпадёт с любой Юлией. Подстрока — только от трёх символов.

На моей библиотеке после этого не нашлось меньше процента треков — мелкие лейблы и VK-релизы, которых в Spotify просто нет.

4. Тихая потеря данных

В оригинале любая ошибка Spotify при обработке трека ловилась одним except SpotifyException и трек помечался обработанным. То есть 502 после исчерпания ретраев, 400 на кривом запросе или 403 «пользователь не в allowlist» — и трек навсегда считался перенесённым. Ни в отчёте, ни в Spotify.

Теперь транзиентные ошибки не помечают трек — он попадёт в следующий запуск. А 401/403 — это не проблема трека, а проблема авторизации: скрипт прерывает прогон сразу вместо того, чтобы 7 000 раз написать «NO».

Там же нашлась ещё одна: если запрос сохранения пачки падал, буфер не очищался, и каждый следующий трек снова дёргал падающий save с растущей пачкой. Для альбомов, где лимит 20 id на запрос, это гарантированный 400 навсегда. Упавшая пачка теперь сбрасывается целиком без отметки — и повторяется на следующий день.

Кстати о лимитах на запрос: у Spotify они разные по эндпоинтам — 50 id для сохранения треков, 20 для альбомов, 50 для подписок, 100 для добавления в плейлист. Один общий --chunk на всё в оригинале ломал импорт альбомов на 21-м.

Тесты

Весь матчинг и обработка ошибок покрыты unittest — 36 тестов на stdlib, без зависимостей. Фейковый Spotify-клиент отдаёт заранее заданные результаты поиска, фейковый Яндекс — треки с нужными duration_ms. Каждый описанный выше баг сначала воспроизведён тестом, потом починен. Например, каскад падающих пачек:

def test_failed_batch_is_dropped_and_later_batches_still_save(self):
    spotify, importer = self.make([SpotifyException(400, -1, 'invalid id')])
    importer.import_likes()
    self.assertEqual(spotify.saved, [['sp1', 'sp0']])           # вторая пачка дошла
    self.assertFalse(importer.progress.is_done('like:3'))       # из первой — не помечены

Как пользоваться

Коротко, подробный гайд — в GUIDE.md репозитория.

  1. Создать приложение в Spotify Dashboard, redirect URI https://open.spotify.com, взять Client ID и Secret. Нужен Premium.

  2. Получить токен Яндекса по ссылке выше.

  3. Запустить:

export SPOTIFY_CLIENT_ID=... SPOTIFY_CLIENT_SECRET=... YANDEX_TOKEN=...
python3 importer.py -u any_name

Через ~700 треков скрипт остановится с точным временем возврата. Та же команда завтра — продолжит. 7 000 треков — десять дней по одному запуску, участия не требуется.

Что не решено

  • Плейлисты создаются публичными — scope playlist-modify-public; приватные — следующим шагом.

  • Артисты матчатся по первому результату — там кроме имени сигналов нет, а транслитерация делает точное сравнение бесполезным.

  • Квота — на аккаунт разработчика, не на приложение. Ускорить перенос нельзя ни вторым приложением, ни параллельными запусками. Только ждать.

  • Обратный перенос (Spotify → Яндекс) не реализован, у Яндекс Музыки для этого есть встроенный импорт.

Код: github.com/BuevichRoman/yandex2spotify, MIT, форк MarshalX/yandex2spotify. Issues и PR приветствуются — особенно если у кого-то есть идея, как матчить артистов с транслитерацией.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.