Sort_YM/sort_ym/apply.py
Andrew Pimenov 1c814830e5 fix apply always re-duplicating tracks on every re-run
track_key compared "id:albumId" against playlist track_id, but Yandex
never echoes albumId back for playlist entries (always None on read) -
so the existing-tracks check could never match anything, and every
apply run re-inserted the whole account on top of itself.
2026-07-16 16:22:04 +03:00

138 lines
6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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