mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 18:02:00 +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)
|
cache/token paths)
|
||||||
auth.py — OAuth device-flow, token storage in .token
|
auth.py — OAuth device-flow, token storage in .token
|
||||||
ymclient.py — Client construction, batching lists, retrying network errors
|
ymclient.py — Client construction, batching lists, retrying network errors
|
||||||
fetch.py — likes -> full Track -> cache/tracks.json; track artists ->
|
fetch.py — likes -> full Track -> cache/tracks.json (serialize_track); track artists ->
|
||||||
client.artists() -> cache/artist_genres.json (both fetched incrementally)
|
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.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
|
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)
|
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
|
report.py — two-pass report row building, writes out/report.csv, summaries
|
||||||
apply.py — playlist creation + idempotent track insertion
|
apply.py — playlist creation + idempotent track insertion
|
||||||
digest.py — aggregated library summary (top artists, genres, decades, top albums)
|
digest.py — aggregated library summary (top artists, genres, decades, top albums)
|
||||||
-> out/digest.md, fully offline
|
-> out/digest.md, fully offline without --source; Wording/playlist_wording —
|
||||||
cli.py — auth / fetch / report / apply / dedupe / digest commands
|
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/
|
tests/
|
||||||
test_genres.py — classify_track, dominant, bucket_for, genre tree
|
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_report.py — two-pass collapsing of small sub-genre groups
|
||||||
test_fetch.py, test_apply.py — track/artist cache, apply idempotency
|
test_fetch.py, test_apply.py — track/artist cache, apply idempotency
|
||||||
test_digest.py — artist/album aggregation, long-tail collapsing, digest size bounds
|
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)
|
cache/ — API cache (in .gitignore)
|
||||||
out/ — reports (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
|
apply --yes → users_playlists_create / users_playlists_insert_track
|
||||||
digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network)
|
digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network)
|
||||||
→ build_rows() → out/digest.md
|
→ 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
|
## 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
|
(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
|
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
|
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
|
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
|
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
|
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.
|
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`.
|
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
|
## Storage
|
||||||
|
|
||||||
- `.token` — the account's OAuth token (not committed).
|
- `.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).
|
`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
|
- `out/digest.md` — a compact library summary for pasting into an LLM chat (top artists, top
|
||||||
albums, genre/language/decade breakdowns).
|
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
|
auth.py — OAuth device-flow, хранение токена в .token
|
||||||
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок
|
ymclient.py — создание Client, батч-нарезка списков, ретраи сетевых ошибок
|
||||||
fetch.py — лайки -> полные Track -> cache/tracks.json; артисты треков ->
|
fetch.py — лайки -> полные Track -> cache/tracks.json (serialize_track); артисты треков ->
|
||||||
client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально)
|
client.artists() -> cache/artist_genres.json (оба докачиваются инкрементально);
|
||||||
|
unique_artist_ids — уникальные id артистов из кэша треков
|
||||||
|
source.py — эфемерный источник: разбор ссылки на произвольный плейлист + загрузка его
|
||||||
|
треков/артистов в память, без записи на диск (см. "--source" ниже)
|
||||||
genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров
|
genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров
|
||||||
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
|
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
|
||||||
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
|
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит)
|
||||||
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста
|
classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки
|
||||||
|
источника для apply --source --label
|
||||||
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
|
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
|
||||||
apply.py — создание плейлистов + идемпотентное добавление треков
|
apply.py — создание плейлистов + идемпотентное добавление треков
|
||||||
digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия,
|
digest.py — агрегированная сводка библиотеки (топ-исполнители, жанры, десятилетия,
|
||||||
топ-альбомы) -> out/digest.md, полностью офлайн
|
топ-альбомы) -> out/digest.md, полностью офлайн без --source; Wording/
|
||||||
cli.py — команды auth / fetch / report / apply / dedupe / digest
|
playlist_wording — тексты дайджеста для лайков vs произвольного плейлиста
|
||||||
|
cli.py — команды auth / fetch / report / apply / dedupe / digest; --source/--label
|
||||||
|
на report/apply/digest (см. "Ключевые решения")
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
test_genres.py — classify_track, dominant, bucket_for, дерево жанров
|
test_genres.py — classify_track, dominant, bucket_for, дерево жанров
|
||||||
|
|
@ -32,6 +38,9 @@ tests/
|
||||||
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
|
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
|
||||||
test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста,
|
test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста,
|
||||||
границы размера дайджеста
|
границы размера дайджеста
|
||||||
|
test_source.py — разбор ссылки на плейлист, сопоставление ответа client.tracks() по id,
|
||||||
|
обработка приватных/недоступных плейлистов
|
||||||
|
test_cli.py — валидация флагов --source/--label до обращения к сети
|
||||||
|
|
||||||
cache/ — кэш API (в .gitignore)
|
cache/ — кэш API (в .gitignore)
|
||||||
out/ — отчёты (в .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
|
apply --yes → users_playlists_create / users_playlists_insert_track
|
||||||
digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется)
|
digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется)
|
||||||
→ build_rows() → out/digest.md
|
→ 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`, топ-исполнители и
|
10000 (жанровые секции ограничены размером `ROOT_BUCKET`/`BUCKET_LABELS`, топ-исполнители и
|
||||||
топ-альбомы — конфигом `[digest]`, а не всей библиотекой; хвост за пределами топа схлопывается в
|
топ-альбомы — конфигом `[digest]`, а не всей библиотекой; хвост за пределами топа схлопывается в
|
||||||
одну фразу с суммарными числами). Исполнители считаются **по имени**, а не через
|
одну фразу с суммарными числами). Исполнители считаются **по имени**, а не через
|
||||||
`zip(artists, artist_ids)` — `fetch._serialize` кладёт в `artists` всех артистов с именем, а в
|
`zip(artists, artist_ids)` — `fetch.serialize_track` кладёт в `artists` всех артистов с именем, а в
|
||||||
`artist_ids` только тех, у кого есть и имя, и id, поэтому списки могут расходиться по длине для
|
`artist_ids` только тех, у кого есть и имя, и id, поэтому списки могут расходиться по длине для
|
||||||
одного трека. По той же причине собственные жанровые теги артиста (`artist_tags`) сопоставляются
|
одного трека. По той же причине собственные жанровые теги артиста (`artist_tags`) сопоставляются
|
||||||
только по трекам, где длины списков совпадают — иначе можно присвоить артисту чужие теги.
|
только по трекам, где длины списков совпадают — иначе можно присвоить артисту чужие теги.
|
||||||
|
|
@ -123,6 +140,30 @@ digest → genres.load_catalog() + language.load_lang_cache() (только кэ
|
||||||
значения, иначе треки с легитимно неизвестным годом перекачивались бы при каждом `fetch`.
|
значения, иначе треки с легитимно неизвестным годом перекачивались бы при каждом `fetch`.
|
||||||
Команда полностью офлайн — `Client` не создаётся вообще, в отличие от `report`/`apply`.
|
Команда полностью офлайн — `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-токен аккаунта (не коммитится).
|
- `.token` — OAuth-токен аккаунта (не коммитится).
|
||||||
|
|
@ -137,3 +178,5 @@ digest → genres.load_catalog() + language.load_lang_cache() (только кэ
|
||||||
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину).
|
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину).
|
||||||
- `out/digest.md` — сжатая сводка библиотеки для вставки в чат с LLM (топ-исполнители, топ-альбомы,
|
- `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 --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 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 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.
|
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
|
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.
|
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
|
Without `--source` the command is fully offline: no network calls, no `.token` needed, only the
|
||||||
downloaded `cache/`. `report` must have run at least once first (for `cache/genre_catalog.json`).
|
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
|
$ 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
|
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).
|
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
|
## Handy things
|
||||||
|
|
||||||
- Run `apply` first with `--limit 20 --yes` and check the result in the app before running it
|
- 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 report
|
||||||
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
|
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
|
||||||
.venv\Scripts\python -m sort_ym digest
|
.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).
|
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 --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков
|
||||||
.venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт)
|
.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 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` не дублирует уже добавленные треки.
|
При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки.
|
||||||
|
|
@ -114,8 +117,10 @@ Remove-Item .token
|
||||||
суммарными числами. Итоговый файл — не больше пары сотен строк независимо от того, 500 треков в
|
суммарными числами. Итоговый файл — не больше пары сотен строк независимо от того, 500 треков в
|
||||||
библиотеке или 10000.
|
библиотеке или 10000.
|
||||||
|
|
||||||
Команда полностью офлайн: она не обращается к сети и не требует `.token`, читает только уже
|
Без `--source` команда полностью офлайн: не обращается к сети, не требует `.token`, читает только
|
||||||
загруженный `cache/`. Нужно, чтобы `report` был запущен хотя бы раз (для `cache/genre_catalog.json`).
|
уже загруженный `cache/` (нужно, чтобы `report` был запущен хотя бы раз, для
|
||||||
|
`cache/genre_catalog.json`). С `--source` (см. раздел «Анализ и раскидка другого плейлиста» ниже)
|
||||||
|
нужны и сеть, и токен, но `fetch` заранее не требуется.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ python -m sort_ym digest
|
$ python -m sort_ym digest
|
||||||
|
|
@ -156,6 +161,40 @@ $ python -m sort_ym digest
|
||||||
покажут «Неизвестно» до первого перезапуска `python -m sort_ym fetch` (он один раз доперекачает
|
покажут «Неизвестно» до первого перезапуска `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
|
## Handy things
|
||||||
|
|
||||||
- Первый прогон `apply` делайте с `--limit 20 --yes` и проверьте результат в приложении, прежде
|
- Первый прогон `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 report
|
||||||
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
|
.venv\Scripts\python -m sort_ym apply --limit 20 --yes
|
||||||
.venv\Scripts\python -m sort_ym digest
|
.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).
|
Структура кода и поток данных — в [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"])
|
bucket_label = _ALL_LABELS.get(bucket, BUCKET_LABELS["other"])
|
||||||
lang_label = LANG_LABELS.get(lang, LANG_LABELS["UNKNOWN"])
|
lang_label = LANG_LABELS.get(lang, LANG_LABELS["UNKNOWN"])
|
||||||
return f"{bucket_label} — {lang_label}"
|
return f"{bucket_label} — {lang_label}"
|
||||||
|
|
||||||
|
|
||||||
|
def with_label(name: str, label: str) -> str:
|
||||||
|
"""Добавляет метку источника к имени целевого плейлиста: "Рок — RU" -> "Рок — RU (Метка)"."""
|
||||||
|
return f"{name} ({label})"
|
||||||
|
|
|
||||||
144
sort_ym/cli.py
144
sort_ym/cli.py
|
|
@ -1,13 +1,56 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
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 apply as apply_mod
|
||||||
from . import auth, classify, digest, fetch, genres, language, report
|
from . import auth, classify, digest, fetch, genres, language, report, source
|
||||||
from .config import load_config
|
from .config import Config, load_config
|
||||||
from .ymclient import make_client
|
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:
|
def cmd_auth(args: argparse.Namespace) -> None:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
auth.device_login(cfg.token_file)
|
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)
|
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:
|
def cmd_report(args: argparse.Namespace) -> None:
|
||||||
cfg = load_config()
|
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)
|
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
|
||||||
|
|
||||||
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
|
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)
|
rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min)
|
||||||
out_file = report.write_report(rows, cfg.out_dir)
|
out_file = report.write_report(rows, cfg.out_dir)
|
||||||
|
_print_report_summary(rows, out_file)
|
||||||
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_apply(args: argparse.Namespace) -> None:
|
def cmd_apply(args: argparse.Namespace) -> None:
|
||||||
if not args.yes:
|
if not args.yes:
|
||||||
raise SystemExit("Это изменит ваш аккаунт. Добавьте флаг --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()
|
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)
|
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
|
||||||
|
|
||||||
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
|
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
|
||||||
|
|
@ -72,22 +148,30 @@ def cmd_apply(args: argparse.Namespace) -> None:
|
||||||
def cmd_digest(args: argparse.Namespace) -> None:
|
def cmd_digest(args: argparse.Namespace) -> None:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|
||||||
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
|
if args.source:
|
||||||
if not tracks_cache:
|
ctx = _source_context(cfg, args.source)
|
||||||
raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch")
|
tracks_cache, artist_genres, catalog, lang_cache = ctx.tracks, ctx.artist_genres, ctx.catalog, ctx.lang_cache
|
||||||
|
wording = digest.playlist_wording(ctx.info.title, ctx.info.owner, ctx.info.url)
|
||||||
|
out_name = digest.DIGEST_SOURCE_FILE
|
||||||
|
else:
|
||||||
|
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
|
||||||
|
if not tracks_cache:
|
||||||
|
raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch")
|
||||||
|
|
||||||
catalog = genres.load_catalog(cfg.cache_dir)
|
catalog = genres.load_catalog(cfg.cache_dir)
|
||||||
if not catalog:
|
if not catalog:
|
||||||
raise SystemExit("Нет снимка дерева жанров. Сначала запустите: python -m sort_ym report")
|
raise SystemExit("Нет снимка дерева жанров. Сначала запустите: python -m sort_ym report")
|
||||||
|
|
||||||
|
artist_genres = fetch.load_artist_genres(cfg.cache_dir)
|
||||||
|
lang_cache = language.load_lang_cache(cfg.cache_dir) # только кэш, без сети
|
||||||
|
wording, out_name = digest.DEFAULT_WORDING, digest.DIGEST_FILE
|
||||||
|
|
||||||
artist_genres = fetch.load_artist_genres(cfg.cache_dir)
|
|
||||||
lang_cache = language.load_lang_cache(cfg.cache_dir) # только кэш, без сети
|
|
||||||
rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min)
|
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 = 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
|
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)
|
text = digest.render_digest(rows, tracks_cache, artist_genres, top, top_albums, wording=wording)
|
||||||
out_file = digest.write_digest(text, cfg.out_dir)
|
out_file = digest.write_digest(text, cfg.out_dir, out_name)
|
||||||
|
|
||||||
print("\nЖанры:")
|
print("\nЖанры:")
|
||||||
for g in digest.genre_stats(rows):
|
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("auth", help="войти через device-flow и сохранить токен")
|
||||||
sub.add_parser("fetch", help="загрузить лайкнутые треки в локальный кэш")
|
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 = sub.add_parser("apply", help="создать плейлисты и добавить треки")
|
||||||
p_apply.add_argument("--yes", action="store_true", help="подтвердить внесение изменений в аккаунт")
|
p_apply.add_argument("--yes", action="store_true", help="подтвердить внесение изменений в аккаунт")
|
||||||
p_apply.add_argument("--limit", type=int, default=None, 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 = sub.add_parser("dedupe", help="найти и убрать повторные вставки одного трека в плейлистах")
|
||||||
p_dedupe.add_argument("--yes", action="store_true", 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", type=int, default=None, help="сколько исполнителей показать поимённо (по умолчанию из config.toml)")
|
||||||
p_digest.add_argument("--top-albums", 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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
@ -134,7 +223,10 @@ def main(argv: list[str] | None = None) -> None:
|
||||||
"dedupe": cmd_dedupe,
|
"dedupe": cmd_dedupe,
|
||||||
"digest": cmd_digest,
|
"digest": cmd_digest,
|
||||||
}
|
}
|
||||||
commands[args.command](args)
|
try:
|
||||||
|
commands[args.command](args)
|
||||||
|
except UnauthorizedError:
|
||||||
|
raise SystemExit("Яндекс отклонил запрос - токен мог истечь. Выполните: python -m sort_ym auth")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
import statistics
|
import statistics
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import classify, genres, report
|
from . import classify, genres, report
|
||||||
|
|
||||||
DIGEST_FILE = "digest.md"
|
DIGEST_FILE = "digest.md"
|
||||||
|
DIGEST_SOURCE_FILE = "digest_source.md"
|
||||||
|
|
||||||
DECADE_UNKNOWN = "unknown"
|
DECADE_UNKNOWN = "unknown"
|
||||||
DECADE_MIN_YEAR = 1900
|
DECADE_MIN_YEAR = 1900
|
||||||
|
|
@ -14,6 +16,43 @@ DECADE_MAX_YEAR = 2100
|
||||||
DECADE_TOP_GENRES = 3 # константа вёрстки (сколько жанров показывать на десятилетие), не config-knob
|
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]:
|
def _by_track_key(tracks_cache: dict[str, dict]) -> dict[tuple, dict]:
|
||||||
"""(id, album_id) из ЗНАЧЕНИЙ кэша -> сама запись кэша.
|
"""(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]]:
|
def _artist_ids_by_name(tracks_cache: dict[str, dict]) -> dict[str, list[str]]:
|
||||||
"""Имя артиста -> id (в порядке первого появления, без дублей).
|
"""Имя артиста -> id (в порядке первого появления, без дублей).
|
||||||
|
|
||||||
fetch._serialize кладёт в artists всех артистов с именем, а в artist_ids - только тех, у кого
|
fetch.serialize_track кладёт в artists всех артистов с именем, а в artist_ids - только тех, у кого
|
||||||
есть и имя, и id, поэтому списки могут расходиться по длине для одного трека. Берём только
|
есть и имя, и id, поэтому списки могут расходиться по длине для одного трека. Берём только
|
||||||
треки, где длины совпадают - иначе zip присвоил бы имени чужой id. Имя, встречающееся только
|
треки, где длины совпадают - иначе zip присвоил бы имени чужой id. Имя, встречающееся только
|
||||||
в рассинхронных треках, остаётся без id (и без тегов) - лучше пусто, чем неверно.
|
в рассинхронных треках, остаётся без id (и без тегов) - лучше пусто, чем неверно.
|
||||||
|
|
@ -224,6 +263,7 @@ def render_digest(
|
||||||
artist_genres: dict[str, dict],
|
artist_genres: dict[str, dict],
|
||||||
top_artists: int,
|
top_artists: int,
|
||||||
top_albums: int,
|
top_albums: int,
|
||||||
|
wording: Wording = DEFAULT_WORDING,
|
||||||
) -> str:
|
) -> str:
|
||||||
total = len(rows)
|
total = len(rows)
|
||||||
unique_artists = {name for t in tracks_cache.values() for name in t["artists"]}
|
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)
|
known_years = sorted(t["year"] for t in tracks_cache.values() if t.get("year") is not None)
|
||||||
|
|
||||||
lines: list[str] = ["# Дайджест музыкальной библиотеки", "", "## Итоги"]
|
lines: list[str] = [f"# {wording.heading}", ""]
|
||||||
lines.append(f"- Лайкнутых треков: {total}")
|
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(unique_artists)}")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"- Задействовано крупных корзин: {len(genre_rows)} из {len(classify.BUCKET_LABELS)}, "
|
f"- Задействовано крупных корзин: {len(genre_rows)} из {len(classify.BUCKET_LABELS)}, "
|
||||||
|
|
@ -251,11 +295,7 @@ def render_digest(
|
||||||
f"({_pct(len(known_years), total)}%)"
|
f"({_pct(len(known_years), total)}%)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lines.append(
|
lines.append(wording.no_years_note)
|
||||||
"> Годы релиза и названия альбомов ещё не загружены - запустите `python -m sort_ym "
|
|
||||||
"fetch` (кэш треков обновится один раз). Секции «Десятилетия» и «Топ альбомов» "
|
|
||||||
"пока неинформативны."
|
|
||||||
)
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
lines.append("## Жанры (крупные корзины)")
|
lines.append("## Жанры (крупные корзины)")
|
||||||
|
|
@ -314,7 +354,7 @@ def render_digest(
|
||||||
shown_albums = albums[:top_albums]
|
shown_albums = albums[:top_albums]
|
||||||
rest_albums = albums[top_albums:]
|
rest_albums = albums[top_albums:]
|
||||||
lines.append(f"## Топ-{len(shown_albums)} альбомов")
|
lines.append(f"## Топ-{len(shown_albums)} альбомов")
|
||||||
lines.append("Счётчик — сколько треков с альбома лайкнуто.")
|
lines.append(wording.album_counter_note)
|
||||||
for i, alb in enumerate(shown_albums, 1):
|
for i, alb in enumerate(shown_albums, 1):
|
||||||
year_part = f" ({alb['year']})" if alb["year"] else ""
|
year_part = f" ({alb['year']})" if alb["year"] else ""
|
||||||
lines.append(f"{i}. {alb['artists']} — {alb['title']}{year_part} — {alb['tracks']}")
|
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)
|
deep_tracks = sum(a["tracks"] for a in albums if a["tracks"] >= 2)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"Ещё {len(rest_albums)} альбомов с {rest_albums[-1]['tracks']}-"
|
wording.rest_albums_note.format(
|
||||||
f"{rest_albums[0]['tracks']} лайкнутыми треками. Треков с альбомов, где лайкнуто "
|
n=len(rest_albums), lo=rest_albums[-1]["tracks"], hi=rest_albums[0]["tracks"]
|
||||||
f"2 и более: {deep_tracks} из {total} ({_pct(deep_tracks, total)}%)."
|
)
|
||||||
|
+ " "
|
||||||
|
+ wording.deep_albums_note.format(deep=deep_tracks, total=total, pct=_pct(deep_tracks, total))
|
||||||
)
|
)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
|
@ -337,8 +379,8 @@ def render_digest(
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
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_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")
|
out_file.write_text(text, encoding="utf-8")
|
||||||
return out_file
|
return out_file
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ def _track_genre(track: Track) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _serialize(track: Track) -> dict:
|
def serialize_track(track: Track) -> dict:
|
||||||
# album_id, album_title и year берутся из одного и того же albums[0] - иначе название
|
# album_id, album_title и year берутся из одного и того же albums[0] - иначе название
|
||||||
# альбома может разъехаться с его id (у трека может быть несколько альбомов).
|
# альбома может разъехаться с его id (у трека может быть несколько альбомов).
|
||||||
album = track.albums[0] if track.albums else None
|
album = track.albums[0] if track.albums else None
|
||||||
|
|
@ -89,7 +89,7 @@ def fetch_liked_tracks(
|
||||||
# альбомах, "основной" альбом в ответе может отличаться от того, под которым трек лайкнут.
|
# альбомах, "основной" альбом в ответе может отличаться от того, под которым трек лайкнут.
|
||||||
# Иначе такой трек тут же попадает под stale-очистку ниже как "больше не лайкнутый".
|
# Иначе такой трек тут же попадает под stale-очистку ниже как "больше не лайкнутый".
|
||||||
for original_id, track in zip(batch, tracks):
|
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)
|
_atomic_write_json(cache_file, cache)
|
||||||
print(f" загружено {len(cache)}/{len(all_ids)}")
|
print(f" загружено {len(cache)}/{len(all_ids)}")
|
||||||
time.sleep(batch_delay)
|
time.sleep(batch_delay)
|
||||||
|
|
@ -111,6 +111,15 @@ def load_artist_genres(cache_dir: Path) -> dict[str, dict]:
|
||||||
return {}
|
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(
|
def fetch_artist_genres(
|
||||||
client: Client,
|
client: Client,
|
||||||
cache_dir: Path,
|
cache_dir: Path,
|
||||||
|
|
@ -127,15 +136,8 @@ def fetch_artist_genres(
|
||||||
cache = load_artist_genres(cache_dir)
|
cache = load_artist_genres(cache_dir)
|
||||||
|
|
||||||
tracks_cache = load_tracks_cache(cache_dir)
|
tracks_cache = load_tracks_cache(cache_dir)
|
||||||
# aid может быть None - у части треков сторонней/пиратской загрузки артист в API Яндекса
|
all_artist_ids = unique_artist_ids(tracks_cache)
|
||||||
# не привязан к id (есть только имя). Такие пропускаем, их жанр возьмём только по треку.
|
missing_ids = [aid for aid in all_artist_ids if aid not in cache]
|
||||||
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)
|
|
||||||
|
|
||||||
if not missing_ids:
|
if not missing_ids:
|
||||||
return cache
|
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"]
|
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(
|
def build_rows(
|
||||||
tracks_cache: dict[str, dict],
|
tracks_cache: dict[str, dict],
|
||||||
|
|
@ -59,9 +62,9 @@ def build_rows(
|
||||||
return 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_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:
|
with out_file.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
|
writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
|
||||||
writer.writeheader()
|
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 yandex_music.exceptions import BadRequestError, TimedOutError
|
||||||
|
|
||||||
from sort_ym import apply as apply_mod
|
from sort_ym import apply as apply_mod
|
||||||
|
from sort_ym import classify
|
||||||
|
|
||||||
|
|
||||||
class FakePlaylist:
|
class FakePlaylist:
|
||||||
|
|
@ -210,6 +211,22 @@ def test_dedupe_playlists_dry_run_does_not_delete():
|
||||||
assert len(client.playlists[100].tracks) == 3, "дубли не должны удаляться в dry-run режиме"
|
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():
|
def test_dedupe_playlists_removes_duplicate_block():
|
||||||
client = FakeClient()
|
client = FakeClient()
|
||||||
client.playlists[100].tracks = [
|
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"]
|
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():
|
def test_digest_line_count_stays_bounded_on_large_library():
|
||||||
tracks_cache = {}
|
tracks_cache = {}
|
||||||
tid = 0
|
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 сохраняет исходную классификацию"
|
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():
|
def test_collapse_is_single_pass_and_terminates():
|
||||||
# Группа ровно на границе порога (== small_group_min) НЕ схлопывается (строгое <).
|
# Группа ровно на границе порога (== small_group_min) НЕ схлопывается (строгое <).
|
||||||
tracks_cache = {
|
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