From b991236ffa71057f11caa3267543dba9946e224a Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Tue, 11 Aug 2026 01:38:37 +0300 Subject: [PATCH] report: pick individual --extra column groups instead of all-or-nothing --extra now takes group names (timestamp/duration/version/album/artist/all) so you can add just what you need, e.g. --extra timestamp. Also split README command lists into core vs. extra sections for readability. --- ARCHITECTURE.md | 5 ++-- ARCHITECTURE.ru.md | 7 +++-- README.md | 33 ++++++++++++++++++---- README.ru.md | 33 ++++++++++++++++++---- sort_ym/cli.py | 16 ++++++----- sort_ym/report.py | 66 +++++++++++++++++++++++++++----------------- tests/test_report.py | 55 ++++++++++++++++++++++++++++++++---- 7 files changed, 162 insertions(+), 53 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2821a11..6b27b11 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -181,8 +181,9 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, - `out/report.csv` — a preview of the playlist breakdown before `apply` (including the `fine_bucket` column — the sub-genre before any collapsing into a top-level bucket). `report --order playlist` keeps source track order instead of sorting by target playlist; - `report --extra` adds the columns listed above (`added_at`, `duration_ms`, `release_date`, - `artist_rating_month/week/day`, etc. - full list in `report.EXTRA_FIELDNAMES`). + `report --extra ` adds the selected groups' columns (`timestamp`/`duration`/ + `version`/`album`/`artist`, or `all` for every group; `report.EXTRA_GROUPS` -> column list, + `report.resolve_extra_columns()` expands group names into a column list for the CLI). - `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 diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index 68b1726..44edd23 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -179,9 +179,10 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, - `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null). - `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку `fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `report --order playlist` - сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; `report --extra` - добавляет колонки из списка выше (`added_at`, `duration_ms`, `release_date`, - `artist_rating_month/week/day` и т.д. - полный список в `report.EXTRA_FIELDNAMES`). + сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; `report --extra + <группа...>` добавляет колонки выбранных групп (`timestamp`/`duration`/`version`/`album`/`artist`, + или `all` - сразу все; `report.EXTRA_GROUPS` -> список колонок, `report.resolve_extra_columns()` + разворачивает имена групп в список колонок для CLI). - `out/digest.md` — сжатая сводка библиотеки для вставки в чат с LLM (топ-исполнители, топ-альбомы, распределение по жанрам, языку и десятилетиям). - `out/report_source.csv`, `out/digest_source.md` — те же отчёты, но для запуска с `--source`; diff --git a/README.md b/README.md index ad01a8a..d221ff1 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,45 @@ For development (tests): ## What it does +### Core commands + ``` .venv\Scripts\python -m sort_ym auth # log in to a Yandex account via device-flow and save the token to .token .venv\Scripts\python -m sort_ym fetch # download liked tracks (cache/tracks.json) and their artists' genres (cache/artist_genres.json); does not change the account .venv\Scripts\python -m sort_ym report # compute genre and language per track, save out/report.csv (does not change the account) -.venv\Scripts\python -m sort_ym report --order playlist # skip playlist-based sorting - keep source track order (as in likes/the --source playlist) -.venv\Scripts\python -m sort_ym report --extra # + added_at/duration_ms/release_date/artist rating columns etc. .venv\Scripts\python -m sort_ym apply --limit 20 --yes # dry run: create playlists and add the first 20 tracks .venv\Scripts\python -m sort_ym apply --yes # create playlists and add all tracks (changes the account) .venv\Scripts\python -m sort_ym digest # library summary (top artists, genres) to out/digest.md - to paste into an LLM chat; no network used -.venv\Scripts\python -m sort_ym report --source # same, but for someone else's/another playlist by link -.venv\Scripts\python -m sort_ym digest --source # digest of that playlist -.venv\Scripts\python -m sort_ym apply --source --label Friend --yes # sort its tracks into your own "Genre — RU (Friend)" playlists ``` On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added. +### Extra: another playlist instead of your likes + +``` +.venv\Scripts\python -m sort_ym report --source # same, but for someone else's/another playlist by link +.venv\Scripts\python -m sort_ym digest --source # digest of that playlist +.venv\Scripts\python -m sort_ym apply --source --label Friend --yes # sort its tracks into your own "Genre — RU (Friend)" playlists +``` + +See "Analyzing and sorting another playlist" below for details. + +### Extra: fine-tuning report.csv + +By default `report` groups/sorts tracks by target playlist with a base set of columns. +These flags change that — from most to least commonly useful: + +``` +.venv\Scripts\python -m sort_ym report --order playlist # skip playlist-based sorting - keep source track order (as in likes/the --source playlist) +.venv\Scripts\python -m sort_ym report --extra timestamp # + when the track was added (added_at) +.venv\Scripts\python -m sort_ym report --extra album # + album release date and likes (release_date, album_likes_count) +.venv\Scripts\python -m sort_ym report --extra artist # + first artist's track counts/rating +.venv\Scripts\python -m sort_ym report --extra duration # + track length (duration_ms) +.venv\Scripts\python -m sort_ym report --extra version # + track/album version tags (remix, anniversary edition, etc.) +.venv\Scripts\python -m sort_ym report --extra timestamp album # groups can be combined, space-separated +.venv\Scripts\python -m sort_ym report --extra all --order playlist # every extra column + source track order +``` + ## Full cache cleanup To start over from scratch (re-fetch everything), delete the `cache/` folder: diff --git a/README.ru.md b/README.ru.md index 47b220a..e2039e1 100644 --- a/README.ru.md +++ b/README.ru.md @@ -33,22 +33,45 @@ python -m venv .venv ## Что делает +### Основные команды + ``` .venv\Scripts\python -m sort_ym auth # войти в аккаунт Яндекса через device-flow и сохранить токен в .token .venv\Scripts\python -m sort_ym fetch # скачать лайкнутые треки (cache/tracks.json) и жанры их артистов (cache/artist_genres.json); аккаунт не меняет .venv\Scripts\python -m sort_ym report # посчитать жанр и язык каждого трека, сохранить out/report.csv (аккаунт не меняет) -.venv\Scripts\python -m sort_ym report --order playlist # без сортировки по плейлистам - треки в исходном порядке (как в лайках/--source плейлисте) -.venv\Scripts\python -m sort_ym report --extra # + колонки added_at/duration_ms/release_date/рейтинг артиста и т.п. .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` не дублирует уже добавленные треки. +### Дополнительно: другой плейлист вместо лайков + +``` +.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 (Друг)" +``` + +Подробнее — раздел «Анализ и раскидка другого плейлиста» ниже. + +### Дополнительно: гибкая настройка report.csv + +По умолчанию `report` группирует и сортирует треки по целевому плейлисту с базовым набором колонок. +Эти флаги это меняют — от самого востребованного к более редкому: + +``` +.venv\Scripts\python -m sort_ym report --order playlist # без сортировки по плейлистам - треки в исходном порядке источника (как в лайках/--source плейлисте) +.venv\Scripts\python -m sort_ym report --extra timestamp # + дата добавления трека (added_at) +.venv\Scripts\python -m sort_ym report --extra album # + дата релиза и лайки альбома (release_date, album_likes_count) +.venv\Scripts\python -m sort_ym report --extra artist # + счётчики/рейтинг первого артиста трека +.venv\Scripts\python -m sort_ym report --extra duration # + длительность трека (duration_ms) +.venv\Scripts\python -m sort_ym report --extra version # + пометки версии трека/альбома (ремикс, юбилейное издание и т.п.) +.venv\Scripts\python -m sort_ym report --extra timestamp album # можно комбинировать несколько групп через пробел +.venv\Scripts\python -m sort_ym report --extra all --order playlist # все доп. колонки сразу + порядок как в источнике +``` + ## Полная очистка кэша Если требуется начать с чистого листа (переполучить все данные с нуля), удалите папку `cache/`: diff --git a/sort_ym/cli.py b/sort_ym/cli.py index 61adb1c..a970e05 100644 --- a/sort_ym/cli.py +++ b/sort_ym/cli.py @@ -79,14 +79,15 @@ def _print_report_summary(rows: list[dict], out_file) -> None: def cmd_report(args: argparse.Namespace) -> None: cfg = load_config() + extra_columns = report.resolve_extra_columns(args.extra) if args.extra else None 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, - order=args.order, extra_fields=args.extra, + order=args.order, extra_columns=extra_columns, ) - out_file = report.write_report(rows, cfg.out_dir, report.REPORT_SOURCE_FILE, extra_fields=args.extra) + out_file = report.write_report(rows, cfg.out_dir, report.REPORT_SOURCE_FILE, extra_columns=extra_columns) _print_report_summary(rows, out_file) return @@ -104,9 +105,9 @@ def cmd_report(args: argparse.Namespace) -> None: rows = report.build_rows( tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min, - order=args.order, extra_fields=args.extra, + order=args.order, extra_columns=extra_columns, ) - out_file = report.write_report(rows, cfg.out_dir, extra_fields=args.extra) + out_file = report.write_report(rows, cfg.out_dir, extra_columns=extra_columns) _print_report_summary(rows, out_file) @@ -207,9 +208,10 @@ def main(argv: list[str] | None = None) -> None: "playlist - как треки идут в источнике (в лайках или в --source плейлисте), без сортировки", ) p_report.add_argument( - "--extra", action="store_true", - help="добавить в CSV колонки с доп. данными API: дата добавления, длительность, дата релиза " - "альбома, рейтинг/счётчики первого артиста трека", + "--extra", nargs="+", metavar="GROUP", choices=[*report.EXTRA_GROUPS, "all"], default=None, + help="добавить в CSV колонки указанных групп (можно несколько через пробел), или 'all' - сразу все: " + "timestamp (added_at), duration (duration_ms), version (track_version/album_version), " + "album (release_date/album_likes_count), artist (счётчики и рейтинг первого артиста трека)", ) p_apply = sub.add_parser("apply", help="создать плейлисты и добавить треки") diff --git a/sort_ym/report.py b/sort_ym/report.py index 4850e7d..704b76c 100644 --- a/sort_ym/report.py +++ b/sort_ym/report.py @@ -7,37 +7,52 @@ from . import classify, genres, language FIELDNAMES = ["title", "artists", "genre_raw", "fine_bucket", "bucket", "lang", "target_playlist", "id", "album_id"] -# Только вместе с --extra: сырые поля API, которых нет в базовом отчёте (см. обсуждение в чате - -# added_at из TrackShort.timestamp, остальное - из Track/Album/Artist, но не заполнено стабильно -# у part данных вроде explicit/lyrics_info, поэтому в набор не включено). -EXTRA_FIELDNAMES = [ - "added_at", - "duration_ms", - "track_version", - "album_version", - "release_date", - "album_likes_count", - "artist_track_count", - "artist_direct_albums", - "artist_also_albums", - "artist_also_tracks", - "artist_rating_month", - "artist_rating_week", - "artist_rating_day", -] +# Сырые поля API, которых нет в базовом отчёте - подключаются группами через --extra +# (см. обсуждение в чате: added_at из TrackShort.timestamp, остальное - из Track/Album/Artist). +# Порядок в EXTRA_FIELDNAMES - канонический порядок колонок в CSV независимо от порядка групп в --extra. +EXTRA_GROUPS: dict[str, list[str]] = { + "timestamp": ["added_at"], + "duration": ["duration_ms"], + "version": ["track_version", "album_version"], + "album": ["release_date", "album_likes_count"], + "artist": [ + "artist_track_count", + "artist_direct_albums", + "artist_also_albums", + "artist_also_tracks", + "artist_rating_month", + "artist_rating_week", + "artist_rating_day", + ], +} +EXTRA_FIELDNAMES = [col for cols in EXTRA_GROUPS.values() for col in cols] + + +def resolve_extra_columns(group_names: list[str]) -> list[str]: + """--extra timestamp album -> ["added_at", "release_date", "album_likes_count"] (канонический порядок). + + "all" - алиас на все группы сразу. + """ + names = list(EXTRA_GROUPS) if "all" in group_names else group_names + unknown = set(names) - set(EXTRA_GROUPS) + if unknown: + raise ValueError(f"неизвестные группы --extra: {sorted(unknown)}, доступны: {list(EXTRA_GROUPS)} или 'all'") + selected = {col for name in names for col in EXTRA_GROUPS[name]} + return [col for col in EXTRA_FIELDNAMES if col in selected] + REPORT_FILE = "report.csv" REPORT_SOURCE_FILE = "report_source.csv" -def _extra_row_fields(t: dict, artist_genres: dict[str, dict]) -> dict: +def _extra_row_fields(t: dict, artist_genres: dict[str, dict], columns: list[str]) -> dict: # Счётчики/рейтинг берём у первого артиста трека - у треков с несколькими артистами # остальные не имеют выделенной колонки (см. обсуждение в чате). artist_ids = t.get("artist_ids", []) artist = artist_genres.get(str(artist_ids[0])) if artist_ids else None counts = (artist or {}).get("counts") or {} ratings = (artist or {}).get("ratings") or {} - return { + all_values = { "added_at": t.get("added_at"), "duration_ms": t.get("duration_ms"), "track_version": t.get("track_version") or "", @@ -52,6 +67,7 @@ def _extra_row_fields(t: dict, artist_genres: dict[str, dict]) -> dict: "artist_rating_week": ratings.get("week"), "artist_rating_day": ratings.get("day"), } + return {col: all_values[col] for col in columns} def build_rows( @@ -61,7 +77,7 @@ def build_rows( artist_genres: dict[str, dict], small_group_min: int, order: str = "grouped", - extra_fields: bool = False, + extra_columns: list[str] | None = None, ) -> list[dict]: # Проход 1: под-жанр (fine) и язык на каждый трек, размеры групп (fine, lang) фиксируем сразу. prelim = [] @@ -101,8 +117,8 @@ def build_rows( "id": t["id"], "album_id": t["album_id"], } - if extra_fields: - row.update(_extra_row_fields(t, artist_genres)) + if extra_columns: + row.update(_extra_row_fields(t, artist_genres, extra_columns)) rows.append(row) # "playlist" - порядок как в источнике (tracks_cache сохраняет порядок плейлиста, см. source.py); @@ -114,10 +130,10 @@ def build_rows( return rows -def write_report(rows: list[dict], out_dir: Path, filename: str = REPORT_FILE, extra_fields: bool = False) -> Path: +def write_report(rows: list[dict], out_dir: Path, filename: str = REPORT_FILE, extra_columns: list[str] | None = None) -> Path: out_dir.mkdir(parents=True, exist_ok=True) out_file = out_dir / filename - fieldnames = FIELDNAMES + EXTRA_FIELDNAMES if extra_fields else FIELDNAMES + fieldnames = FIELDNAMES + extra_columns if extra_columns else FIELDNAMES with out_file.open("w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() diff --git a/tests/test_report.py b/tests/test_report.py index ab616f9..62b6b43 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -1,3 +1,5 @@ +import pytest + from sort_ym import report CATALOG: dict = {} # classify_track с пустыми artist_genre_lists использует только genre_raw @@ -105,7 +107,20 @@ def test_order_grouped_sorts_by_target_playlist(): assert rows == sorted(rows, key=lambda r: (r["target_playlist"], r["artists"], r["title"])) -def test_extra_fields_adds_columns_from_track_and_artist_cache(): +def test_resolve_extra_columns_expands_single_group(): + assert report.resolve_extra_columns(["timestamp"]) == ["added_at"] + + +def test_resolve_extra_columns_all_expands_every_group_in_canonical_order(): + assert report.resolve_extra_columns(["all"]) == report.EXTRA_FIELDNAMES + + +def test_resolve_extra_columns_rejects_unknown_group(): + with pytest.raises(ValueError, match="bogus"): + report.resolve_extra_columns(["bogus"]) + + +def test_extra_columns_only_adds_requested_group(): tracks_cache = { "1:1": { **_track(1, "T", ["A"], "punk", artist_ids=[42]), @@ -125,7 +140,35 @@ def test_extra_fields_adds_columns_from_track_and_artist_cache(): genre_catalog=CATALOG, artist_genres=artist_genres, small_group_min=12, - extra_fields=True, + extra_columns=report.resolve_extra_columns(["timestamp"]), + ) + + row = rows[0] + assert row["added_at"] == "2026-01-01T00:00:00+00:00" + assert "duration_ms" not in row, "запрошена только группа timestamp - duration не должно быть в строке" + + +def test_extra_columns_all_adds_every_group(): + tracks_cache = { + "1:1": { + **_track(1, "T", ["A"], "punk", artist_ids=[42]), + "added_at": "2026-01-01T00:00:00+00:00", + "duration_ms": 123000, + "track_version": "Remix", + "album_version": None, + "release_date": "2020-05-01T00:00:00+03:00", + "album_likes_count": 10, + }, + } + artist_genres = {"42": {"name": "A", "genres": [], "counts": {"tracks": 5}, "ratings": {"month": 7}}} + + rows = report.build_rows( + tracks_cache=tracks_cache, + lang_cache={}, + genre_catalog=CATALOG, + artist_genres=artist_genres, + small_group_min=12, + extra_columns=report.resolve_extra_columns(["all"]), ) row = rows[0] @@ -137,14 +180,14 @@ def test_extra_fields_adds_columns_from_track_and_artist_cache(): assert row["artist_rating_month"] == 7 -def test_write_report_extra_fields_writes_extended_header(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, "added_at": "x", "duration_ms": 1, "track_version": "", "album_version": "", "release_date": "y", "album_likes_count": 1, "artist_track_count": 1, "artist_direct_albums": 1, "artist_also_albums": 1, "artist_also_tracks": 1, "artist_rating_month": 1, "artist_rating_week": 1, "artist_rating_day": 1}] +def test_write_report_extra_columns_writes_only_selected_header(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, "added_at": "x"}] - out_file = report.write_report(rows, tmp_path, extra_fields=True) + out_file = report.write_report(rows, tmp_path, extra_columns=report.resolve_extra_columns(["timestamp"])) header = out_file.read_text(encoding="utf-8-sig").splitlines()[0] assert "added_at" in header - assert "artist_rating_day" in header + assert "artist_rating_day" not in header def test_collapse_is_single_pass_and_terminates():