mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 15:42:02 +03:00
with_retries caught bare Exception with a type whitelist, masking real bugs as network errors under retry/backoff - narrowed back to NetworkError. fetch_api_languages was writing full lyrics text for every language into lyrics_text.json via its track_supplement side channel, breaking the documented RU-only invariant and letting analyze feed foreign-language lyrics through the Russian-only prompt - now gated on normalize_api_language. dedupe_playlists logged the found duplicate count as "removed" even when a mid-loop network/API error cut deletion short. THEME_PATTERN's relaxed regex allowed 2-char themes, contradicting its own test; current_themes was hoisted out of the analyze loop so later prompts never saw themes found earlier in the same run. Both reverted to intended behavior, docs updated to match.
237 lines
12 KiB
Python
237 lines
12 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_found = 0
|
||
total_removed = 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_found += n
|
||
|
||
if dry_run:
|
||
print(f"{playlist.title}: найдено дублей {n} ({len(ranges)} диапазон(ов))")
|
||
continue
|
||
|
||
revision = full.revision or 1
|
||
removed = 0
|
||
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
|
||
removed += to - frm
|
||
time.sleep(delay)
|
||
total_removed += removed
|
||
suffix = "" if removed == n else f" из {n} найденных"
|
||
print(f"{playlist.title}: удалено {removed} дублей{suffix}")
|
||
|
||
if dry_run:
|
||
if total_found:
|
||
print(f"\nВсего дублей найдено: {total_found} в {total_ranges} диапазон(ов). Запустите с --yes для удаления.")
|
||
else:
|
||
print("\nДублей не найдено.")
|
||
else:
|
||
print(f"\nВсего удалено дублей: {total_removed}")
|