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.
This commit is contained in:
Andrew Pimenov 2026-08-11 01:38:37 +03:00
parent 5d4cab5d68
commit b991236ffa
7 changed files with 162 additions and 53 deletions

View file

@ -181,8 +181,9 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
- `out/report.csv` — a preview of the playlist breakdown before `apply` (including the - `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). `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 --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`, `report --extra <group...>` adds the selected groups' columns (`timestamp`/`duration`/
`artist_rating_month/week/day`, etc. - full list in `report.EXTRA_FIELDNAMES`). `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 - `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 - `out/report_source.csv`, `out/digest_source.md` — the same reports for a `--source` run; a

View file

@ -179,9 +179,10 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null). - `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null).
- `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку - `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `report --order playlist` `fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `report --order playlist`
сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; `report --extra` сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; `report --extra
добавляет колонки из списка выше (`added_at`, `duration_ms`, `release_date`, <группа...>` добавляет колонки выбранных групп (`timestamp`/`duration`/`version`/`album`/`artist`,
`artist_rating_month/week/day` и т.д. - полный список в `report.EXTRA_FIELDNAMES`). или `all` - сразу все; `report.EXTRA_GROUPS` -> список колонок, `report.resolve_extra_columns()`
разворачивает имена групп в список колонок для CLI).
- `out/digest.md` — сжатая сводка библиотеки для вставки в чат с LLM (топ-исполнители, топ-альбомы, - `out/digest.md` — сжатая сводка библиотеки для вставки в чат с LLM (топ-исполнители, топ-альбомы,
распределение по жанрам, языку и десятилетиям). распределение по жанрам, языку и десятилетиям).
- `out/report_source.csv`, `out/digest_source.md` — те же отчёты, но для запуска с `--source`; - `out/report_source.csv`, `out/digest_source.md` — те же отчёты, но для запуска с `--source`;

View file

@ -33,22 +33,45 @@ For development (tests):
## What it does ## 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 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 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 # 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 --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.
### Extra: another playlist instead of your likes
```
.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
```
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 ## Full cache cleanup
To start over from scratch (re-fetch everything), delete the `cache/` folder: To start over from scratch (re-fetch everything), delete the `cache/` folder:

View file

@ -33,22 +33,45 @@ python -m venv .venv
## Что делает ## Что делает
### Основные команды
``` ```
.venv\Scripts\python -m sort_ym auth # войти в аккаунт Яндекса через device-flow и сохранить токен в .token .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 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 # посчитать жанр и язык каждого трека, сохранить 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 --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` не дублирует уже добавленные треки.
### Дополнительно: другой плейлист вместо лайков
```
.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/`: Если требуется начать с чистого листа (переполучить все данные с нуля), удалите папку `cache/`:

View file

@ -79,14 +79,15 @@ def _print_report_summary(rows: list[dict], out_file) -> None:
def cmd_report(args: argparse.Namespace) -> None: def cmd_report(args: argparse.Namespace) -> None:
cfg = load_config() cfg = load_config()
extra_columns = report.resolve_extra_columns(args.extra) if args.extra else None
if args.source: if args.source:
ctx = _source_context(cfg, args.source) ctx = _source_context(cfg, args.source)
rows = report.build_rows( rows = report.build_rows(
ctx.tracks, ctx.lang_cache, ctx.catalog, ctx.artist_genres, cfg.small_group_min, 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) _print_report_summary(rows, out_file)
return return
@ -104,9 +105,9 @@ def cmd_report(args: argparse.Namespace) -> None:
rows = report.build_rows( rows = report.build_rows(
tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min, 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) _print_report_summary(rows, out_file)
@ -207,9 +208,10 @@ def main(argv: list[str] | None = None) -> None:
"playlist - как треки идут в источнике (в лайках или в --source плейлисте), без сортировки", "playlist - как треки идут в источнике (в лайках или в --source плейлисте), без сортировки",
) )
p_report.add_argument( p_report.add_argument(
"--extra", action="store_true", "--extra", nargs="+", metavar="GROUP", choices=[*report.EXTRA_GROUPS, "all"], default=None,
help="добавить в CSV колонки с доп. данными API: дата добавления, длительность, дата релиза " 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="создать плейлисты и добавить треки") p_apply = sub.add_parser("apply", help="создать плейлисты и добавить треки")

View file

@ -7,37 +7,52 @@ 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"]
# Только вместе с --extra: сырые поля API, которых нет в базовом отчёте (см. обсуждение в чате - # Сырые поля API, которых нет в базовом отчёте - подключаются группами через --extra
# added_at из TrackShort.timestamp, остальное - из Track/Album/Artist, но не заполнено стабильно # (см. обсуждение в чате: added_at из TrackShort.timestamp, остальное - из Track/Album/Artist).
# у part данных вроде explicit/lyrics_info, поэтому в набор не включено). # Порядок в EXTRA_FIELDNAMES - канонический порядок колонок в CSV независимо от порядка групп в --extra.
EXTRA_FIELDNAMES = [ EXTRA_GROUPS: dict[str, list[str]] = {
"added_at", "timestamp": ["added_at"],
"duration_ms", "duration": ["duration_ms"],
"track_version", "version": ["track_version", "album_version"],
"album_version", "album": ["release_date", "album_likes_count"],
"release_date", "artist": [
"album_likes_count", "artist_track_count",
"artist_track_count", "artist_direct_albums",
"artist_direct_albums", "artist_also_albums",
"artist_also_albums", "artist_also_tracks",
"artist_also_tracks", "artist_rating_month",
"artist_rating_month", "artist_rating_week",
"artist_rating_week", "artist_rating_day",
"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_FILE = "report.csv"
REPORT_SOURCE_FILE = "report_source.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_ids = t.get("artist_ids", [])
artist = artist_genres.get(str(artist_ids[0])) if artist_ids else None artist = artist_genres.get(str(artist_ids[0])) if artist_ids else None
counts = (artist or {}).get("counts") or {} counts = (artist or {}).get("counts") or {}
ratings = (artist or {}).get("ratings") or {} ratings = (artist or {}).get("ratings") or {}
return { all_values = {
"added_at": t.get("added_at"), "added_at": t.get("added_at"),
"duration_ms": t.get("duration_ms"), "duration_ms": t.get("duration_ms"),
"track_version": t.get("track_version") or "", "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_week": ratings.get("week"),
"artist_rating_day": ratings.get("day"), "artist_rating_day": ratings.get("day"),
} }
return {col: all_values[col] for col in columns}
def build_rows( def build_rows(
@ -61,7 +77,7 @@ def build_rows(
artist_genres: dict[str, dict], artist_genres: dict[str, dict],
small_group_min: int, small_group_min: int,
order: str = "grouped", order: str = "grouped",
extra_fields: bool = False, extra_columns: list[str] | None = None,
) -> list[dict]: ) -> list[dict]:
# Проход 1: под-жанр (fine) и язык на каждый трек, размеры групп (fine, lang) фиксируем сразу. # Проход 1: под-жанр (fine) и язык на каждый трек, размеры групп (fine, lang) фиксируем сразу.
prelim = [] prelim = []
@ -101,8 +117,8 @@ def build_rows(
"id": t["id"], "id": t["id"],
"album_id": t["album_id"], "album_id": t["album_id"],
} }
if extra_fields: if extra_columns:
row.update(_extra_row_fields(t, artist_genres)) row.update(_extra_row_fields(t, artist_genres, extra_columns))
rows.append(row) rows.append(row)
# "playlist" - порядок как в источнике (tracks_cache сохраняет порядок плейлиста, см. source.py); # "playlist" - порядок как в источнике (tracks_cache сохраняет порядок плейлиста, см. source.py);
@ -114,10 +130,10 @@ def build_rows(
return 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_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / filename 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: 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()

View file

@ -1,3 +1,5 @@
import pytest
from sort_ym import report from sort_ym import report
CATALOG: dict = {} # classify_track с пустыми artist_genre_lists использует только genre_raw 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"])) 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 = { tracks_cache = {
"1:1": { "1:1": {
**_track(1, "T", ["A"], "punk", artist_ids=[42]), **_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, genre_catalog=CATALOG,
artist_genres=artist_genres, artist_genres=artist_genres,
small_group_min=12, 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] row = rows[0]
@ -137,14 +180,14 @@ def test_extra_fields_adds_columns_from_track_and_artist_cache():
assert row["artist_rating_month"] == 7 assert row["artist_rating_month"] == 7
def test_write_report_extra_fields_writes_extended_header(tmp_path): 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", "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}] 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] header = out_file.read_text(encoding="utf-8-sig").splitlines()[0]
assert "added_at" in header 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(): def test_collapse_is_single_pass_and_terminates():