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

Понадобилось переехать с Яндекс Музыки на 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 репозитория.
Создать приложение в Spotify Dashboard, redirect URI
https://open.spotify.com, взять Client ID и Secret. Нужен Premium.Получить токен Яндекса по ссылке выше.
Запустить:
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 приветствуются — особенно если у кого-то есть идея, как матчить артистов с транслитерацией.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.