mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 15:42:02 +03:00
232 lines
11 KiB
Python
232 lines
11 KiB
Python
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}")
|