Sort_YM/tests/test_source.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

298 lines
12 KiB
Python
Raw 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 pytest
from yandex_music.exceptions import NotFoundError, UnauthorizedError
from sort_ym import digest, report, source
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_, album_id=None, timestamp=None):
self.id = id_
self.album_id = album_id
self.timestamp = timestamp
@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
def test_parse_playlist_url_accepts_lk_prefixed_uuid_form():
# "lk." (личный кабинет) - реальный префикс у ссылок на персональные плейлисты вроде "Мне
# нравится" (подтверждено вживую через client.playlist()); префикс - часть самого uuid для
# API, а не что-то, что нужно отрезать при парсинге.
ref = source.parse_playlist_url(
"https://music.yandex.ru/playlists/lk.dff9b654-af53-4c65-ab4a-d4ed5a0ae72d?utm_source=desktop"
)
assert ref.uuid == "lk.dff9b654-af53-4c65-ab4a-d4ed5a0ae72d"
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"], "counts": None, "ratings": None},
"9": {"name": "B", "genres": ["rock"], "counts": None, "ratings": None},
}
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",
"added_at", "duration_ms", "track_version", "album_version", "release_date", "album_likes_count",
}
class FakeLikes:
tracks_ids = ["100:10"]
tracks = [FakeTrackShort(100, timestamp="2026-01-01T00:00:00+00:00")]
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