diff --git a/config.toml b/config.toml index 050819b..ca8191b 100644 --- a/config.toml +++ b/config.toml @@ -3,6 +3,8 @@ fetch_batch_delay = 0.4 lyrics_request_delay = 0.25 apply_request_delay = 0.3 +# Таймаут одного запроса, секунды (библиотека по умолчанию ставит 5 - мало при медленной сети) +request_timeout = 20 [fetch] # Сколько id треков в одном батч-запросе client.tracks(...) diff --git a/sort_ym/apply.py b/sort_ym/apply.py index c207d6a..2a2d929 100644 --- a/sort_ym/apply.py +++ b/sort_ym/apply.py @@ -4,14 +4,16 @@ import time from yandex_music import Client, Playlist +from .ymclient import with_retries + def _existing_playlists_by_title(client: Client) -> dict[str, Playlist]: - playlists = client.users_playlists_list() + 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) -> set[str]: - full = client.users_playlists(playlist.kind) + full = with_retries(lambda: client.users_playlists(playlist.kind)) if full is None: return set() return {t.track_id for t in full.tracks} @@ -20,7 +22,7 @@ def _existing_track_ids(client: Client, playlist: Playlist) -> set[str]: def _get_or_create_playlist(client: Client, title: str, existing: dict[str, Playlist]) -> Playlist: if title in existing: return existing[title] - playlist = client.users_playlists_create(title, visibility="private") + playlist = with_retries(lambda: client.users_playlists_create(title, visibility="private")) existing[title] = playlist return playlist @@ -51,11 +53,13 @@ def apply_classification( if track_key in already: continue - updated = client.users_playlists_insert_track( - playlist.kind, - row["id"], - row["album_id"], - revision=revision, + updated = with_retries( + lambda: client.users_playlists_insert_track( + playlist.kind, + row["id"], + row["album_id"], + revision=revision, + ) ) if updated is not None and updated.revision is not None: revision = updated.revision diff --git a/sort_ym/cli.py b/sort_ym/cli.py index 576035f..697bf30 100644 --- a/sort_ym/cli.py +++ b/sort_ym/cli.py @@ -15,13 +15,13 @@ def cmd_auth(args: argparse.Namespace) -> None: def cmd_fetch(args: argparse.Namespace) -> None: cfg = load_config() - client = make_client(auth.get_token(cfg.token_file)) + client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout) fetch.fetch_liked_tracks(client, cfg.cache_dir, cfg.batch_size, cfg.fetch_batch_delay) def cmd_report(args: argparse.Namespace) -> None: cfg = load_config() - client = make_client(auth.get_token(cfg.token_file)) + client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout) tracks_cache = fetch.load_tracks_cache(cfg.cache_dir) if not tracks_cache: @@ -53,7 +53,7 @@ def cmd_apply(args: argparse.Namespace) -> None: raise SystemExit("Это изменит ваш аккаунт. Добавьте флаг --yes для подтверждения.") cfg = load_config() - client = make_client(auth.get_token(cfg.token_file)) + client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout) tracks_cache = fetch.load_tracks_cache(cfg.cache_dir) if not tracks_cache: diff --git a/sort_ym/config.py b/sort_ym/config.py index d046d94..14a26fc 100644 --- a/sort_ym/config.py +++ b/sort_ym/config.py @@ -12,6 +12,7 @@ class Config: fetch_batch_delay: float lyrics_request_delay: float apply_request_delay: float + request_timeout: float batch_size: int token_file: Path cache_dir: Path @@ -27,6 +28,7 @@ def load_config(path: Path | None = None) -> Config: fetch_batch_delay=data["throttle"]["fetch_batch_delay"], lyrics_request_delay=data["throttle"]["lyrics_request_delay"], apply_request_delay=data["throttle"]["apply_request_delay"], + request_timeout=data["throttle"]["request_timeout"], batch_size=data["fetch"]["batch_size"], token_file=ROOT / data["paths"]["token_file"], cache_dir=ROOT / data["paths"]["cache_dir"], diff --git a/sort_ym/fetch.py b/sort_ym/fetch.py index a2e8208..6546daa 100644 --- a/sort_ym/fetch.py +++ b/sort_ym/fetch.py @@ -6,7 +6,7 @@ from pathlib import Path from yandex_music import Client, Track -from .ymclient import chunked +from .ymclient import chunked, with_retries TRACKS_CACHE_FILE = "tracks.json" @@ -53,7 +53,7 @@ def fetch_liked_tracks( cache_file = cache_dir / TRACKS_CACHE_FILE cache = load_tracks_cache(cache_dir) - likes = client.users_likes_tracks() + likes = with_retries(lambda: client.users_likes_tracks()) if likes is None: print("Не удалось получить список лайкнутых треков.") return cache @@ -64,7 +64,7 @@ def fetch_liked_tracks( print(f"Лайкнутых треков: {len(all_ids)}, новых для загрузки: {len(missing_ids)}") for batch in chunked(missing_ids, batch_size): - tracks = client.tracks(batch) + tracks = with_retries(lambda: client.tracks(batch)) for track in tracks: cache[track.track_id] = _serialize(track) _atomic_write_json(cache_file, cache) diff --git a/sort_ym/genres.py b/sort_ym/genres.py index af40db4..7b78081 100644 --- a/sort_ym/genres.py +++ b/sort_ym/genres.py @@ -5,6 +5,8 @@ from pathlib import Path from yandex_music import Client, Genre +from .ymclient import with_retries + GENRE_CATALOG_FILE = "genre_catalog.json" # Корневые жанры каталога Яндекс.Музыки -> наши крупные корзины. @@ -83,7 +85,7 @@ def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]: if cache_file.exists(): return json.loads(cache_file.read_text(encoding="utf-8")) - roots = client.genres() + roots = with_retries(lambda: client.genres()) flat = flatten_catalog(roots) cache_file.write_text(json.dumps(flat, ensure_ascii=False, indent=2), encoding="utf-8") return flat diff --git a/sort_ym/language.py b/sort_ym/language.py index 2bb5a98..bd82659 100644 --- a/sort_ym/language.py +++ b/sort_ym/language.py @@ -6,7 +6,9 @@ import unicodedata from pathlib import Path from yandex_music import Client -from yandex_music.exceptions import YandexMusicError +from yandex_music.exceptions import NotFoundError + +from .ymclient import with_retries LYRICS_LANG_CACHE_FILE = "lyrics_lang.json" @@ -99,8 +101,8 @@ def load_lang_cache(cache_dir: Path) -> dict[str, str | None]: def _fetch_one_language(client: Client, track_id: str) -> str | None: try: - supplement = client.track_supplement(track_id) - except YandexMusicError: + supplement = with_retries(lambda: client.track_supplement(track_id)) + except NotFoundError: return None if supplement is None or supplement.lyrics is None: return None diff --git a/sort_ym/ymclient.py b/sort_ym/ymclient.py index 17b376b..b911cc6 100644 --- a/sort_ym/ymclient.py +++ b/sort_ym/ymclient.py @@ -1,18 +1,44 @@ from __future__ import annotations -from typing import Iterator, List, TypeVar +import time +from typing import Callable, Iterator, List, TypeVar from yandex_music import Client +from yandex_music.exceptions import NetworkError, NotFoundError T = TypeVar("T") -def make_client(token: str) -> Client: +def make_client(token: str, request_timeout: float = 20) -> Client: client = Client(token=token) - client.init() + client.request.set_timeout(request_timeout) + with_retries(lambda: client.init()) return client def chunked(items: List[T], size: int) -> Iterator[List[T]]: for i in range(0, len(items), size): yield items[i : i + size] + + +def with_retries(fn: Callable[[], T], max_attempts: int = 4, base_delay: float = 2.0) -> T: + """Повторяет вызов при временных сетевых сбоях (таймауты и т.п.) с экспоненциальной паузой. + + NotFoundError не повторяется - это окончательный ответ API, а не сбой сети, + хотя формально он тоже унаследован от NetworkError. + """ + last_exc: NetworkError | None = None + for attempt in range(max_attempts): + try: + return fn() + except NotFoundError: + raise + except NetworkError as e: + last_exc = e + if attempt < max_attempts - 1: + wait = base_delay * (2**attempt) + print(f" сетевая ошибка ({e}), повтор через {wait:.0f}с...") + time.sleep(wait) + + assert last_exc is not None + raise last_exc