From 384095f496e6045e2ae443dcd8afea021dad767a Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Mon, 6 Jul 2026 15:06:07 +0300 Subject: [PATCH] skip tracks without album on insert, stop retrying BadRequestError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply --yes падал целиком на первом же самозалитом/пиратском треке без альбома (105 из 1984) - Яндекс отвечает BadRequestError (wrong-json), т.к. albumId обязателен, а with_retries лечил это как временный сетевой сбой и вслепую повторял заведомо безнадёжный запрос 4 раза. Теперь BadRequestError (как и NotFoundError) не ретраится нигде - это окончательный ответ сервера, а не таймаут. Треки без album_id пропускаются проактивно с понятным сообщением, а неожиданный BadRequestError на отдельном треке больше не прерывает весь прогон - пропускается со счётчиком, остальные треки обрабатываются дальше. --- sort_ym/apply.py | 31 +++++++++++++++++++++++++++---- sort_ym/ymclient.py | 9 +++++---- tests/test_apply.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/sort_ym/apply.py b/sort_ym/apply.py index d1bd5da..a478ec3 100644 --- a/sort_ym/apply.py +++ b/sort_ym/apply.py @@ -3,7 +3,7 @@ from __future__ import annotations import time from yandex_music import Client, Playlist -from yandex_music.exceptions import NetworkError +from yandex_music.exceptions import BadRequestError, NetworkError from .ymclient import with_retries @@ -62,6 +62,10 @@ def _insert_track_verified( max_attempts=1, ) return updated.revision if updated is not None and updated.revision is not None else revision + except BadRequestError: + # Детерминированный отказ сервера (например, невалидные данные трека) - ответ + # точно получен, трек точно не вставлен. Повторять бессмысленно, пробрасываем сразу. + raise except NetworkError as e: last_attempt = attempt == max_attempts - 1 print(f" сетевая ошибка при вставке трека ({e}), проверяю фактическое состояние плейлиста...") @@ -98,15 +102,34 @@ def apply_classification( revision = playlist.revision or 1 added = 0 + no_album = 0 + failed = 0 for row in playlist_rows: track_key = f"{row['id']}:{row['album_id']}" if row["album_id"] else str(row["id"]) if track_key in already: continue - revision = _insert_track_verified(client, playlist, row, track_key, revision) + if row["album_id"] is None: + # Самозалитые/пиратские треки без альбома - Яндекс не даёт вставить такой + # трек в плейлист (albumId обязателен), это не временный сбой. Пропускаем. + no_album += 1 + continue + + try: + revision = _insert_track_verified(client, playlist, row, track_key, revision) + except BadRequestError as e: + print(f" пропущен трек id={row['id']} ({row.get('title', '?')!r}): {e}") + failed += 1 + continue + already.add(track_key) added += 1 time.sleep(delay) - skipped = len(playlist_rows) - added - print(f"{title}: добавлено {added}, уже было {skipped}") + already_had = len(playlist_rows) - added - no_album - failed + summary = f"{title}: добавлено {added}, уже было {already_had}" + if no_album: + summary += f", пропущено без альбома {no_album}" + if failed: + summary += f", ошибок {failed}" + print(summary) diff --git a/sort_ym/ymclient.py b/sort_ym/ymclient.py index b911cc6..2dbfc07 100644 --- a/sort_ym/ymclient.py +++ b/sort_ym/ymclient.py @@ -4,7 +4,7 @@ import time from typing import Callable, Iterator, List, TypeVar from yandex_music import Client -from yandex_music.exceptions import NetworkError, NotFoundError +from yandex_music.exceptions import BadRequestError, NetworkError, NotFoundError T = TypeVar("T") @@ -24,14 +24,15 @@ def chunked(items: List[T], size: int) -> Iterator[List[T]]: def with_retries(fn: Callable[[], T], max_attempts: int = 4, base_delay: float = 2.0) -> T: """Повторяет вызов при временных сетевых сбоях (таймауты и т.п.) с экспоненциальной паузой. - NotFoundError не повторяется - это окончательный ответ API, а не сбой сети, - хотя формально он тоже унаследован от NetworkError. + NotFoundError и BadRequestError не повторяются - это окончательные ответы сервера + (данные точно не найдены / запрос точно невалиден), а не сбои сети, хотя формально + оба тоже унаследованы от NetworkError. Повтор такого запроса даст тот же результат. """ last_exc: NetworkError | None = None for attempt in range(max_attempts): try: return fn() - except NotFoundError: + except (NotFoundError, BadRequestError): raise except NetworkError as e: last_exc = e diff --git a/tests/test_apply.py b/tests/test_apply.py index 9e708e2..4c0ca9c 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -1,4 +1,4 @@ -from yandex_music.exceptions import TimedOutError +from yandex_music.exceptions import BadRequestError, TimedOutError from sort_ym import apply as apply_mod @@ -92,3 +92,44 @@ def test_apply_does_not_duplicate_track_on_timeout_after_server_applied_write(): assert client.insert_calls == 1, "insert_track не должен вызываться повторно вслепую при таймауте" assert client.server_tracks == [(1, 10)], "трек должен оказаться в плейлисте ровно один раз" + + +def test_apply_skips_tracks_without_album_id(): + # Самозалитые/пиратские треки без альбома - Яндекс не даёт вставить такой трек в + # плейлист (albumId обязателен). Должны пропускаться без попытки вставки и без падения. + client = FakeClient() + rows = [ + {"target_playlist": "Поп — INT", "id": 1, "album_id": None, "title": "no album"}, + {"target_playlist": "Поп — INT", "id": 2, "album_id": 20, "title": "has album"}, + ] + + apply_mod.apply_classification(client, rows, delay=0) + + assert client.inserted == [(200, 2, 20, 1)], "трек без альбома не должен уходить в insert_track" + + +class BadRequestClient(FakeClient): + """insert_track кидает BadRequestError (детерминированный отказ) на конкретном треке.""" + + def __init__(self, bad_track_id): + super().__init__() + self.playlists = {200: FakePlaylist(200, "Поп — INT", revision=1, tracks=[])} + self.next_kind = 201 + self._bad_track_id = bad_track_id + + def users_playlists_insert_track(self, kind, track_id, album_id, revision=1): + if track_id == self._bad_track_id: + raise BadRequestError({"name": "wrong-json", "message": "Invalid JSON rules"}) + return super().users_playlists_insert_track(kind, track_id, album_id, revision) + + +def test_apply_continues_after_bad_request_on_single_track(): + client = BadRequestClient(bad_track_id=1) + rows = [ + {"target_playlist": "Поп — INT", "id": 1, "album_id": 10, "title": "broken"}, + {"target_playlist": "Поп — INT", "id": 2, "album_id": 20, "title": "ok"}, + ] + + apply_mod.apply_classification(client, rows, delay=0) + + assert client.inserted == [(200, 2, 20, 1)], "второй трек должен вставиться, несмотря на ошибку первого"