mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-11 01:02:02 +03:00
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:
parent
187c30b4ab
commit
ff452b0162
15 changed files with 932 additions and 69 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
один перезаписываемый слот "последнего внешнего источника", без истории по чужим плейлистам.
|
||||
|
|
|
|||
44
README.md
44
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 <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).
|
||||
|
|
|
|||
44
README.ru.md
44
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).
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
|
|
|
|||
126
sort_ym/cli.py
126
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,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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
200
sort_ym/source.py
Normal 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)
|
||||
|
|
@ -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
18
tests/test_cli.py
Normal 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"])
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
275
tests/test_source.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue