diff --git a/sort_ym/apply.py b/sort_ym/apply.py index bb7a12d..e7e54e8 100644 --- a/sort_ym/apply.py +++ b/sort_ym/apply.py @@ -13,11 +13,30 @@ def _existing_playlists_by_title(client: Client) -> dict[str, Playlist]: return {p.title: p for p in playlists if p.title} -def _existing_track_ids(client: Client, playlist: Playlist) -> set[str]: - full = with_retries(lambda: client.users_playlists(playlist.kind)) - if full is None: - return set() - return {t.track_id for t in full.tracks} +def _existing_track_ids(client: Client, playlist: Playlist, max_attempts: int = 4) -> set[str]: + """Возвращает set track_id уже находящихся в плейлисте треков. + + client.users_playlists(kind) может вернуть None не только когда плейлиста не существует, + но и при любом сбое разбора ответа (усечённое/повреждённое тело при 200 OK) - это тихий + фолбэк библиотеки, а не исключение, поэтому with_retries его не перехватывает. Плейлист + только что получен из users_playlists_list(), так что None здесь почти наверняка означает + именно такой сбой, а не "плейлист пуст". Раньше это тихо трактовалось как пустой плейлист, + из-за чего apply_classification вставлял все треки заново поверх уже существующих (дубли). + Теперь повторяем запрос и, если так и не получили данные, падаем явно вместо угадывания. + """ + for attempt in range(max_attempts): + full = with_retries(lambda: client.users_playlists(playlist.kind)) + if full is not None: + return {t.track_id for t in full.tracks} + if attempt < max_attempts - 1: + wait = 2.0 * (2**attempt) + print(f" пустой ответ при чтении плейлиста «{playlist.title}», повтор через {wait:.0f}с...") + time.sleep(wait) + + raise RuntimeError( + f"не удалось прочитать содержимое плейлиста «{playlist.title}» (kind={playlist.kind}) " + "после нескольких попыток - прерываю, чтобы не вставить треки повторно поверх существующих" + ) def _get_or_create_playlist(client: Client, title: str, existing: dict[str, Playlist]) -> Playlist: diff --git a/tests/test_apply.py b/tests/test_apply.py index 00e0647..ce95525 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -135,3 +135,47 @@ def test_apply_continues_after_bad_request_on_single_track(): apply_mod.apply_classification(client, rows, delay=0) assert client.inserted == [(200, 2, 20, 1)], "второй трек должен вставиться, несмотря на ошибку первого" + + +class NoneOnceClient(FakeClient): + """users_playlists возвращает None (сбой разбора ответа) на первой попытке, затем нормальные данные.""" + + def __init__(self): + super().__init__() + self.calls = 0 + + def users_playlists(self, kind): + self.calls += 1 + if self.calls == 1: + return None + return super().users_playlists(kind) + + +def test_apply_retries_on_none_response_instead_of_treating_as_empty_playlist(monkeypatch): + # Регрессия: client.users_playlists() тихо возвращает None при любом сбое разбора ответа + # (не только когда плейлиста реально не существует). Раньше это трактовалось как "плейлист + # пуст", и apply вставлял поверх уже существующих треков дубли. + monkeypatch.setattr(apply_mod.time, "sleep", lambda s: None) + client = NoneOnceClient() + rows = [{"target_playlist": "Рок — RU", "id": 1, "album_id": 10}] + + apply_mod.apply_classification(client, rows, delay=0) + + assert client.inserted == [], "трек уже был в плейлисте, дубль вставляться не должен" + + +class AlwaysNoneClient(FakeClient): + def users_playlists(self, kind): + return None + + +def test_apply_raises_instead_of_silently_treating_persistent_none_as_empty(monkeypatch): + monkeypatch.setattr(apply_mod.time, "sleep", lambda s: None) + client = AlwaysNoneClient() + rows = [{"target_playlist": "Рок — RU", "id": 1, "album_id": 10}] + + try: + apply_mod.apply_classification(client, rows, delay=0) + assert False, "должно было упасть, а не молча решить, что плейлист пуст" + except RuntimeError: + pass