skip tracks without album on insert, stop retrying BadRequestError

apply --yes падал целиком на первом же самозалитом/пиратском треке без
альбома (105 из 1984) - Яндекс отвечает BadRequestError (wrong-json),
т.к. albumId обязателен, а with_retries лечил это как временный сетевой
сбой и вслепую повторял заведомо безнадёжный запрос 4 раза. Теперь
BadRequestError (как и NotFoundError) не ретраится нигде - это
окончательный ответ сервера, а не таймаут. Треки без album_id
пропускаются проактивно с понятным сообщением, а неожиданный
BadRequestError на отдельном треке больше не прерывает весь прогон -
пропускается со счётчиком, остальные треки обрабатываются дальше.
This commit is contained in:
Andrew Pimenov 2026-07-06 15:06:07 +03:00
parent 0902d86bcd
commit 384095f496
3 changed files with 74 additions and 9 deletions

View file

@ -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
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)

View file

@ -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

View file

@ -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)], "второй трек должен вставиться, несмотря на ошибку первого"