diff --git a/sort_ym/fetch.py b/sort_ym/fetch.py index 6546daa..96b64c4 100644 --- a/sort_ym/fetch.py +++ b/sort_ym/fetch.py @@ -65,8 +65,17 @@ def fetch_liked_tracks( for batch in chunked(missing_ids, batch_size): tracks = with_retries(lambda: client.tracks(batch)) - for track in tracks: - cache[track.track_id] = _serialize(track) + if len(tracks) != len(batch): + print( + f" предупреждение: запрошено {len(batch)} треков, получено {len(tracks)} - " + "часть могла быть удалена из каталога Яндекса, попробуем ещё раз при следующем fetch" + ) + # Кэшируем по исходному id из списка лайков (likes.tracks_ids), а не по track.track_id, + # пересчитанному из albums[0] вернувшегося Track - для трека, доступного на нескольких + # альбомах, "основной" альбом в ответе может отличаться от того, под которым трек лайкнут. + # Иначе такой трек тут же попадает под stale-очистку ниже как "больше не лайкнутый". + for original_id, track in zip(batch, tracks): + cache[original_id] = _serialize(track) _atomic_write_json(cache_file, cache) print(f" загружено {len(cache)}/{len(all_ids)}") time.sleep(batch_delay) diff --git a/tests/test_fetch.py b/tests/test_fetch.py new file mode 100644 index 0000000..01c15f9 --- /dev/null +++ b/tests/test_fetch.py @@ -0,0 +1,74 @@ +from pathlib import Path + +from sort_ym import fetch + + +class FakeArtist: + def __init__(self, name): + self.name = name + + +class FakeAlbum: + def __init__(self, id_, genre=None): + self.id = id_ + self.genre = genre + + +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("Artist")] + self.lyrics_available = lyrics_available + + +class FakeLikes: + def __init__(self, tracks_ids): + self.tracks_ids = tracks_ids + + +class FakeClient: + """Симулирует трек, у которого "основной" альбом в ответе tracks() + отличается от альбома, под которым трек значится в списке лайков - + это реальное поведение API Яндекса для треков на нескольких альбомах.""" + + def __init__(self, likes_ids, tracks_by_batch): + self._likes_ids = likes_ids + self._tracks_by_batch = tracks_by_batch + + def users_likes_tracks(self): + return FakeLikes(self._likes_ids) + + def tracks(self, batch): + return self._tracks_by_batch[tuple(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}"