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.
This commit is contained in:
Andrew Pimenov 2026-08-06 19:15:57 +03:00
parent 187c30b4ab
commit ff452b0162
15 changed files with 932 additions and 69 deletions

View file

@ -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 <url> → 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/<login>/playlists/<kind>` (plus a best-effort `/playlists/<uuid>`
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.

View file

@ -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 <url> → 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/<kind>` (плюс best-effort форма
`/playlists/<uuid>` через `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`;
один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам.

View file

@ -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 <url> # same, but for someone else's/another playlist by link
.venv\Scripts\python -m sort_ym digest --source <url> # digest of that playlist
.venv\Scripts\python -m sort_ym apply --source <url> --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/<login>/playlists/<number>`.
```
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 <name>`** (`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).

View file

@ -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).

View file

@ -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})"

View file

@ -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,6 +148,12 @@ def cmd_apply(args: argparse.Namespace) -> None:
def cmd_digest(args: argparse.Namespace) -> None:
cfg = load_config()
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")
@ -82,12 +164,14 @@ def cmd_digest(args: argparse.Namespace) -> None:
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
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,
}
try:
commands[args.command](args)
except UnauthorizedError:
raise SystemExit("Яндекс отклонил запрос - токен мог истечь. Выполните: python -m sort_ym auth")
if __name__ == "__main__":

View file

@ -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

View file

@ -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

View file

@ -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()

200
sort_ym/source.py Normal file
View file

@ -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/<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)

View file

@ -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 = [

18
tests/test_cli.py Normal file
View file

@ -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"])

View file

@ -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

View file

@ -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 = {

275
tests/test_source.py Normal file
View file

@ -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