Sort_YM/tests/test_fetch.py
Andrew Pimenov 5d4cab5d68 report: --order/--extra flags, added_at and artist stats in track cache
CSV can now keep source playlist order instead of forced playlist grouping,
and optionally include added_at/duration_ms/release_date/artist rating from
the API that were previously fetched but discarded.
2026-08-11 01:25:51 +03:00

332 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from pathlib import Path
from sort_ym import fetch
class FakeArtist:
def __init__(self, id_, name, genres=None, counts=None, ratings=None):
self.id = id_
self.name = name
self.genres = genres
self.counts = counts
self.ratings = ratings
class FakeAlbum:
def __init__(self, id_, genre=None, title="", year=None, version=None, release_date=None, likes_count=None):
self.id = id_
self.genre = genre
self.title = title
self.year = year
self.version = version
self.release_date = release_date
self.likes_count = likes_count
class FakeTrack:
def __init__(self, id_, albums, title="Title", artists=None, lyrics_available=False, duration_ms=None, version=None):
self.id = id_
self.albums = albums
self.title = title
self.artists = artists or [FakeArtist(1, "Artist")]
self.lyrics_available = lyrics_available
self.duration_ms = duration_ms
self.version = version
class FakeTrackShort:
def __init__(self, id_, timestamp=None):
self.id = id_
self.timestamp = timestamp
class FakeLikes:
def __init__(self, tracks_ids, tracks=None):
self.tracks_ids = tracks_ids
# По умолчанию строим TrackShort-заглушки из tracks_ids ("100:10" -> id=100) -
# реальный API всегда возвращает .tracks вместе с .tracks_ids, тесты не завязанные
# на timestamp могут его не указывать явно.
self.tracks = tracks if tracks is not None else [FakeTrackShort(tid.split(":")[0]) for tid in tracks_ids]
class FakeClient:
"""Симулирует трек, у которого "основной" альбом в ответе tracks()
отличается от альбома, под которым трек значится в списке лайков -
это реальное поведение API Яндекса для треков на нескольких альбомах."""
def __init__(self, likes_ids, tracks_by_batch, artists_by_batch=None):
self._likes_ids = likes_ids
self._tracks_by_batch = tracks_by_batch
self._artists_by_batch = artists_by_batch or {}
def users_likes_tracks(self):
return FakeLikes(self._likes_ids)
def tracks(self, batch):
return self._tracks_by_batch[tuple(batch)]
def artists(self, batch):
return self._artists_by_batch[tuple(sorted(batch))]
def test_fetch_caches_under_original_liked_id_not_recomputed_track_id(tmp_path: Path):
# Трек лайкнут как "100:10", но Track.albums[0].id из ответа API - 999 (другой альбом).
track = FakeTrack(id_=100, albums=[FakeAlbum(999, genre="pop")])
client = FakeClient(
likes_ids=["100:10"],
tracks_by_batch={("100:10",): [track]},
)
cache = fetch.fetch_liked_tracks(client, tmp_path, batch_size=100, batch_delay=0)
assert "100:10" in cache, "трек должен кэшироваться под id из списка лайков"
assert "100:999" not in cache, "не должен пересчитывать ключ из albums[0] вернувшегося Track"
assert cache["100:10"]["id"] == 100
assert cache["100:10"]["genre_raw"] == "pop"
def test_fetch_does_not_purge_track_with_mismatched_album_as_stale(tmp_path: Path):
# Регрессия: раньше такой трек кэшировался под "неправильным" ключом track.track_id,
# который не совпадал с likes.tracks_ids, и тут же удалялся как "больше не лайкнутый".
track = FakeTrack(id_=100, albums=[FakeAlbum(999)])
client = FakeClient(
likes_ids=["100:10"],
tracks_by_batch={("100:10",): [track]},
)
cache = fetch.fetch_liked_tracks(client, tmp_path, batch_size=100, batch_delay=0)
assert len(cache) == 1, f"трек не должен быть вычищен как stale сразу после загрузки: {cache}"
def test_fetch_stores_artist_ids(tmp_path: Path):
track = FakeTrack(id_=100, albums=[FakeAlbum(10)], artists=[FakeArtist(7, "Artist A")])
client = FakeClient(likes_ids=["100:10"], tracks_by_batch={("100:10",): [track]})
cache = fetch.fetch_liked_tracks(client, tmp_path, batch_size=100, batch_delay=0)
assert cache["100:10"]["artist_ids"] == [7]
def test_fetch_skips_artist_with_no_id(tmp_path: Path):
# Регрессия: некоторые треки (сторонняя/пиратская загрузка) имеют артиста с именем,
# но без id в каталоге Яндекса (Artist.id is None). Раньше None попадал в artist_ids,
# затем str(None) = "None" улетал в client.artists() и API отвечал BadRequestError.
track = FakeTrack(id_=100, albums=[FakeAlbum(10)], artists=[FakeArtist(None, "Unlinked Artist")])
client = FakeClient(likes_ids=["100:10"], tracks_by_batch={("100:10",): [track]})
cache = fetch.fetch_liked_tracks(client, tmp_path, batch_size=100, batch_delay=0)
assert cache["100:10"]["artist_ids"] == [], "артист без id не должен попадать в artist_ids"
def test_fetch_artist_genres_ignores_none_ids_in_old_cache(tmp_path: Path):
# Защита на уровне fetch_artist_genres даже если в tracks.json уже есть None
# (например, кэш был написан до фикса выше).
cache_dir = tmp_path
tracks_cache = {
"100:10": {"id": 100, "album_id": 10, "title": "T1", "artists": ["Unlinked"], "artist_ids": [None], "genre_raw": "pop", "lyrics_available": False},
}
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
json.dumps(tracks_cache, ensure_ascii=False), encoding="utf-8"
)
def boom(batch):
raise AssertionError(f"не должен запрашивать None как id артиста: {batch}")
client = FakeClient(likes_ids=[], tracks_by_batch={})
client.artists = boom
result = fetch.fetch_artist_genres(client, cache_dir, batch_size=100, batch_delay=0)
assert result == {}
def test_fetch_re_downloads_old_cache_entries_missing_artist_ids(tmp_path: Path):
# Старый формат кэша (до добавления artist_ids) должен быть докачан повторно.
cache_dir = tmp_path
cache_dir.mkdir(exist_ok=True)
old_entry = {
"id": 100,
"album_id": 10,
"title": "Old",
"artists": ["Artist A"],
"genre_raw": "pop",
"lyrics_available": False,
}
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
json.dumps({"100:10": old_entry}, ensure_ascii=False), encoding="utf-8"
)
track = FakeTrack(id_=100, albums=[FakeAlbum(10)], artists=[FakeArtist(7, "Artist A")])
client = FakeClient(likes_ids=["100:10"], tracks_by_batch={("100:10",): [track]})
cache = fetch.fetch_liked_tracks(client, cache_dir, batch_size=100, batch_delay=0)
assert "artist_ids" in cache["100:10"], "запись без artist_ids должна быть докачана заново"
assert cache["100:10"]["artist_ids"] == [7]
def test_fetch_re_downloads_old_cache_entries_missing_year(tmp_path: Path):
# Старый формат кэша (до добавления album_title/year, но уже с artist_ids) должен
# быть докачан повторно.
cache_dir = tmp_path
cache_dir.mkdir(exist_ok=True)
old_entry = {
"id": 100,
"album_id": 10,
"title": "Old",
"artists": ["Artist A"],
"artist_ids": [7],
"genre_raw": "pop",
"lyrics_available": False,
}
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
json.dumps({"100:10": old_entry}, ensure_ascii=False), encoding="utf-8"
)
track = FakeTrack(
id_=100,
albums=[FakeAlbum(10, title="Iowa", year=2001)],
artists=[FakeArtist(7, "Artist A")],
)
client = FakeClient(likes_ids=["100:10"], tracks_by_batch={("100:10",): [track]})
cache = fetch.fetch_liked_tracks(client, cache_dir, batch_size=100, batch_delay=0)
assert cache["100:10"]["year"] == 2001
assert cache["100:10"]["album_title"] == "Iowa"
def test_fetch_re_downloads_old_cache_entries_missing_duration_ms(tmp_path: Path):
# Кэш до добавления added_at/duration_ms и т.п. (но уже с artist_ids/year) должен
# быть докачан повторно.
cache_dir = tmp_path
cache_dir.mkdir(exist_ok=True)
old_entry = {
"id": 100,
"album_id": 10,
"album_title": "Iowa",
"year": 2001,
"title": "Old",
"artists": ["Artist A"],
"artist_ids": [7],
"genre_raw": "pop",
"lyrics_available": False,
}
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
json.dumps({"100:10": old_entry}, ensure_ascii=False), encoding="utf-8"
)
track = FakeTrack(id_=100, albums=[FakeAlbum(10, title="Iowa", year=2001)], artists=[FakeArtist(7, "Artist A")], duration_ms=250000)
client = FakeClient(likes_ids=["100:10"], tracks_by_batch={("100:10",): [track]})
cache = fetch.fetch_liked_tracks(client, cache_dir, batch_size=100, batch_delay=0)
assert cache["100:10"]["duration_ms"] == 250000
def test_fetch_does_not_redownload_entries_with_legitimately_unknown_year(tmp_path: Path):
# Регрессия: year=None легитимен (у альбома действительно нет года), и такая запись
# не должна перекачиваться при каждом fetch - проверяем наличие ключа, а не значение.
cache_dir = tmp_path
cache_dir.mkdir(exist_ok=True)
entry = {
"id": 100,
"album_id": 10,
"album_title": "Unknown Year Album",
"year": None,
"title": "T1",
"artists": ["Artist A"],
"artist_ids": [7],
"genre_raw": "pop",
"lyrics_available": False,
"duration_ms": 200000,
}
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
json.dumps({"100:10": entry}, ensure_ascii=False), encoding="utf-8"
)
def boom(batch):
raise AssertionError(f"не должен перекачивать трек с легитимно неизвестным годом: {batch}")
client = FakeClient(likes_ids=["100:10"], tracks_by_batch={})
client.tracks = boom
cache = fetch.fetch_liked_tracks(client, cache_dir, batch_size=100, batch_delay=0)
assert cache["100:10"]["year"] is None
def test_fetch_artist_genres_batches_and_caches(tmp_path: Path):
cache_dir = tmp_path
tracks_cache = {
"100:10": {"id": 100, "album_id": 10, "title": "T1", "artists": ["A"], "artist_ids": [7], "genre_raw": "pop", "lyrics_available": False},
"200:20": {"id": 200, "album_id": 20, "title": "T2", "artists": ["B"], "artist_ids": [9], "genre_raw": "rock", "lyrics_available": False},
}
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
json.dumps(tracks_cache, ensure_ascii=False), encoding="utf-8"
)
client = FakeClient(
likes_ids=[],
tracks_by_batch={},
artists_by_batch={("7", "9"): [FakeArtist(7, "Artist A", genres=["indie"]), FakeArtist(9, "Artist B", genres=["rock", "punk"])]},
)
result = fetch.fetch_artist_genres(client, cache_dir, batch_size=100, batch_delay=0)
assert result["7"] == {"name": "Artist A", "genres": ["indie"], "counts": None, "ratings": None}
assert result["9"] == {"name": "Artist B", "genres": ["rock", "punk"], "counts": None, "ratings": None}
persisted = fetch.load_artist_genres(cache_dir)
assert persisted == result
def test_fetch_artist_genres_skips_already_cached(tmp_path: Path):
cache_dir = tmp_path
tracks_cache = {
"100:10": {"id": 100, "album_id": 10, "title": "T1", "artists": ["A"], "artist_ids": [7], "genre_raw": "pop", "lyrics_available": False},
}
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
json.dumps(tracks_cache, ensure_ascii=False), encoding="utf-8"
)
(cache_dir / fetch.ARTIST_GENRES_CACHE_FILE).write_text(
json.dumps({"7": {"name": "Artist A", "genres": ["indie"], "counts": None, "ratings": None}}, ensure_ascii=False),
encoding="utf-8",
)
def boom(batch):
raise AssertionError("не должен запрашивать уже закэшированного артиста")
client = FakeClient(likes_ids=[], tracks_by_batch={})
client.artists = boom
result = fetch.fetch_artist_genres(client, cache_dir, batch_size=100, batch_delay=0)
assert result == {"7": {"name": "Artist A", "genres": ["indie"], "counts": None, "ratings": None}}
def test_fetch_artist_genres_re_downloads_old_cache_entries_missing_counts(tmp_path: Path):
# Кэш до добавления counts/ratings (но уже с name/genres) должен быть докачан повторно.
cache_dir = tmp_path
tracks_cache = {
"100:10": {"id": 100, "album_id": 10, "title": "T1", "artists": ["A"], "artist_ids": [7], "genre_raw": "pop", "lyrics_available": False},
}
(cache_dir / fetch.TRACKS_CACHE_FILE).write_text(
json.dumps(tracks_cache, ensure_ascii=False), encoding="utf-8"
)
(cache_dir / fetch.ARTIST_GENRES_CACHE_FILE).write_text(
json.dumps({"7": {"name": "Artist A", "genres": ["indie"]}}, ensure_ascii=False), encoding="utf-8"
)
client = FakeClient(
likes_ids=[],
tracks_by_batch={},
artists_by_batch={("7",): [FakeArtist(7, "Artist A", genres=["indie"])]},
)
result = fetch.fetch_artist_genres(client, cache_dir, batch_size=100, batch_delay=0)
assert result["7"] == {"name": "Artist A", "genres": ["indie"], "counts": None, "ratings": None}