From ff452b016238df56be9e4a5e021f001daa21c588 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Thu, 6 Aug 2026 19:15:57 +0300 Subject: [PATCH] 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. --- ARCHITECTURE.md | 56 ++++++++- ARCHITECTURE.ru.md | 55 ++++++++- README.md | 44 ++++++- README.ru.md | 44 ++++++- sort_ym/classify.py | 5 + sort_ym/cli.py | 144 ++++++++++++++++++---- sort_ym/digest.py | 70 ++++++++--- sort_ym/fetch.py | 24 ++-- sort_ym/report.py | 7 +- sort_ym/source.py | 200 +++++++++++++++++++++++++++++++ tests/test_apply.py | 17 +++ tests/test_cli.py | 18 +++ tests/test_digest.py | 24 ++++ tests/test_report.py | 18 +++ tests/test_source.py | 275 +++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 932 insertions(+), 69 deletions(-) create mode 100644 sort_ym/source.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_source.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c726628..082cb00 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,17 +13,23 @@ sort_ym/ cache/token paths) auth.py — OAuth device-flow, token storage in .token ymclient.py — Client construction, batching lists, retrying network errors - fetch.py — likes -> full Track -> cache/tracks.json; track artists -> - client.artists() -> cache/artist_genres.json (both fetched incrementally) + fetch.py — likes -> full Track -> cache/tracks.json (serialize_track); track artists -> + client.artists() -> cache/artist_genres.json (both fetched incrementally); + unique_artist_ids — unique artist ids from the track cache + source.py — ephemeral source: parses a link to an arbitrary playlist + loads its tracks/ + artists into memory, never touches disk (see "--source" below) genres.py — the real Yandex genre tree; classify_track — resolves track genre and artist genres into a single sub-genre (root_id); ROOT_BUCKET — sub-genre -> 11 top-level buckets language.py — hybrid language detection (lyrics API / genre hint / alphabet) - classify.py — (sub-genre | top-level bucket, language) -> playlist name + classify.py — (sub-genre | top-level bucket, language) -> playlist name; with_label — source + label suffix for apply --source --label report.py — two-pass report row building, writes out/report.csv, summaries apply.py — playlist creation + idempotent track insertion digest.py — aggregated library summary (top artists, genres, decades, top albums) - -> out/digest.md, fully offline - cli.py — auth / fetch / report / apply / dedupe / digest commands + -> out/digest.md, fully offline without --source; Wording/playlist_wording — + digest text for likes vs an arbitrary playlist + cli.py — auth / fetch / report / apply / dedupe / digest commands; --source/--label + on report/apply/digest (see "Key decisions") tests/ test_genres.py — classify_track, dominant, bucket_for, genre tree @@ -31,6 +37,9 @@ tests/ test_report.py — two-pass collapsing of small sub-genre groups test_fetch.py, test_apply.py — track/artist cache, apply idempotency test_digest.py — artist/album aggregation, long-tail collapsing, digest size bounds + test_source.py — playlist URL parsing, matching client.tracks() response by id, handling + private/inaccessible playlists + test_cli.py — --source/--label flag validation before any network access cache/ — API cache (in .gitignore) out/ — reports (in .gitignore) @@ -50,6 +59,14 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json apply --yes → users_playlists_create / users_playlists_insert_track digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network) → build_rows() → out/digest.md + +report/apply/digest --source → source.parse_playlist_url() → (user_id, kind) + → client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch) + → an in-memory dict (fetch.serialize_track shape, never written to disk) + → client.artists(batch) → genres.load_or_fetch_catalog() (shared cache/genre_catalog.json) + + language.load_lang_cache() (read-only) → build_rows() + → out/report_source.csv | out/digest_source.md | apply → playlists on your own account + (shared "Genre — RU/INT" or "... (label)" with --label) ``` ## Key decisions @@ -109,7 +126,7 @@ digest → genres.load_catalog() + language.load_lang_cache() (cache only, no ne (genre sections are bounded by the size of `ROOT_BUCKET`/`BUCKET_LABELS`; top artists and top albums are bounded by the `[digest]` config, not the library size; anything past the top is collapsed into a single sentence with aggregate numbers). Artists are counted **by name**, not via - `zip(artists, artist_ids)` — `fetch._serialize` puts every named artist into `artists` but only + `zip(artists, artist_ids)` — `fetch.serialize_track` puts every named artist into `artists` but only those that also have an id into `artist_ids`, so the two lists can differ in length for a given track. For the same reason, an artist's own genre tags (`artist_tags`) are only looked up from tracks where the two lists' lengths match — otherwise an artist could end up with someone else's @@ -123,6 +140,31 @@ digest → genres.load_catalog() + language.load_lang_cache() (cache only, no ne truthy, otherwise tracks with a legitimately unknown year would be re-fetched on every `fetch` run. The command is fully offline — it never constructs a `Client`, unlike `report`/`apply`. +- **Arbitrary playlist by link (`source.py`, `--source` on `report`/`apply`/`digest`)**: an + external source is **ephemeral** — its data is fetched into memory on every run and never + written to disk (`source.py` imports no `json` and never touches `cache_dir` at all). The one + exception is the shared `cache/genre_catalog.json` (Yandex's genre tree — a reference dataset, + not a specific playlist's history), reused as usual via `genres.load_or_fetch_catalog`. Writes + always target your own account: a foreign `user_id` is only ever passed into + `client.users_playlists(kind, user_id=...)` (a read); no call in `apply.py` + (`users_playlists_create`, `users_playlists_insert_track`) accepts one. The `yandex_music` + library has no URL parser at all — `source.parse_playlist_url` parses + `https://music.yandex.ru/users//playlists/` (plus a best-effort `/playlists/` + form via `client.playlist(uuid)`) itself, with no external dependency. `Playlist.tracks` is a + list of `TrackShort` (no genre/album data); full `Track` objects are fetched with the same batch + method used for likes, `client.tracks(...)`, but the response is matched back to the requested + id by value (`by_id.get(...)`), not a positional `zip` — a foreign playlist is noticeably more + likely to contain unavailable tracks, and a shortened response under `zip` would silently mix up + track data (`Playlist.fetch_tracks()` is a trap — it just re-calls `users_playlists` and returns + the same short objects, so it isn't used). `UnauthorizedError` extends `YandexMusicError` + directly, not `NetworkError`, so `ymclient.with_retries` never catches or retries it — it used to + be unhandled anywhere in the project; it's now caught in `source._load_playlist` (a private or + inaccessible playlist) and by a top-level net in `cli.main()` (an expired token on any command). + Language for an external source is not refined via `track_supplement` (one uncached network + request per track on every run) — it falls back to the existing `cache/lyrics_lang.json` (read + only) plus the heuristics; a track whose language disagrees with what the precise check would + have picked can land in the wrong language playlist under `apply --source` without `--label`. + ## Storage - `.token` — the account's OAuth token (not committed). @@ -137,3 +179,5 @@ digest → genres.load_catalog() + language.load_lang_cache() (cache only, no ne `fine_bucket` column — the sub-genre before any collapsing into a top-level bucket). - `out/digest.md` — a compact library summary for pasting into an LLM chat (top artists, top albums, genre/language/decade breakdowns). +- `out/report_source.csv`, `out/digest_source.md` — the same reports for a `--source` run; a + single overwritten "last external source" slot, no history of other playlists is kept. diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index 22e5ba2..fe6830c 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -13,17 +13,23 @@ sort_ym/ пути к кэшу/токену) auth.py — OAuth device-flow, хранение токена в .token ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок - fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков -> - client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально) + fetch.py — лайки -> полные Track -> cache/tracks.json (serialize_track); артисты треков -> + client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально); + unique_artist_ids — уникальные id артистов из кэша треков + source.py — эфемерный источник: разбор ссылки на произвольный плейлист + загрузка его + треков/артистов в память, без записи на диск (см. "--source" ниже) genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит) - classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста + classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки + источника для apply --source --label report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки apply.py — создание плейлистов + идемпотентное добавление треков digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия, - топ-альбомы) -> out/digest.md, полностью офлайн - cli.py — команды auth / fetch / report / apply / dedupe / digest + топ-альбомы) -> out/digest.md, полностью офлайн без --source; Wording/ + playlist_wording — тексты дайджеста для лайков vs произвольного плейлиста + cli.py — команды auth / fetch / report / apply / dedupe / digest; --source/--label + на report/apply/digest (см. "Ключевые решения") tests/ test_genres.py — classify_track, dominant, bucket_for, дерево жанров @@ -32,6 +38,9 @@ tests/ test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста, границы размера дайджеста + test_source.py — разбор ссылки на плейлист, сопоставление ответа client.tracks() по id, + обработка приватных/недоступных плейлистов + test_cli.py — валидация флагов --source/--label до обращения к сети cache/ — кэш API (в .gitignore) out/ — отчёты (в .gitignore) @@ -51,6 +60,14 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json apply --yes → users_playlists_create / users_playlists_insert_track digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется) → build_rows() → out/digest.md + +report/apply/digest --source → source.parse_playlist_url() → (user_id, kind) + → client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch) + → словарь в памяти (форма fetch.serialize_track, на диск не пишется) + → client.artists(batch) → genres.load_or_fetch_catalog() (общий cache/genre_catalog.json) + + language.load_lang_cache() (только чтение) → build_rows() + → out/report_source.csv | out/digest_source.md | apply → плейлисты своего аккаунта + (общие "Жанр — RU/INT" или "... (метка)" при --label) ``` ## Ключевые решения @@ -110,7 +127,7 @@ digest → genres.load_catalog() + language.load_lang_cache() (только кэ 10000 (жанровые секции ограничены размером `ROOT_BUCKET`/`BUCKET_LABELS`, топ-исполнители и топ-альбомы — конфигом `[digest]`, а не всей библиотекой; хвост за пределами топа схлопывается в одну фразу с суммарными числами). Исполнители считаются **по имени**, а не через - `zip(artists, artist_ids)` — `fetch._serialize` кладёт в `artists` всех артистов с именем, а в + `zip(artists, artist_ids)` — `fetch.serialize_track` кладёт в `artists` всех артистов с именем, а в `artist_ids` только тех, у кого есть и имя, и id, поэтому списки могут расходиться по длине для одного трека. По той же причине собственные жанровые теги артиста (`artist_tags`) сопоставляются только по трекам, где длины списков совпадают — иначе можно присвоить артисту чужие теги. @@ -123,6 +140,30 @@ digest → genres.load_catalog() + language.load_lang_cache() (только кэ значения, иначе треки с легитимно неизвестным годом перекачивались бы при каждом `fetch`. Команда полностью офлайн — `Client` не создаётся вообще, в отличие от `report`/`apply`. +- **Произвольный плейлист по ссылке (`source.py`, флаг `--source` на `report`/`apply`/`digest`)**: + внешний источник **эфемерен** — данные загружаются в память при каждом запуске и никогда не + пишутся на диск (`source.py` не импортирует `json` и не трогает `cache_dir` вообще). Единственное + исключение — общий `cache/genre_catalog.json` (дерево жанров Яндекса, не история конкретного + плейлиста) переиспользуется как обычно через `genres.load_or_fetch_catalog`. Запись всегда идёт + в свой аккаунт: foreign `user_id` передаётся только в `client.users_playlists(kind, user_id=...)` + (чтение), ни один вызов `apply.py` (`users_playlists_create`, `users_playlists_insert_track`) его + не принимает. Разбора ссылки в библиотеке `yandex_music` нет вообще — `source.parse_playlist_url` + разбирает `https://music.yandex.ru/users/<логин>/playlists/` (плюс best-effort форма + `/playlists/` через `client.playlist(uuid)`) сам, без внешних зависимостей. `Playlist.tracks` + — это `TrackShort` (без жанра/альбома), полные `Track` докачиваются тем же батч-методом + `client.tracks(...)`, что и лайки, но результат сопоставляется с запрошенным id по значению + (`by_id.get(...)`), а не позиционным `zip` — в чужом плейлисте заметно вероятнее недоступные + треки, и укороченный ответ при `zip` тихо перепутал бы данные между треками (`Playlist.fetch_tracks()` + — ловушка, просто повторяет `users_playlists` и возвращает те же короткие объекты, не используется). + `UnauthorizedError` наследуется от `YandexMusicError`, а не от `NetworkError`, поэтому + `ymclient.with_retries` её не перехватывает и не повторяет — раньше она была необработана нигде в + проекте; теперь ловится в `source._load_playlist` (приватный/недоступный плейлист) и общей сеткой + в `cli.main()` (истёкший токен на любой команде). Язык для внешнего источника не уточняется через + `track_supplement` (сетевой запрос на каждый трек без кэша при каждом прогоне) — используется + только уже существующий `cache/lyrics_lang.json` на чтение плюс эвристики; трек, разошедшийся по + языку с тем, что выбрала бы точная проверка, может попасть не в тот языковой плейлист при + `apply --source` без `--label`. + ## Хранилище - `.token` — OAuth-токен аккаунта (не коммитится). @@ -137,3 +178,5 @@ digest → genres.load_catalog() + language.load_lang_cache() (только кэ `fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). - `out/digest.md` — сжатая сводка библиотеки для вставки в чат с LLM (топ-исполнители, топ-альбомы, распределение по жанрам, языку и десятилетиям). +- `out/report_source.csv`, `out/digest_source.md` — те же отчёты, но для запуска с `--source`; + один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам. diff --git a/README.md b/README.md index 3bb8a24..9545823 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ For development (tests): .venv\Scripts\python -m sort_ym apply --limit 20 --yes # dry run: create playlists and add the first 20 tracks .venv\Scripts\python -m sort_ym apply --yes # create playlists and add all tracks (changes the account) .venv\Scripts\python -m sort_ym digest # library summary (top artists, genres) to out/digest.md - to paste into an LLM chat; no network used +.venv\Scripts\python -m sort_ym report --source # same, but for someone else's/another playlist by link +.venv\Scripts\python -m sort_ym digest --source # digest of that playlist +.venv\Scripts\python -m sort_ym apply --source --label Friend --yes # sort its tracks into your own "Genre — RU (Friend)" playlists ``` On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added. @@ -113,8 +116,10 @@ hard size ceilings: at most 11 lines for top-level genres, ~34 for sub-genres, 3 Anything past the top collapses into a single sentence with aggregate numbers. The resulting file stays a couple hundred lines at most, regardless of whether the library has 500 tracks or 10000. -The command is fully offline: it makes no network calls and needs no `.token`, only the already -downloaded `cache/`. `report` must have run at least once first (for `cache/genre_catalog.json`). +Without `--source` the command is fully offline: no network calls, no `.token` needed, only the +already downloaded `cache/` (requires `report` to have run at least once, for +`cache/genre_catalog.json`). With `--source` (see "Analyzing another playlist" below) it needs +both network and a token, but does not require `fetch` to have run at all. ``` $ python -m sort_ym digest @@ -155,6 +160,40 @@ Example `out/digest.md` (abridged): sections will show "Unknown" until the next `python -m sort_ym fetch` run (it re-downloads the whole cache once, then goes back to only fetching new tracks as usual). +## Analyzing another playlist + +`report`, `digest`, and `apply` can work on any Yandex Music playlist — your own other playlist, +or someone else's public one — not just your likes, via the `--source` flag with a link. + +Getting the link: open the playlist in the web player and copy the address bar — it looks like +`https://music.yandex.ru/users//playlists/`. + +``` +python -m sort_ym report --source https://music.yandex.ru/users/vasya/playlists/1001 +python -m sort_ym digest --source https://music.yandex.ru/users/vasya/playlists/1001 +python -m sort_ym apply --source https://music.yandex.ru/users/vasya/playlists/1001 --label Vasya --yes +``` + +- **`--label `** (`apply` only) — tracks land in separate playlists like "Rock — RU + (Vasya)" instead of the shared "Rock — RU". Without `--label` they go into the same playlists as + your own liked tracks of that genre/language — a deliberate default, but exactly why you should + decide explicitly whether you want someone else's taste mixed into your own for a foreign source. +- **No caching, no history**: an external playlist's data is fetched fresh on every run and never + saved to disk (unlike likes in `cache/tracks.json`) — by design. Re-running against the same + link re-downloads everything from scratch. +- **Private playlists**: if the playlist is inaccessible (private, or your token expired), the + command prints a clear error — either ask the owner to make it public, or re-authenticate + (`python -m sort_ym auth`). +- **Slightly lower language accuracy**: an external source's language isn't refined through the + lyrics API (a per-track request, and without caching it would be slow on every run) — only the + existing `cache/lyrics_lang.json` plus the heuristics. Without `--label` a track can land in the + wrong language playlist, and if you later like it, it ends up in both. +- There's no dry-run for `apply --source` (only `--limit`) — run `report --source` first to see + the playlist breakdown before inserting anything for real. +- `report --source`/`digest --source` write to separate files — + `out/report_source.csv`/`out/digest_source.md` — and never overwrite your main + `out/report.csv`/`out/digest.md`. + ## Handy things - Run `apply` first with `--limit 20 --yes` and check the result in the app before running it @@ -182,6 +221,7 @@ Python 3.11+, `yandex-music==3.0.0`, standard library (`tomllib`, `csv`, `unicod .venv\Scripts\python -m sort_ym report .venv\Scripts\python -m sort_ym apply --limit 20 --yes .venv\Scripts\python -m sort_ym digest +.venv\Scripts\python -m sort_ym report --source https://music.yandex.ru/users/vasya/playlists/1001 ``` Code structure and data flow are in [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/README.ru.md b/README.ru.md index 4b2f7f7..d0bbfaf 100644 --- a/README.ru.md +++ b/README.ru.md @@ -40,6 +40,9 @@ python -m venv .venv .venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков .venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт) .venv\Scripts\python -m sort_ym digest # сводка библиотеки (топ-исполнители, жанры) в out/digest.md - чтобы вставить в чат с LLM; сеть не используется +.venv\Scripts\python -m sort_ym report --source <ссылка> # то же самое, но для чужого/другого плейлиста по ссылке +.venv\Scripts\python -m sort_ym digest --source <ссылка> # дайджест чужого/другого плейлиста +.venv\Scripts\python -m sort_ym apply --source <ссылка> --label Друг --yes # раскидать его треки по своим плейлистам "Жанр — RU (Друг)" ``` При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки. @@ -114,8 +117,10 @@ Remove-Item .token суммарными числами. Итоговый файл — не больше пары сотен строк независимо от того, 500 треков в библиотеке или 10000. -Команда полностью офлайн: она не обращается к сети и не требует `.token`, читает только уже -загруженный `cache/`. Нужно, чтобы `report` был запущен хотя бы раз (для `cache/genre_catalog.json`). +Без `--source` команда полностью офлайн: не обращается к сети, не требует `.token`, читает только +уже загруженный `cache/` (нужно, чтобы `report` был запущен хотя бы раз, для +`cache/genre_catalog.json`). С `--source` (см. раздел «Анализ и раскидка другого плейлиста» ниже) +нужны и сеть, и токен, но `fetch` заранее не требуется. ``` $ python -m sort_ym digest @@ -156,6 +161,40 @@ $ python -m sort_ym digest покажут «Неизвестно» до первого перезапуска `python -m sort_ym fetch` (он один раз доперекачает весь кэш, дальше — как обычно, только новые треки). +## Анализ и раскидка другого плейлиста + +`report`, `digest` и `apply` умеют работать не только с лайками, но и с любым плейлистом +Яндекс.Музыки (своим другим или чужим публичным) — по ссылке, флагом `--source`. + +Как получить ссылку: откройте плейлист в веб-плеере Яндекс.Музыки и скопируйте адрес из строки +браузера — вида `https://music.yandex.ru/users/<логин>/playlists/<номер>`. + +``` +python -m sort_ym report --source https://music.yandex.ru/users/vasya/playlists/1001 +python -m sort_ym digest --source https://music.yandex.ru/users/vasya/playlists/1001 +python -m sort_ym apply --source https://music.yandex.ru/users/vasya/playlists/1001 --label Вася --yes +``` + +- **`--label <имя>`** (только для `apply`) — треки лягут в отдельные плейлисты вида + «Рок — RU (Вася)» вместо общих «Рок — RU». Без `--label` они попадут в те же плейлисты, что и + ваши лайкнутые треки того же жанра/языка — это осознанный дефолт, но именно поэтому для чужого + плейлиста стоит явно решить, нужно ли смешивать чужой вкус со своим. +- **Никакого кэша и истории**: данные внешнего плейлиста загружаются заново при каждом запуске + и никогда не сохраняются на диск (в отличие от лайков в `cache/tracks.json`) — так и задумано. + Повторный запуск на той же ссылке снова скачает всё с нуля. +- **Приватные плейлисты**: если плейлист недоступен (приватный либо истёк токен), команда выдаст + понятную ошибку — либо попросите владельца сделать плейлист публичным, либо перезайдите + (`python -m sort_ym auth`). +- **Точность языка чуть ниже**: для внешнего источника язык не уточняется через API текста песни + (это отдельный запрос на трек, и без кэша он был бы медленным на каждый запуск) — только по + уже накопленному `cache/lyrics_lang.json` и эвристикам. Без `--label` трек может попасть не в + тот языковой плейлист, а если его потом лайкнуть — окажется добавлен в оба. +- Для `apply` нет пробного режима с `--source` (только `--limit`) — сначала проверьте `report + --source`, чтобы увидеть распределение по плейлистам до реальной вставки. +- Результаты `report --source`/`digest --source` сохраняются в отдельные файлы — + `out/report_source.csv`/`out/digest_source.md` — и не затирают ваши основные `out/report.csv`/ + `out/digest.md`. + ## Handy things - Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде @@ -183,6 +222,7 @@ Python 3.11+, `yandex-music==3.0.0`, стандартная библиотека .venv\Scripts\python -m sort_ym report .venv\Scripts\python -m sort_ym apply --limit 20 --yes .venv\Scripts\python -m sort_ym digest +.venv\Scripts\python -m sort_ym report --source https://music.yandex.ru/users/vasya/playlists/1001 ``` Структура кода и поток данных — в [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md). diff --git a/sort_ym/classify.py b/sort_ym/classify.py index 75a264c..739f20d 100644 --- a/sort_ym/classify.py +++ b/sort_ym/classify.py @@ -71,3 +71,8 @@ def playlist_name(bucket: str, lang: str) -> str: bucket_label = _ALL_LABELS.get(bucket, BUCKET_LABELS["other"]) lang_label = LANG_LABELS.get(lang, LANG_LABELS["UNKNOWN"]) return f"{bucket_label} — {lang_label}" + + +def with_label(name: str, label: str) -> str: + """Добавляет метку источника к имени целевого плейлиста: "Рок — RU" -> "Рок — RU (Метка)".""" + return f"{name} ({label})" diff --git a/sort_ym/cli.py b/sort_ym/cli.py index aa775ce..fb9e172 100644 --- a/sort_ym/cli.py +++ b/sort_ym/cli.py @@ -1,13 +1,56 @@ from __future__ import annotations import argparse +from typing import NamedTuple + +from yandex_music import Client +from yandex_music.exceptions import UnauthorizedError from . import apply as apply_mod -from . import auth, classify, digest, fetch, genres, language, report -from .config import load_config +from . import auth, classify, digest, fetch, genres, language, report, source +from .config import Config, load_config from .ymclient import make_client +SOURCE_HELP = ( + "ссылка на плейлист Яндекс.Музыки (https://music.yandex.ru/users/<логин>/playlists/<номер>) - " + "анализировать его вместо своих лайков; данные загружаются заново при каждом запуске и не кэшируются" +) +LABEL_HELP = ( + "метка источника: треки лягут в отдельные плейлисты «Жанр — RU (метка)» вместо общих " + "(имеет смысл только вместе с --source)" +) + + +class _SourceContext(NamedTuple): + client: Client + info: source.SourceInfo + tracks: dict[str, dict] + artist_genres: dict[str, dict] + catalog: dict[str, dict] + lang_cache: dict[str, str | None] + + +def _source_context(cfg: Config, url: str) -> _SourceContext: + try: + source.parse_playlist_url(url) # быстрый провал до токена и сети + except source.SourceError as e: + raise SystemExit(str(e)) + + client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout) + + try: + data = source.load_source(client, url, cfg.batch_size, cfg.fetch_batch_delay) + except source.SourceError as e: + raise SystemExit(str(e)) + + catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir) # общий cache/genre_catalog.json + lang_cache = language.load_lang_cache(cfg.cache_dir) # только чтение, без сети - см. ARCHITECTURE + + print(f"Источник: «{data.info.title}» ({data.info.owner}), треков: {len(data.tracks)}") + return _SourceContext(client, data.info, data.tracks, data.artist_genres, catalog, lang_cache) + + def cmd_auth(args: argparse.Namespace) -> None: cfg = load_config() auth.device_login(cfg.token_file) @@ -20,8 +63,30 @@ def cmd_fetch(args: argparse.Namespace) -> None: fetch.fetch_artist_genres(client, cfg.cache_dir, cfg.batch_size, cfg.fetch_batch_delay) +def _print_report_summary(rows: list[dict], out_file) -> None: + print("\nРаспределение по плейлистам:") + for name, n in sorted(report.summarize(rows).items()): + print(f" {name}: {n}") + + other = report.other_breakdown(rows) + if other: + print("\nНе распознанные жанры (попали в 'Разное'), можно дополнить sort_ym/genres.py:") + for genre_raw, n in sorted(other.items(), key=lambda kv: -kv[1]): + print(f" {genre_raw}: {n}") + + print(f"\nОтчёт сохранён: {out_file}") + + def cmd_report(args: argparse.Namespace) -> None: cfg = load_config() + + if args.source: + ctx = _source_context(cfg, args.source) + rows = report.build_rows(ctx.tracks, ctx.lang_cache, ctx.catalog, ctx.artist_genres, cfg.small_group_min) + out_file = report.write_report(rows, cfg.out_dir, report.REPORT_SOURCE_FILE) + _print_report_summary(rows, out_file) + return + client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout) tracks_cache = fetch.load_tracks_cache(cfg.cache_dir) @@ -36,25 +101,36 @@ def cmd_report(args: argparse.Namespace) -> None: rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min) out_file = report.write_report(rows, cfg.out_dir) - - print("\nРаспределение по плейлистам:") - for name, n in sorted(report.summarize(rows).items()): - print(f" {name}: {n}") - - other = report.other_breakdown(rows) - if other: - print("\nНе распознанные жанры (попали в 'Разное'), можно дополнить sort_ym/genres.py:") - for genre_raw, n in sorted(other.items(), key=lambda kv: -kv[1]): - print(f" {genre_raw}: {n}") - - print(f"\nОтчёт сохранён: {out_file}") + _print_report_summary(rows, out_file) def cmd_apply(args: argparse.Namespace) -> None: if not args.yes: raise SystemExit("Это изменит ваш аккаунт. Добавьте флаг --yes для подтверждения.") + label = None + if args.label is not None: + if not args.source: + raise SystemExit( + "--label имеет смысл только вместе с --source: метка нужна, чтобы отделить " + "треки из чужого плейлиста от ваших собственных." + ) + label = args.label.strip() + if not label: + raise SystemExit("--label не может быть пустым.") + cfg = load_config() + + if args.source: + ctx = _source_context(cfg, args.source) + rows = report.build_rows(ctx.tracks, ctx.lang_cache, ctx.catalog, ctx.artist_genres, cfg.small_group_min) + if label: + for row in rows: + row["target_playlist"] = classify.with_label(row["target_playlist"], label) + print("Целевые плейлисты: " + (f"отдельные, с меткой «{label}»" if label else "общие (как для лайков)")) + apply_mod.apply_classification(ctx.client, rows, cfg.apply_request_delay, limit=args.limit) + return + client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout) tracks_cache = fetch.load_tracks_cache(cfg.cache_dir) @@ -72,22 +148,30 @@ def cmd_apply(args: argparse.Namespace) -> None: def cmd_digest(args: argparse.Namespace) -> None: cfg = load_config() - tracks_cache = fetch.load_tracks_cache(cfg.cache_dir) - if not tracks_cache: - raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch") + if args.source: + ctx = _source_context(cfg, args.source) + tracks_cache, artist_genres, catalog, lang_cache = ctx.tracks, ctx.artist_genres, ctx.catalog, ctx.lang_cache + wording = digest.playlist_wording(ctx.info.title, ctx.info.owner, ctx.info.url) + out_name = digest.DIGEST_SOURCE_FILE + else: + tracks_cache = fetch.load_tracks_cache(cfg.cache_dir) + if not tracks_cache: + raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch") - catalog = genres.load_catalog(cfg.cache_dir) - if not catalog: - raise SystemExit("Нет снимка дерева жанров. Сначала запустите: python -m sort_ym report") + catalog = genres.load_catalog(cfg.cache_dir) + if not catalog: + raise SystemExit("Нет снимка дерева жанров. Сначала запустите: python -m sort_ym report") + + artist_genres = fetch.load_artist_genres(cfg.cache_dir) + lang_cache = language.load_lang_cache(cfg.cache_dir) # только кэш, без сети + wording, out_name = digest.DEFAULT_WORDING, digest.DIGEST_FILE - artist_genres = fetch.load_artist_genres(cfg.cache_dir) - lang_cache = language.load_lang_cache(cfg.cache_dir) # только кэш, без сети rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min) top = args.top if args.top is not None else cfg.digest_top_artists top_albums = args.top_albums if args.top_albums is not None else cfg.digest_top_albums - text = digest.render_digest(rows, tracks_cache, artist_genres, top, top_albums) - out_file = digest.write_digest(text, cfg.out_dir) + text = digest.render_digest(rows, tracks_cache, artist_genres, top, top_albums, wording=wording) + out_file = digest.write_digest(text, cfg.out_dir, out_name) print("\nЖанры:") for g in digest.genre_stats(rows): @@ -108,11 +192,15 @@ def main(argv: list[str] | None = None) -> None: sub.add_parser("auth", help="войти через device-flow и сохранить токен") sub.add_parser("fetch", help="загрузить лайкнутые треки в локальный кэш") - sub.add_parser("report", help="посчитать распределение по плейлистам и сохранить out/report.csv (ничего не меняет в аккаунте)") + + p_report = sub.add_parser("report", help="посчитать распределение по плейлистам и сохранить out/report.csv (ничего не меняет в аккаунте)") + p_report.add_argument("--source", metavar="URL", default=None, help=SOURCE_HELP) p_apply = sub.add_parser("apply", help="создать плейлисты и добавить треки") p_apply.add_argument("--yes", action="store_true", help="подтвердить внесение изменений в аккаунт") p_apply.add_argument("--limit", type=int, default=None, help="ограничить количество треков (для пробного запуска)") + p_apply.add_argument("--source", metavar="URL", default=None, help=SOURCE_HELP) + p_apply.add_argument("--label", metavar="NAME", default=None, help=LABEL_HELP) p_dedupe = sub.add_parser("dedupe", help="найти и убрать повторные вставки одного трека в плейлистах") p_dedupe.add_argument("--yes", action="store_true", help="подтвердить удаление дублей (без флага - только отчёт)") @@ -123,6 +211,7 @@ def main(argv: list[str] | None = None) -> None: ) p_digest.add_argument("--top", type=int, default=None, help="сколько исполнителей показать поимённо (по умолчанию из config.toml)") p_digest.add_argument("--top-albums", type=int, default=None, help="сколько альбомов показать поимённо (по умолчанию из config.toml)") + p_digest.add_argument("--source", metavar="URL", default=None, help=SOURCE_HELP) args = parser.parse_args(argv) @@ -134,7 +223,10 @@ def main(argv: list[str] | None = None) -> None: "dedupe": cmd_dedupe, "digest": cmd_digest, } - commands[args.command](args) + try: + commands[args.command](args) + except UnauthorizedError: + raise SystemExit("Яндекс отклонил запрос - токен мог истечь. Выполните: python -m sort_ym auth") if __name__ == "__main__": diff --git a/sort_ym/digest.py b/sort_ym/digest.py index 8756b01..b768df2 100644 --- a/sort_ym/digest.py +++ b/sort_ym/digest.py @@ -2,11 +2,13 @@ from __future__ import annotations import statistics from collections import Counter +from dataclasses import dataclass from pathlib import Path from . import classify, genres, report DIGEST_FILE = "digest.md" +DIGEST_SOURCE_FILE = "digest_source.md" DECADE_UNKNOWN = "unknown" DECADE_MIN_YEAR = 1900 @@ -14,6 +16,43 @@ DECADE_MAX_YEAR = 2100 DECADE_TOP_GENRES = 3 # константа вёрстки (сколько жанров показывать на десятилетие), не config-knob +@dataclass(frozen=True) +class Wording: + """Тексты дайджеста, зависящие от источника (лайки vs произвольный плейлист по --source). + + Дефолты - буквально сегодняшние строки для лайков, чтобы render_digest(...) без явного + wording давал побайтово тот же результат, что и раньше. + """ + + heading: str = "Дайджест музыкальной библиотеки" + subtitle: str | None = None + tracks_label: str = "Лайкнутых треков" + no_years_note: str = ( + "> Годы релиза и названия альбомов ещё не загружены - запустите `python -m sort_ym " + "fetch` (кэш треков обновится один раз). Секции «Десятилетия» и «Топ альбомов» " + "пока неинформативны." + ) + album_counter_note: str = "Счётчик — сколько треков с альбома лайкнуто." + rest_albums_note: str = "Ещё {n} альбомов с {lo}-{hi} лайкнутыми треками." + deep_albums_note: str = "Треков с альбомов, где лайкнуто 2 и более: {deep} из {total} ({pct}%)." + + +DEFAULT_WORDING = Wording() + + +def playlist_wording(title: str, owner: str, url: str) -> Wording: + """Wording для дайджеста произвольного плейлиста (cmd_digest --source).""" + return Wording( + heading=f"Дайджест плейлиста «{title}»", + subtitle=f"Источник: плейлист «{title}» (владелец: {owner}), ссылка: {url}", + tracks_label="Треков в плейлисте", + no_years_note="> Год выхода и название альбома неизвестны для всех треков этого плейлиста.", + album_counter_note="Счётчик — сколько треков плейлиста относится к альбому.", + rest_albums_note="Ещё {n} альбомов с {lo}-{hi} треками в плейлисте.", + deep_albums_note="Треков с альбомов, где в плейлисте 2 и более трека: {deep} из {total} ({pct}%).", + ) + + def _by_track_key(tracks_cache: dict[str, dict]) -> dict[tuple, dict]: """(id, album_id) из ЗНАЧЕНИЙ кэша -> сама запись кэша. @@ -31,7 +70,7 @@ def _most_common_deterministic(counter: Counter): def _artist_ids_by_name(tracks_cache: dict[str, dict]) -> dict[str, list[str]]: """Имя артиста -> id (в порядке первого появления, без дублей). - fetch._serialize кладёт в artists всех артистов с именем, а в artist_ids - только тех, у кого + fetch.serialize_track кладёт в artists всех артистов с именем, а в artist_ids - только тех, у кого есть и имя, и id, поэтому списки могут расходиться по длине для одного трека. Берём только треки, где длины совпадают - иначе zip присвоил бы имени чужой id. Имя, встречающееся только в рассинхронных треках, остаётся без id (и без тегов) - лучше пусто, чем неверно. @@ -224,6 +263,7 @@ def render_digest( artist_genres: dict[str, dict], top_artists: int, top_albums: int, + wording: Wording = DEFAULT_WORDING, ) -> str: total = len(rows) unique_artists = {name for t in tracks_cache.values() for name in t["artists"]} @@ -237,8 +277,12 @@ def render_digest( known_years = sorted(t["year"] for t in tracks_cache.values() if t.get("year") is not None) - lines: list[str] = ["# Дайджест музыкальной библиотеки", "", "## Итоги"] - lines.append(f"- Лайкнутых треков: {total}") + lines: list[str] = [f"# {wording.heading}", ""] + if wording.subtitle: + lines.append(wording.subtitle) + lines.append("") + lines.append("## Итоги") + lines.append(f"- {wording.tracks_label}: {total}") lines.append(f"- Уникальных исполнителей: {len(unique_artists)}") lines.append( f"- Задействовано крупных корзин: {len(genre_rows)} из {len(classify.BUCKET_LABELS)}, " @@ -251,11 +295,7 @@ def render_digest( f"({_pct(len(known_years), total)}%)" ) else: - lines.append( - "> Годы релиза и названия альбомов ещё не загружены - запустите `python -m sort_ym " - "fetch` (кэш треков обновится один раз). Секции «Десятилетия» и «Топ альбомов» " - "пока неинформативны." - ) + lines.append(wording.no_years_note) lines.append("") lines.append("## Жанры (крупные корзины)") @@ -314,7 +354,7 @@ def render_digest( shown_albums = albums[:top_albums] rest_albums = albums[top_albums:] lines.append(f"## Топ-{len(shown_albums)} альбомов") - lines.append("Счётчик — сколько треков с альбома лайкнуто.") + lines.append(wording.album_counter_note) for i, alb in enumerate(shown_albums, 1): year_part = f" ({alb['year']})" if alb["year"] else "" lines.append(f"{i}. {alb['artists']} — {alb['title']}{year_part} — {alb['tracks']}") @@ -322,9 +362,11 @@ def render_digest( deep_tracks = sum(a["tracks"] for a in albums if a["tracks"] >= 2) lines.append("") lines.append( - f"Ещё {len(rest_albums)} альбомов с {rest_albums[-1]['tracks']}-" - f"{rest_albums[0]['tracks']} лайкнутыми треками. Треков с альбомов, где лайкнуто " - f"2 и более: {deep_tracks} из {total} ({_pct(deep_tracks, total)}%)." + wording.rest_albums_note.format( + n=len(rest_albums), lo=rest_albums[-1]["tracks"], hi=rest_albums[0]["tracks"] + ) + + " " + + wording.deep_albums_note.format(deep=deep_tracks, total=total, pct=_pct(deep_tracks, total)) ) lines.append("") @@ -337,8 +379,8 @@ def render_digest( return "\n".join(lines).rstrip() + "\n" -def write_digest(text: str, out_dir: Path) -> Path: +def write_digest(text: str, out_dir: Path, filename: str = DIGEST_FILE) -> Path: out_dir.mkdir(parents=True, exist_ok=True) - out_file = out_dir / DIGEST_FILE + out_file = out_dir / filename out_file.write_text(text, encoding="utf-8") return out_file diff --git a/sort_ym/fetch.py b/sort_ym/fetch.py index f226cc6..a912408 100644 --- a/sort_ym/fetch.py +++ b/sort_ym/fetch.py @@ -19,7 +19,7 @@ def _track_genre(track: Track) -> str | None: return None -def _serialize(track: Track) -> dict: +def serialize_track(track: Track) -> dict: # album_id, album_title и year берутся из одного и того же albums[0] - иначе название # альбома может разъехаться с его id (у трека может быть несколько альбомов). album = track.albums[0] if track.albums else None @@ -89,7 +89,7 @@ def fetch_liked_tracks( # альбомах, "основной" альбом в ответе может отличаться от того, под которым трек лайкнут. # Иначе такой трек тут же попадает под stale-очистку ниже как "больше не лайкнутый". for original_id, track in zip(batch, tracks): - cache[original_id] = _serialize(track) + cache[original_id] = serialize_track(track) _atomic_write_json(cache_file, cache) print(f" загружено {len(cache)}/{len(all_ids)}") time.sleep(batch_delay) @@ -111,6 +111,15 @@ def load_artist_genres(cache_dir: Path) -> dict[str, dict]: return {} +def unique_artist_ids(tracks: dict[str, dict]) -> list[str]: + """Уникальные id артистов, встречающихся в треках, отсортированные. + + aid может быть None - у части треков сторонней/пиратской загрузки артист в API Яндекса + не привязан к id (есть только имя). Такие пропускаем, их жанр возьмём только по треку. + """ + return sorted({str(aid) for t in tracks.values() for aid in t.get("artist_ids", []) if aid is not None}) + + def fetch_artist_genres( client: Client, cache_dir: Path, @@ -127,15 +136,8 @@ def fetch_artist_genres( cache = load_artist_genres(cache_dir) tracks_cache = load_tracks_cache(cache_dir) - # aid может быть None - у части треков сторонней/пиратской загрузки артист в API Яндекса - # не привязан к id (есть только имя). Такие пропускаем, их жанр возьмём только по треку. - all_artist_ids = { - str(aid) - for t in tracks_cache.values() - for aid in t.get("artist_ids", []) - if aid is not None - } - missing_ids = sorted(aid for aid in all_artist_ids if aid not in cache) + all_artist_ids = unique_artist_ids(tracks_cache) + missing_ids = [aid for aid in all_artist_ids if aid not in cache] if not missing_ids: return cache diff --git a/sort_ym/report.py b/sort_ym/report.py index 180e2e9..616c293 100644 --- a/sort_ym/report.py +++ b/sort_ym/report.py @@ -7,6 +7,9 @@ from . import classify, genres, language FIELDNAMES = ["title", "artists", "genre_raw", "fine_bucket", "bucket", "lang", "target_playlist", "id", "album_id"] +REPORT_FILE = "report.csv" +REPORT_SOURCE_FILE = "report_source.csv" + def build_rows( tracks_cache: dict[str, dict], @@ -59,9 +62,9 @@ def build_rows( return rows -def write_report(rows: list[dict], out_dir: Path) -> Path: +def write_report(rows: list[dict], out_dir: Path, filename: str = REPORT_FILE) -> Path: out_dir.mkdir(parents=True, exist_ok=True) - out_file = out_dir / "report.csv" + out_file = out_dir / filename with out_file.open("w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=FIELDNAMES) writer.writeheader() diff --git a/sort_ym/source.py b/sort_ym/source.py new file mode 100644 index 0000000..1a54ce9 --- /dev/null +++ b/sort_ym/source.py @@ -0,0 +1,200 @@ +"""Эфемерный источник треков: чужой (или свой) плейлист по ссылке. + +Ничего не пишет на диск - данные внешнего плейлиста живут только в памяти одного запуска +(см. 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/ + + +@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/ или /playlists/ -> 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) diff --git a/tests/test_apply.py b/tests/test_apply.py index dc8cd4c..ec1593d 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -1,6 +1,7 @@ from yandex_music.exceptions import BadRequestError, TimedOutError from sort_ym import apply as apply_mod +from sort_ym import classify class FakePlaylist: @@ -210,6 +211,22 @@ def test_dedupe_playlists_dry_run_does_not_delete(): assert len(client.playlists[100].tracks) == 3, "дубли не должны удаляться в dry-run режиме" +def test_with_label_appends_suffix(): + assert classify.with_label("Рок — RU", "Друг") == "Рок — RU (Друг)" + + +def test_apply_classification_uses_labeled_playlist_without_touching_shared_one(): + # --label в cli.py переписывает target_playlist до вызова apply_classification - + # сама функция про метки ничего не знает, просто группирует по строке target_playlist. + client = FakeClient() + rows = [{"target_playlist": classify.with_label("Рок — RU", "Друг"), "id": 5, "album_id": 50}] + + apply_mod.apply_classification(client, rows, delay=0) + + assert client.created == ["Рок — RU (Друг)"] + assert client.inserted == [(200, 5, 50, 1)] + + def test_dedupe_playlists_removes_duplicate_block(): client = FakeClient() client.playlists[100].tracks = [ diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..92c8a93 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,18 @@ +import pytest + +from sort_ym import cli + + +def test_apply_label_without_source_fails_before_any_network_access(): + with pytest.raises(SystemExit, match="--source"): + cli.main(["apply", "--yes", "--label", "Друг"]) + + +def test_apply_empty_label_fails_before_any_network_access(): + with pytest.raises(SystemExit, match="пустым"): + cli.main(["apply", "--yes", "--source", "https://music.yandex.ru/users/a/playlists/1", "--label", " "]) + + +def test_apply_without_yes_fails_regardless_of_source(): + with pytest.raises(SystemExit, match="--yes"): + cli.main(["apply"]) diff --git a/tests/test_digest.py b/tests/test_digest.py index b5b9587..edad7be 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -227,6 +227,30 @@ def test_artist_tags_truncated_to_limit(): assert digest.artist_tags("Artist X", ids_by_name, artist_genres, limit=3) == ["a", "b", "c"] +def test_default_wording_matches_likes_output(): + # Фиксирует путь без --source: render_digest(...) без явного wording не должен измениться + # при добавлении Wording/playlist_wording для --source. + tracks_cache = {"1:10": _track(1, "T1", ["Artist"], "punk")} + rows = _rows(tracks_cache) + + text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10) + + assert text.startswith("# Дайджест музыкальной библиотеки") + assert "Лайкнутых треков: 1" in text + + +def test_playlist_wording_does_not_mention_likes(): + tracks_cache = {"1:10": _track(1, "T1", ["Artist"], "punk")} + rows = _rows(tracks_cache) + wording = digest.playlist_wording("Мой плейлист", "friend", "https://music.yandex.ru/x") + + text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10, wording=wording) + + assert "Мой плейлист" in text + assert "friend" in text + assert "лайкнут" not in text.lower() + + def test_digest_line_count_stays_bounded_on_large_library(): tracks_cache = {} tid = 0 diff --git a/tests/test_report.py b/tests/test_report.py index 578e9d6..1d90449 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -47,6 +47,24 @@ def test_small_fine_group_collapses_to_coarse_bucket(): assert all(r["fine_bucket"] == "indie" for r in indie_rows), "fine_bucket сохраняет исходную классификацию" +def test_write_report_uses_default_filename(tmp_path): + rows = [{"title": "T", "artists": "A", "genre_raw": "punk", "fine_bucket": "punk", "bucket": "punk", "lang": "RU", "target_playlist": "Панк — RU", "id": 1, "album_id": 10}] + + out_file = report.write_report(rows, tmp_path) + + assert out_file.name == "report.csv" == report.REPORT_FILE + assert out_file.exists() + + +def test_write_report_accepts_custom_filename_for_source_runs(tmp_path): + rows = [{"title": "T", "artists": "A", "genre_raw": "punk", "fine_bucket": "punk", "bucket": "punk", "lang": "RU", "target_playlist": "Панк — RU", "id": 1, "album_id": 10}] + + out_file = report.write_report(rows, tmp_path, report.REPORT_SOURCE_FILE) + + assert out_file.name == "report_source.csv" == report.REPORT_SOURCE_FILE + assert out_file.exists() + + def test_collapse_is_single_pass_and_terminates(): # Группа ровно на границе порога (== small_group_min) НЕ схлопывается (строгое <). tracks_cache = { diff --git a/tests/test_source.py b/tests/test_source.py new file mode 100644 index 0000000..a0189dc --- /dev/null +++ b/tests/test_source.py @@ -0,0 +1,275 @@ +import pytest +from yandex_music.exceptions import NotFoundError, UnauthorizedError + +from sort_ym import digest, report, source + + +class FakeArtist: + def __init__(self, id_, name, genres=None): + self.id = id_ + self.name = name + self.genres = genres + + +class FakeAlbum: + def __init__(self, id_, genre=None, title="", year=None): + self.id = id_ + self.genre = genre + self.title = title + self.year = year + + +class FakeTrack: + def __init__(self, id_, albums, title="Title", artists=None, lyrics_available=False): + self.id = id_ + self.albums = albums + self.title = title + self.artists = artists or [FakeArtist(1, "Artist")] + self.lyrics_available = lyrics_available + + +class FakeTrackShort: + def __init__(self, id_, album_id=None): + self.id = id_ + self.album_id = album_id + + @property + def track_id(self): + return f"{self.id}:{self.album_id}" if self.album_id else f"{self.id}" + + +class FakeOwner: + def __init__(self, uid=1, login="owner", name=None): + self.uid = uid + self.login = login + self.name = name + + +class FakePlaylist: + def __init__(self, title="Playlist", tracks=None, track_count=None, owner=None): + self.title = title + self.tracks = tracks or [] + self.track_count = track_count if track_count is not None else len(self.tracks) + self.owner = owner if owner is not None else FakeOwner() + + +class FakeSourceClient: + def __init__(self, playlist=None, tracks_by_batch=None, artists_by_batch=None, raises=None): + self.playlist_obj = playlist + self._tracks_by_batch = tracks_by_batch or {} + self._artists_by_batch = artists_by_batch or {} + self._raises = raises + self.calls: list[dict] = [] + + def users_playlists(self, kind, user_id=None): + self.calls.append({"kind": kind, "user_id": user_id}) + if self._raises: + raise self._raises + return self.playlist_obj + + def playlist(self, uuid): + if self._raises: + raise self._raises + return self.playlist_obj + + def tracks(self, batch): + return self._tracks_by_batch[tuple(batch)] + + def artists(self, batch): + return self._artists_by_batch.get(tuple(sorted(batch)), []) + + +ACCEPTED_URLS = [ + ("https://music.yandex.ru/users/vasya/playlists/1001", "vasya", "1001"), + ("https://music.yandex.ru/users/vasya/playlists/1001/", "vasya", "1001"), + ("http://music.yandex.ru/users/vasya/playlists/1001", "vasya", "1001"), + ("music.yandex.ru/users/vasya/playlists/1001", "vasya", "1001"), + ("https://www.music.yandex.ru/users/vasya/playlists/1001", "vasya", "1001"), + ("https://MUSIC.YANDEX.RU/users/vasya/playlists/1001", "vasya", "1001"), + ("https://music.yandex.com/users/vasya/playlists/1001", "vasya", "1001"), + ("https://music.yandex.ru/users/vasya/playlists/1001?utm_source=share", "vasya", "1001"), + ("https://music.yandex.ru/users/vasya/playlists/1001#about", "vasya", "1001"), + ("https://music.yandex.ru/users/12345/playlists/1001", "12345", "1001"), + ("https://music.yandex.ru/users/user.name-1/playlists/1001", "user.name-1", "1001"), + ("https://music.yandex.ru/users/%D0%B2%D0%B0%D1%81%D1%8F/playlists/1001", "вася", "1001"), +] + + +@pytest.mark.parametrize("url,user_id,kind", ACCEPTED_URLS) +def test_parse_playlist_url_accepts_valid_links(url, user_id, kind): + ref = source.parse_playlist_url(url) + assert ref.user_id == user_id + assert ref.kind == kind + assert ref.uuid is None + + +def test_parse_playlist_url_accepts_uuid_form(): + ref = source.parse_playlist_url("https://music.yandex.ru/playlists/1e5a8f3c-1234-4abc-9def-0123456789ab") + assert ref.uuid == "1e5a8f3c-1234-4abc-9def-0123456789ab" + assert ref.user_id is None + assert ref.kind is None + + +REJECTED_URLS = [ + "", + " ", + "https://example.com/users/vasya/playlists/1001", + "https://music.yandex.ru/album/123", + "https://music.yandex.ru/artist/123", + "https://music.yandex.ru/users/vasya/playlists", + "https://music.yandex.ru/users/vasya/playlists/abc", + "https://music.yandex.ru/playlists/12345", +] + + +@pytest.mark.parametrize("url", REJECTED_URLS) +def test_parse_playlist_url_rejects_bad_links(url): + with pytest.raises(source.SourceError, match="music.yandex.ru/users/"): + source.parse_playlist_url(url) + + +def test_fetch_playlist_tracks_passes_foreign_user_id_through(): + short = FakeTrackShort(100, 10) + playlist = FakePlaylist(tracks=[short]) + track = FakeTrack(100, albums=[FakeAlbum(10, title="Album", year=2001)], artists=[FakeArtist(7, "Artist A")]) + client = FakeSourceClient(playlist=playlist, tracks_by_batch={("100:10",): [track]}) + ref = source.PlaylistRef(url="u", user_id="somebody", kind="12345") + + source.fetch_playlist_tracks(client, ref, batch_size=100, batch_delay=0) + + assert client.calls == [{"kind": "12345", "user_id": "somebody"}] + + +def test_fetch_playlist_tracks_dedupes_duplicate_track_ids(): + shorts = [FakeTrackShort(1, 10), FakeTrackShort(1, 10), FakeTrackShort(2, 20)] + playlist = FakePlaylist(tracks=shorts) + client = FakeSourceClient( + playlist=playlist, + tracks_by_batch={ + ("1:10",): [FakeTrack(1, albums=[FakeAlbum(10)])], + ("2:20",): [FakeTrack(2, albums=[FakeAlbum(20)])], + }, + ) + ref = source.PlaylistRef(url="u", user_id="x", kind="1") + + info, tracks = source.fetch_playlist_tracks(client, ref, batch_size=1, batch_delay=0) + + assert set(tracks.keys()) == {"1:10", "2:20"} + + +def test_fetch_playlist_tracks_matches_response_by_id_not_position(): + # Запрошены два трека, ответ содержит только один (второй недоступен в каталоге) - + # позиционный zip(batch, fetched) присвоил бы данные трека 2 ключу "1:10". + shorts = [FakeTrackShort(1, 10), FakeTrackShort(2, 20)] + playlist = FakePlaylist(tracks=shorts) + client = FakeSourceClient( + playlist=playlist, + tracks_by_batch={("1:10", "2:20"): [FakeTrack(2, albums=[FakeAlbum(20)])]}, + ) + ref = source.PlaylistRef(url="u", user_id="x", kind="1") + + info, tracks = source.fetch_playlist_tracks(client, ref, batch_size=100, batch_delay=0) + + assert set(tracks.keys()) == {"2:20"} + assert "1:10" not in tracks + + +def test_fetch_playlist_tracks_empty_with_zero_track_count_raises_playlist_empty(): + playlist = FakePlaylist(tracks=[], track_count=0) + client = FakeSourceClient(playlist=playlist) + ref = source.PlaylistRef(url="u", user_id="x", kind="1") + + with pytest.raises(source.SourceError, match="пуст"): + source.fetch_playlist_tracks(client, ref, batch_size=100, batch_delay=0) + + +def test_fetch_playlist_tracks_empty_with_nonzero_track_count_raises_transient_error(): + playlist = FakePlaylist(tracks=[], track_count=50) + client = FakeSourceClient(playlist=playlist) + ref = source.PlaylistRef(url="u", user_id="x", kind="1") + + with pytest.raises(source.SourceError, match="не вернул"): + source.fetch_playlist_tracks(client, ref, batch_size=100, batch_delay=0) + + +def test_fetch_playlist_tracks_empty_uuid_form_mentions_supported_link_shape(): + playlist = FakePlaylist(tracks=[], track_count=50) + client = FakeSourceClient(playlist=playlist) + ref = source.PlaylistRef(url="u", uuid="1e5a8f3c-1234-4abc-9def-0123456789ab") + + with pytest.raises(source.SourceError, match="music.yandex.ru/users/"): + source.fetch_playlist_tracks(client, ref, batch_size=100, batch_delay=0) + + +@pytest.mark.parametrize( + "exc,match", + [ + (NotFoundError("nope"), "не найден"), + (UnauthorizedError("nope"), "Нет доступа"), + ], +) +def test_load_playlist_maps_api_errors_to_source_error_without_retrying(exc, match): + client = FakeSourceClient(raises=exc) + ref = source.PlaylistRef(url="u", user_id="x", kind="1") + + with pytest.raises(source.SourceError, match=match): + source._load_playlist(client, ref) + + assert len(client.calls) == 1, "NotFoundError/UnauthorizedError не должны повторяться" + + +def test_load_playlist_none_response_raises_source_error(): + client = FakeSourceClient(playlist=None) + ref = source.PlaylistRef(url="u", user_id="x", kind="1") + + with pytest.raises(source.SourceError, match="пустой ответ"): + source._load_playlist(client, ref) + + +def test_fetch_artist_genres_map_batches_without_touching_disk(tmp_path): + client = FakeSourceClient( + artists_by_batch={("7", "9"): [FakeArtist(7, "A", genres=["indie"]), FakeArtist(9, "B", genres=["rock"])]} + ) + + result = source.fetch_artist_genres_map(client, ["7", "9"], batch_size=100, batch_delay=0) + + assert result == {"7": {"name": "A", "genres": ["indie"]}, "9": {"name": "B", "genres": ["rock"]}} + assert list(tmp_path.iterdir()) == [] # ничего не записано на диск + + +def test_source_track_shape_matches_fetch_cache_shape(tmp_path): + from sort_ym import fetch as fetch_mod + + expected_keys = { + "id", "album_id", "album_title", "year", "title", + "artists", "artist_ids", "genre_raw", "lyrics_available", + } + + class FakeLikes: + tracks_ids = ["100:10"] + + class FakeLikesClient: + def users_likes_tracks(self): + return FakeLikes() + + def tracks(self, batch): + return [FakeTrack(100, albums=[FakeAlbum(10, title="Album", year=2001)], artists=[FakeArtist(7, "Artist A")])] + + liked_cache = fetch_mod.fetch_liked_tracks(FakeLikesClient(), tmp_path, batch_size=100, batch_delay=0) + assert set(liked_cache["100:10"].keys()) == expected_keys + + short = FakeTrackShort(100, 10) + playlist = FakePlaylist(title="Playlist X", tracks=[short]) + track = FakeTrack(100, albums=[FakeAlbum(10, title="Album", year=2001)], artists=[FakeArtist(7, "Artist A")]) + client = FakeSourceClient(playlist=playlist, tracks_by_batch={("100:10",): [track]}) + ref = source.PlaylistRef(url="https://example", user_id="x", kind="1") + + info, tracks = source.fetch_playlist_tracks(client, ref, batch_size=100, batch_delay=0) + assert set(tracks["100:10"].keys()) == expected_keys + + rows = report.build_rows(tracks, {}, {}, {}, small_group_min=1) + assert rows and rows[0]["target_playlist"] + + wording = digest.playlist_wording(info.title, info.owner, info.url) + text = digest.render_digest(rows, tracks, {}, top_artists=5, top_albums=5, wording=wording) + assert "Playlist X" in text