from __future__ import annotations import time from yandex_music import Client, Playlist from yandex_music.exceptions import BadRequestError, NetworkError from .ymclient import with_retries def _existing_playlists_by_title(client: Client) -> dict[str, Playlist]: playlists = with_retries(lambda: client.users_playlists_list()) return {p.title: p for p in playlists if p.title} 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: if title in existing: return existing[title] try: playlist = with_retries(lambda: client.users_playlists_create(title, visibility="private"), max_attempts=1) except NetworkError: # Таймаут не значит, что запрос не дошёл до сервера - плейлист мог всё же # создаться. Перепроверяем по имени вместо слепого повтора (иначе - дубль плейлиста). fresh = _existing_playlists_by_title(client) if title in fresh: existing[title] = fresh[title] return fresh[title] raise existing[title] = playlist return playlist def _insert_track_verified( client: Client, playlist: Playlist, row: dict, track_key: str, revision: int, max_attempts: int = 4, ) -> int: """Добавляет трек в плейлист, возвращает новую revision. users_playlists_insert_track - операция записи, а не чтения: при таймауте непонятно, применилась ли вставка на сервере. Слепой повтор такого вызова рискует добавить трек второй раз. Поэтому при сетевой ошибке перепроверяем реальное содержимое плейлиста и повторяем вставку, только если трека там действительно ещё нет. """ for attempt in range(max_attempts): try: updated = with_retries( lambda: client.users_playlists_insert_track( playlist.kind, row["id"], row["album_id"], revision=revision ), 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}), проверяю фактическое состояние плейлиста...") fresh = with_retries(lambda: client.users_playlists(playlist.kind)) if fresh is not None: revision = fresh.revision or revision if track_key in {t.track_id for t in fresh.tracks}: return revision if last_attempt: raise time.sleep(2.0 * (2**attempt)) raise RuntimeError("unreachable") def apply_classification( client: Client, rows: list[dict], delay: float, limit: int | None = None, ) -> None: if limit is not None: rows = rows[:limit] existing_playlists = _existing_playlists_by_title(client) by_playlist: dict[str, list[dict]] = {} for row in rows: by_playlist.setdefault(row["target_playlist"], []).append(row) for title, playlist_rows in by_playlist.items(): playlist = _get_or_create_playlist(client, title, existing_playlists) already = _existing_track_ids(client, playlist) revision = playlist.revision or 1 added = 0 no_album = 0 failed = 0 for row in playlist_rows: # Сервер никогда не возвращает albumId у треков в плейлисте (Track.album_id всегда # None при чтении, проверено вживую на десятках плейлистов, включая чужие) - track_id # всегда голый id. albumId нужен только для самой вставки, не для сравнения. track_key = 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) 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)