Sort_YM/sort_ym/source.py
Andrew Pimenov ff452b0162 add --source flag to read/sort an arbitrary playlist by link
report/apply/digest can now point at any Yandex Music playlist via a
share link instead of only the account's likes. Fetches the playlist's
tracks and artist genres straight into memory (no disk cache, nothing
persisted) and feeds them through the existing build_rows/render_digest/
apply_classification pipeline. apply supports --label to keep a foreign
playlist's tracks in separate genre playlists instead of merging them
into your own. Closes a gap where UnauthorizedError went uncaught anywhere.
2026-08-09 11:59:39 +03:00

200 lines
8.4 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.

"""Эфемерный источник треков: чужой (или свой) плейлист по ссылке.
Ничего не пишет на диск - данные внешнего плейлиста живут только в памяти одного запуска
(см. ARCHITECTURE - истории по внешним источникам нет, в отличие от cache/tracks.json для лайков).
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
from typing import NamedTuple
from urllib.parse import unquote, urlsplit
from yandex_music import Client, Playlist
from yandex_music.exceptions import NotFoundError, UnauthorizedError
from . import fetch
from .ymclient import chunked, with_retries
HOST_RE = re.compile(r"^(?:www\.)?music\.yandex\.[a-z]{2,3}$", re.IGNORECASE)
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
_LINK_HINT = (
"Ожидается ссылка вида https://music.yandex.ru/users/<логин>/playlists/<номер> - "
"откройте плейлист в веб-плеере Яндекс.Музыки и скопируйте адрес из строки браузера."
)
class SourceError(Exception):
"""Ссылка непонятна или плейлист недоступен - текст уже готов для показа пользователю."""
@dataclass(frozen=True)
class PlaylistRef:
url: str
user_id: str | None = None
kind: str | None = None
uuid: str | None = None # форма /playlists/<uuid>
@dataclass(frozen=True)
class SourceInfo:
url: str
title: str
owner: str
track_count: int
class SourceData(NamedTuple):
info: SourceInfo
tracks: dict[str, dict] # форма == fetch.serialize_track()
artist_genres: dict[str, dict] # форма == fetch.load_artist_genres()
def parse_playlist_url(url: str) -> PlaylistRef:
"""https://music.yandex.ru/users/<логин>/playlists/<kind> или /playlists/<uuid> -> PlaylistRef.
Библиотека yandex_music не умеет разбирать ссылки сама - парсинг целиком на нашей стороне.
"""
raw = url.strip()
if not raw:
raise SourceError(f"Пустая ссылка на плейлист. {_LINK_HINT}")
if "//" not in raw:
raw = f"https://{raw}"
parsed = urlsplit(raw)
host = (parsed.hostname or "").lower()
if not HOST_RE.match(host):
raise SourceError(f"Это не похоже на ссылку Яндекс.Музыки: '{url}'. {_LINK_HINT}")
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 4 and parts[0] == "users" and parts[2] == "playlists" and parts[3].isdigit():
return PlaylistRef(url=url, user_id=parts[1], kind=parts[3])
if len(parts) == 2 and parts[0] == "playlists" and UUID_RE.match(parts[1]):
return PlaylistRef(url=url, uuid=parts[1])
raise SourceError(f"Не удалось разобрать ссылку на плейлист: '{url}'. {_LINK_HINT}")
def _owner_label(playlist: Playlist) -> str:
owner = playlist.owner
if owner is None:
return "неизвестен"
return owner.login or owner.name or str(owner.uid)
def _load_playlist(client: Client, ref: PlaylistRef) -> Playlist:
"""Читает плейлист по ref. with_retries не повторяет NotFoundError/UnauthorizedError -
оба финальные ответы сервера, а не сетевой сбой, лишние попытки на приватный плейлист не нужны.
"""
try:
if ref.uuid:
playlist = with_retries(lambda: client.playlist(ref.uuid))
else:
playlist = with_retries(lambda: client.users_playlists(ref.kind, user_id=ref.user_id))
except NotFoundError:
raise SourceError(
f"Плейлист не найден: {ref.url}\n"
"Проверьте ссылку - возможно, плейлист удалён, переименован владельцем или "
"ссылка скопирована не полностью."
)
except UnauthorizedError:
raise SourceError(
f"Нет доступа к плейлисту: {ref.url}\n"
"Скорее всего он приватный (виден только владельцу) - попросите владельца сделать "
"его публичным. Если доступ точно есть, мог истечь токен: python -m sort_ym auth"
)
if playlist is None:
raise SourceError(
f"Не удалось прочитать плейлист по ссылке {ref.url} - API вернул пустой ответ. "
"Повторите позже."
)
return playlist
def fetch_playlist_tracks(
client: Client,
ref: PlaylistRef,
batch_size: int,
batch_delay: float,
) -> tuple[SourceInfo, dict[str, dict]]:
playlist = _load_playlist(client, ref)
info = SourceInfo(
url=ref.url,
title=playlist.title or "без названия",
owner=_owner_label(playlist),
track_count=playlist.track_count or 0,
)
short = playlist.tracks or []
if not short:
if info.track_count == 0:
raise SourceError(f"Плейлист «{info.title}» пуст - анализировать нечего.")
if ref.uuid:
raise SourceError(
f"Плейлист «{info.title}» открылся, но список треков по ссылке этого вида "
"получить не удалось. Откройте плейлист в веб-плеере и скопируйте ссылку вида "
"https://music.yandex.ru/users/<логин>/playlists/<номер>."
)
raise SourceError(
f"Плейлист «{info.title}»: API сообщает о {info.track_count} треках, но список "
"треков не вернул. Повторите позже."
)
# Дедуп с сохранением порядка - чужие плейлисты чаще лайков содержат один трек дважды.
ids = list(dict.fromkeys(t.track_id for t in short))
print(f"Треков в плейлисте «{info.title}»: {len(ids)}")
tracks: dict[str, dict] = {}
missing = 0
for batch in chunked(ids, batch_size):
fetched = with_retries(lambda: client.tracks(batch))
# Сопоставляем по id, а не позиционным zip(batch, fetched) - в чужом плейлисте заметно
# вероятнее недоступные/удалённые треки, и укороченный ответ при zip тихо перепутал бы
# треки между собой.
by_id = {str(t.id): t for t in fetched}
for requested_id in batch:
track = by_id.get(requested_id.split(":")[0])
if track is None:
missing += 1
continue
tracks[requested_id] = fetch.serialize_track(track)
print(f" загружено {len(tracks)}/{len(ids)}")
time.sleep(batch_delay)
if missing:
print(f" недоступно в каталоге: {missing}")
return info, tracks
def fetch_artist_genres_map(
client: Client,
artist_ids: list[str],
batch_size: int,
batch_delay: float,
) -> dict[str, dict]:
"""Как fetch.fetch_artist_genres, но без чтения/записи кэша - только в память."""
result: dict[str, dict] = {}
for batch in chunked(artist_ids, batch_size):
artists = with_retries(lambda: client.artists(batch))
for artist in artists:
result[str(artist.id)] = {"name": artist.name or "", "genres": list(artist.genres or [])}
time.sleep(batch_delay)
return result
def load_source(client: Client, url: str, batch_size: int, batch_delay: float) -> SourceData:
ref = parse_playlist_url(url)
info, tracks = fetch_playlist_tracks(client, ref, batch_size, batch_delay)
artist_genres = fetch_artist_genres_map(client, fetch.unique_artist_ids(tracks), batch_size, batch_delay)
return SourceData(info=info, tracks=tracks, artist_genres=artist_genres)