Sort_YM/sort_ym/apply.py
2026-08-19 03:53:25 +03:00

232 lines
11 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, 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 {str(t.track_id).split(":")[0] 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 {str(t.track_id).split(":")[0] 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)
def find_duplicate_ranges(track_ids: list[str]) -> list[tuple[int, int]]:
"""Индексы всех повторных вхождений track_id (кроме первого), сгруппированные в
непрерывные диапазоны [from, to) - в формате, который ожидает delete_track."""
seen: set[str] = set()
dup_indices: list[int] = []
for i, tid in enumerate(track_ids):
if tid in seen:
dup_indices.append(i)
else:
seen.add(tid)
ranges: list[tuple[int, int]] = []
for i in dup_indices:
if ranges and ranges[-1][1] == i:
ranges[-1] = (ranges[-1][0], i + 1)
else:
ranges.append((i, i + 1))
return ranges
def dedupe_playlists(client: Client, delay: float, dry_run: bool = True) -> None:
"""Убирает повторные вставки одного и того же трека в плейлистах (см. _existing_track_ids).
Удаление позиционное (from/to индекс), поэтому при сетевой ошибке не повторяем вслепую -
индексы могли уже сдвинуться. Просто прерываем обработку этого плейлиста; повторный запуск
dedupe безопасен и идемпотентен, так как каждый раз пересчитывает диапазоны от актуального
состояния плейлиста.
"""
playlists = with_retries(lambda: client.users_playlists_list())
total_ranges = 0
total_tracks = 0
for playlist in playlists:
full = with_retries(lambda: client.users_playlists(playlist.kind))
if full is None:
print(f" не удалось прочитать «{playlist.title}», пропускаю")
continue
ids = [str(t.track_id).split(":")[0] for t in full.tracks]
ranges = find_duplicate_ranges(ids)
if not ranges:
continue
n = sum(to - frm for frm, to in ranges)
total_ranges += len(ranges)
total_tracks += n
if dry_run:
print(f"{playlist.title}: найдено дублей {n} ({len(ranges)} диапазон(ов))")
continue
revision = full.revision or 1
for frm, to in sorted(ranges, key=lambda r: -r[0]):
try:
updated = with_retries(lambda: client.users_playlists_delete_track(playlist.kind, frm, to, revision=revision))
if updated is not None and updated.revision is not None:
revision = updated.revision
except NetworkError as e:
print(f" сетевая ошибка при удалении из «{playlist.title}» ({e}), прерываю")
break
except BadRequestError as e:
print(f" ошибка при удалении из «{playlist.title}» ({e}), прерываю")
break
time.sleep(delay)
print(f"{playlist.title}: удалено {n} дублей")
if dry_run:
if total_tracks:
print(f"\nВсего дублей найдено: {total_tracks} в {total_ranges} диапазон(ов). Запустите с --yes для удаления.")
else:
print("\nДублей не найдено.")
else:
print(f"\nВсего удалено дублей: {total_tracks}")