mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1034 lines
37 KiB
1034 lines
37 KiB
"""Unit tests for core/amazon_client.py.
|
|
|
|
All network I/O is mocked via a fake session — no real T2Tunes instance needed.
|
|
|
|
Run from project root:
|
|
python -m pytest tests/tools/test_amazon_client.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# Make sure project root is importable when running tests directly.
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from core.amazon_client import (
|
|
Album,
|
|
AmazonClient,
|
|
AmazonClientError,
|
|
Artist,
|
|
T2TunesSearchItem,
|
|
T2TunesStreamInfo,
|
|
Track,
|
|
_rate_limit,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
TRACK_DOC = {
|
|
"asin": "B09XYZ1234",
|
|
"title": "Not Like Us",
|
|
"artistName": "Kendrick Lamar",
|
|
"__type": "track",
|
|
"albumName": "GNX",
|
|
"albumAsin": "B0ABCDE123",
|
|
"duration": 217,
|
|
"isrc": "USRC12345678",
|
|
}
|
|
|
|
ALBUM_DOC = {
|
|
"asin": "B0ABCDE123",
|
|
"albumAsin": "B0ABCDE123",
|
|
"title": "GNX",
|
|
"albumName": "GNX",
|
|
"artistName": "Kendrick Lamar",
|
|
"__type": "album",
|
|
"duration": 0,
|
|
}
|
|
|
|
SEARCH_RESPONSE_TRACKS = {
|
|
"results": [
|
|
{
|
|
"hits": [
|
|
{"document": TRACK_DOC},
|
|
{
|
|
"document": {
|
|
"asin": "B09XYZ5678",
|
|
"title": "euphoria",
|
|
"artistName": "Kendrick Lamar",
|
|
"__type": "track",
|
|
"albumName": "euphoria",
|
|
"albumAsin": "B0ABCDE456",
|
|
"duration": 480,
|
|
"isrc": "USRC87654321",
|
|
}
|
|
},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
SEARCH_RESPONSE_ALBUMS = {
|
|
"results": [{"hits": [{"document": ALBUM_DOC}]}]
|
|
}
|
|
|
|
SEARCH_RESPONSE_MIXED = {
|
|
"results": [
|
|
{
|
|
"hits": [
|
|
{"document": TRACK_DOC},
|
|
{"document": ALBUM_DOC},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
ALBUM_METADATA_RESPONSE = {
|
|
"albumList": [
|
|
{
|
|
"asin": "B0ABCDE123",
|
|
"title": "GNX",
|
|
"image": "https://example.com/cover.jpg",
|
|
"trackCount": 12,
|
|
"label": "pgLang/Interscope",
|
|
"artistName": "Kendrick Lamar",
|
|
}
|
|
]
|
|
}
|
|
|
|
MEDIA_RESPONSE_FLAC = {
|
|
"asin": "B09XYZ1234",
|
|
"streamable": True,
|
|
"decryptionKey": None,
|
|
"streamInfo": {
|
|
"codec": "FLAC",
|
|
"format": "FLAC",
|
|
"sampleRate": 44100,
|
|
"streamUrl": "https://cdn.example.com/track.flac",
|
|
},
|
|
"tags": {
|
|
"title": "Not Like Us",
|
|
"artist": "Kendrick Lamar",
|
|
"album": "GNX",
|
|
"isrc": "USRC12345678",
|
|
"trackNumber": "3",
|
|
"discNumber": "1",
|
|
"date": "2024-11-22",
|
|
},
|
|
}
|
|
|
|
MEDIA_RESPONSE_HIRES = {
|
|
"asin": "B09XYZ1234",
|
|
"streamable": True,
|
|
"decryptionKey": "somekey",
|
|
"streamInfo": {
|
|
"codec": "FLAC",
|
|
"format": "FLAC",
|
|
"sampleRate": 96000,
|
|
"streamUrl": "https://cdn.example.com/track-hires.flac",
|
|
},
|
|
"tags": {
|
|
"title": "Not Like Us",
|
|
"artist": "Kendrick Lamar",
|
|
"album": "GNX",
|
|
"isrc": "USRC12345678",
|
|
},
|
|
}
|
|
|
|
STATUS_UP = {"amazonMusic": "up", "version": "1.0"}
|
|
STATUS_DOWN = {"amazonMusic": "down", "version": "1.0"}
|
|
|
|
|
|
def _mock_response(data: Any, status_code: int = 200) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.ok = 200 <= status_code < 400
|
|
resp.json.return_value = data
|
|
resp.text = json.dumps(data)
|
|
if status_code >= 400:
|
|
from requests import HTTPError
|
|
exc = HTTPError(response=resp)
|
|
resp.raise_for_status.side_effect = exc
|
|
else:
|
|
resp.raise_for_status.return_value = None
|
|
return resp
|
|
|
|
|
|
def _make_client(response_map: Optional[Dict[str, Any]] = None) -> AmazonClient:
|
|
"""Build an AmazonClient with a fake session.
|
|
|
|
response_map: path substring → response data (first match wins).
|
|
"""
|
|
session = MagicMock()
|
|
|
|
def _get(url, params=None, timeout=None, **_):
|
|
if response_map:
|
|
for key, data in response_map.items():
|
|
if key in url:
|
|
if isinstance(data, Exception):
|
|
raise data
|
|
return _mock_response(data)
|
|
return _mock_response({"error": "no mock for " + url}, 404)
|
|
|
|
session.get.side_effect = _get
|
|
with patch("core.amazon_client._rate_limit"):
|
|
with patch("core.amazon_client.config_manager") as cfg:
|
|
cfg.get.return_value = ""
|
|
client = AmazonClient(
|
|
base_url="https://test.t2tunes.local",
|
|
country="US",
|
|
session=session,
|
|
)
|
|
client.session = session
|
|
return client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dataclass construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTrackDataclass:
|
|
def test_from_search_hit_basic(self):
|
|
t = Track.from_search_hit(TRACK_DOC)
|
|
assert t.id == "B09XYZ1234"
|
|
assert t.name == "Not Like Us"
|
|
assert t.artists == ["Kendrick Lamar"]
|
|
assert t.album == "GNX"
|
|
assert t.duration_ms == 217_000
|
|
assert t.isrc == "USRC12345678"
|
|
assert t.popularity == 0
|
|
|
|
def test_from_search_hit_missing_fields(self):
|
|
t = Track.from_search_hit({})
|
|
assert t.id == ""
|
|
assert t.name == ""
|
|
assert t.artists == ["Unknown Artist"]
|
|
assert t.duration_ms == 0
|
|
assert t.isrc is None
|
|
|
|
def test_from_stream_info(self):
|
|
stream = T2TunesStreamInfo(
|
|
asin="B09XYZ1234",
|
|
streamable=True,
|
|
codec="FLAC",
|
|
format="FLAC",
|
|
sample_rate=44100,
|
|
stream_url="https://cdn.example.com/track.flac",
|
|
decryption_key=None,
|
|
title="Not Like Us",
|
|
artist="Kendrick Lamar",
|
|
album="GNX",
|
|
isrc="USRC12345678",
|
|
)
|
|
t = Track.from_stream_info(stream)
|
|
assert t.id == "B09XYZ1234"
|
|
assert t.name == "Not Like Us"
|
|
assert t.artists == ["Kendrick Lamar"]
|
|
assert t.isrc == "USRC12345678"
|
|
|
|
def test_from_stream_info_empty_artist(self):
|
|
stream = T2TunesStreamInfo(
|
|
asin="B1",
|
|
streamable=True,
|
|
codec="FLAC",
|
|
format="FLAC",
|
|
sample_rate=44100,
|
|
stream_url="https://cdn.example.com/t.flac",
|
|
decryption_key=None,
|
|
)
|
|
t = Track.from_stream_info(stream)
|
|
assert t.artists == ["Unknown Artist"]
|
|
|
|
|
|
class TestArtistDataclass:
|
|
def test_from_name(self):
|
|
a = Artist.from_name("Kendrick Lamar")
|
|
assert a.id == "kendrick_lamar"
|
|
assert a.name == "Kendrick Lamar"
|
|
assert a.genres == []
|
|
assert a.followers == 0
|
|
|
|
def test_from_name_special_chars(self):
|
|
a = Artist.from_name("AC/DC")
|
|
assert "ac" in a.id
|
|
|
|
|
|
class TestAlbumDataclass:
|
|
def test_from_search_hit(self):
|
|
al = Album.from_search_hit(ALBUM_DOC)
|
|
assert al.id == "B0ABCDE123"
|
|
assert al.name == "GNX"
|
|
assert al.artists == ["Kendrick Lamar"]
|
|
assert al.album_type == "album"
|
|
|
|
def test_from_search_hit_fallback_asin(self):
|
|
al = Album.from_search_hit({"asin": "B0001", "albumName": "Test", "artistName": "X"})
|
|
assert al.id == "B0001"
|
|
|
|
def test_from_metadata(self):
|
|
meta = ALBUM_METADATA_RESPONSE["albumList"][0]
|
|
al = Album.from_metadata(meta, asin="B0ABCDE123")
|
|
assert al.id == "B0ABCDE123"
|
|
assert al.name == "GNX"
|
|
assert al.total_tracks == 12
|
|
assert al.image_url == "https://example.com/cover.jpg"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T2TunesSearchItem helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestT2TunesSearchItem:
|
|
def test_is_track(self):
|
|
item = T2TunesSearchItem(
|
|
asin="A1", title="T", artist_name="X", item_type="MusicTrack"
|
|
)
|
|
assert item.is_track is True
|
|
assert item.is_album is False
|
|
|
|
def test_is_album(self):
|
|
item = T2TunesSearchItem(
|
|
asin="A1", title="T", artist_name="X", item_type="MusicAlbum"
|
|
)
|
|
assert item.is_album is True
|
|
assert item.is_track is False
|
|
|
|
def test_ambiguous_type(self):
|
|
item = T2TunesSearchItem(
|
|
asin="A1", title="T", artist_name="X", item_type="Unknown"
|
|
)
|
|
assert item.is_track is False
|
|
assert item.is_album is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _iter_search_items static method
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestIterSearchItems:
|
|
def test_parses_tracks(self):
|
|
items = list(AmazonClient._iter_search_items(SEARCH_RESPONSE_TRACKS))
|
|
assert len(items) == 2
|
|
assert items[0].asin == "B09XYZ1234"
|
|
assert items[0].title == "Not Like Us"
|
|
assert items[0].is_track
|
|
|
|
def test_parses_albums(self):
|
|
items = list(AmazonClient._iter_search_items(SEARCH_RESPONSE_ALBUMS))
|
|
assert len(items) == 1
|
|
assert items[0].is_album
|
|
|
|
def test_skips_missing_asin(self):
|
|
resp = {"results": [{"hits": [{"document": {"title": "No ASIN"}}]}]}
|
|
items = list(AmazonClient._iter_search_items(resp))
|
|
assert items == []
|
|
|
|
def test_empty_results(self):
|
|
items = list(AmazonClient._iter_search_items({"results": []}))
|
|
assert items == []
|
|
|
|
def test_wrong_type_raises(self):
|
|
with pytest.raises(AmazonClientError):
|
|
list(AmazonClient._iter_search_items(["not", "a", "dict"]))
|
|
|
|
def test_skips_malformed_hits(self):
|
|
resp = {
|
|
"results": [
|
|
{
|
|
"hits": [
|
|
"not_a_dict",
|
|
{"document": None},
|
|
{"document": {"asin": "B1", "__type": "track", "title": "T", "artistName": "A"}},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
items = list(AmazonClient._iter_search_items(resp))
|
|
assert len(items) == 1
|
|
assert items[0].asin == "B1"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_stream_info static method
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseStreamInfo:
|
|
def test_flac_stream(self):
|
|
s = AmazonClient._parse_stream_info(MEDIA_RESPONSE_FLAC)
|
|
assert s.asin == "B09XYZ1234"
|
|
assert s.streamable is True
|
|
assert s.codec == "FLAC"
|
|
assert s.sample_rate == 44100
|
|
assert s.stream_url == "https://cdn.example.com/track.flac"
|
|
assert s.has_decryption_key is False
|
|
assert s.title == "Not Like Us"
|
|
assert s.isrc == "USRC12345678"
|
|
|
|
def test_hires_with_key(self):
|
|
s = AmazonClient._parse_stream_info(MEDIA_RESPONSE_HIRES)
|
|
assert s.sample_rate == 96000
|
|
assert s.has_decryption_key is True
|
|
|
|
def test_typo_stremeable(self):
|
|
data = {
|
|
"asin": "B1",
|
|
"stremeable": True, # typo variant
|
|
"streamInfo": {"codec": "OPUS", "format": "OPUS", "streamUrl": "https://x.com/t.opus"},
|
|
"tags": {},
|
|
}
|
|
s = AmazonClient._parse_stream_info(data)
|
|
assert s.streamable is True
|
|
assert s.codec == "OPUS"
|
|
|
|
def test_missing_stream_info(self):
|
|
s = AmazonClient._parse_stream_info({"asin": "B1"})
|
|
assert s.stream_url == ""
|
|
assert s.codec == ""
|
|
assert s.sample_rate is None
|
|
assert s.has_decryption_key is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AmazonClient — HTTP layer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestStatus:
|
|
def test_success(self):
|
|
client = _make_client({"/api/status": STATUS_UP})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
result = client.status()
|
|
assert result["amazonMusic"] == "up"
|
|
|
|
def test_http_error_raises(self):
|
|
client = _make_client()
|
|
client.session.get.side_effect = None
|
|
client.session.get.return_value = _mock_response({}, 503)
|
|
with pytest.raises(AmazonClientError, match="HTTP 503"):
|
|
client.status()
|
|
|
|
def test_non_json_raises(self):
|
|
resp = MagicMock()
|
|
resp.raise_for_status.return_value = None
|
|
resp.json.side_effect = ValueError("not json")
|
|
resp.text = "<html>error</html>"
|
|
client = _make_client()
|
|
client.session.get.side_effect = None
|
|
client.session.get.return_value = resp
|
|
with pytest.raises(AmazonClientError, match="not JSON"):
|
|
client.status()
|
|
|
|
|
|
class TestIsAuthenticated:
|
|
def test_true_when_up(self):
|
|
client = _make_client({"/api/status": STATUS_UP})
|
|
assert client.is_authenticated() is True
|
|
|
|
def test_false_when_down(self):
|
|
client = _make_client({"/api/status": STATUS_DOWN})
|
|
assert client.is_authenticated() is False
|
|
|
|
def test_false_on_error(self):
|
|
from requests import RequestException
|
|
client = _make_client()
|
|
client.session.get.side_effect = RequestException("network error")
|
|
assert client.is_authenticated() is False
|
|
|
|
|
|
class TestReloadConfig:
|
|
def test_reloads_fields(self):
|
|
with patch("core.amazon_client.config_manager") as cfg:
|
|
cfg.get.side_effect = lambda key, default="": {
|
|
"amazon.base_url": "https://new.instance.local",
|
|
"amazon.country": "GB",
|
|
"amazon.preferred_codec": "opus",
|
|
}.get(key, default)
|
|
client = AmazonClient(session=MagicMock())
|
|
client.reload_config()
|
|
assert "new.instance.local" in client.base_url
|
|
assert client.country == "GB"
|
|
assert client.preferred_codec == "opus"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AmazonClient — search_raw
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSearchRaw:
|
|
def test_returns_items(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
items = client.search_raw("Kendrick Lamar")
|
|
assert len(items) == 2
|
|
|
|
def test_passes_country(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
client.search_raw("test", types="track")
|
|
call_kwargs = client.session.get.call_args
|
|
assert "country" in str(call_kwargs)
|
|
|
|
def test_network_error_raises(self):
|
|
from requests import RequestException
|
|
client = _make_client()
|
|
client.session.get.side_effect = RequestException("timeout")
|
|
with pytest.raises(AmazonClientError):
|
|
client.search_raw("test")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AmazonClient — search_tracks / search_artists / search_albums
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSearchTracks:
|
|
def test_returns_track_list(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
tracks = client.search_tracks("Kendrick Lamar")
|
|
assert len(tracks) == 2
|
|
assert all(isinstance(t, Track) for t in tracks)
|
|
|
|
def test_respects_limit(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
tracks = client.search_tracks("Kendrick Lamar", limit=1)
|
|
assert len(tracks) == 1
|
|
|
|
def test_ignores_album_hits(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_ALBUMS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
tracks = client.search_tracks("GNX")
|
|
assert tracks == []
|
|
|
|
def test_track_fields(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
tracks = client.search_tracks("Kendrick")
|
|
t = tracks[0]
|
|
assert t.name == "Not Like Us"
|
|
assert t.artists == ["Kendrick Lamar"]
|
|
assert t.album == "GNX"
|
|
assert t.duration_ms == 217_000
|
|
assert t.isrc == "USRC12345678"
|
|
|
|
|
|
class TestSearchArtists:
|
|
def test_returns_unique_artists(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
artists = client.search_artists("Kendrick")
|
|
# Both tracks are by Kendrick Lamar — should deduplicate
|
|
assert len(artists) == 1
|
|
assert artists[0].name == "Kendrick Lamar"
|
|
|
|
def test_returns_artist_dataclass(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
artists = client.search_artists("Kendrick")
|
|
assert isinstance(artists[0], Artist)
|
|
|
|
def test_artist_image_from_album(self):
|
|
resp = {
|
|
"results": [{"hits": [
|
|
{"document": {"asin": "A1", "title": "T1", "artistName": "Kendrick Lamar",
|
|
"__type": "track", "albumAsin": "B0ABCDE123"}},
|
|
]}]
|
|
}
|
|
client = _make_client({
|
|
"amazon-music/search": resp,
|
|
"amazon-music/metadata": ALBUM_METADATA_RESPONSE,
|
|
})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
artists = client.search_artists("Kendrick")
|
|
assert artists[0].image_url == "https://example.com/cover.jpg"
|
|
|
|
def test_deduplicates_feat_credits(self):
|
|
resp = {
|
|
"results": [
|
|
{
|
|
"hits": [
|
|
{"document": {"asin": "A1", "title": "T1", "artistName": "Kendrick Lamar", "__type": "track"}},
|
|
{"document": {"asin": "A2", "title": "T2", "artistName": "Kendrick Lamar feat. SZA", "__type": "track"}},
|
|
{"document": {"asin": "A3", "title": "T3", "artistName": "Kendrick Lamar ft. Drake", "__type": "track"}},
|
|
{"document": {"asin": "A4", "title": "T4", "artistName": "SZA featuring Kendrick Lamar", "__type": "track"}},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
client = _make_client({"amazon-music/search": resp})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
artists = client.search_artists("Kendrick")
|
|
names = [a.name for a in artists]
|
|
assert "Kendrick Lamar" in names
|
|
assert "SZA" in names
|
|
assert len(artists) == 2
|
|
|
|
def test_respects_limit(self):
|
|
resp = {
|
|
"results": [
|
|
{
|
|
"hits": [
|
|
{
|
|
"document": {
|
|
"asin": f"B{i}",
|
|
"title": f"Song {i}",
|
|
"artistName": f"Artist {i}",
|
|
"__type": "track",
|
|
}
|
|
}
|
|
for i in range(10)
|
|
]
|
|
}
|
|
]
|
|
}
|
|
client = _make_client({"amazon-music/search": resp})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
artists = client.search_artists("Various", limit=3)
|
|
assert len(artists) == 3
|
|
|
|
|
|
class TestSearchAlbums:
|
|
def test_returns_albums(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_ALBUMS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
albums = client.search_albums("GNX")
|
|
assert len(albums) == 1
|
|
assert isinstance(albums[0], Album)
|
|
assert albums[0].id == "B0ABCDE123"
|
|
|
|
def test_deduplicates_by_asin(self):
|
|
resp = {
|
|
"results": [
|
|
{
|
|
"hits": [
|
|
{"document": {**ALBUM_DOC}},
|
|
{"document": {**ALBUM_DOC}}, # duplicate
|
|
]
|
|
}
|
|
]
|
|
}
|
|
client = _make_client({"amazon-music/search": resp})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
albums = client.search_albums("GNX")
|
|
assert len(albums) == 1
|
|
|
|
def test_derives_albums_from_track_hits(self):
|
|
"""search_albums now intentionally queries `types=track` and derives
|
|
Album objects from the album metadata carried on each track hit —
|
|
Amazon's album-type query is broken upstream, so the t2tunes fix
|
|
switched everything to track-type and reconstructs albums from the
|
|
results. Distinct album ASINs across the track hits yield distinct
|
|
albums; duplicates collapse via the explicit/clean dedup key."""
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
albums = client.search_albums("Kendrick")
|
|
# Two track hits → two distinct album ASINs → two derived albums.
|
|
assert {a.id for a in albums} == {"B0ABCDE123", "B0ABCDE456"}
|
|
assert {a.name for a in albums} == {"GNX", "euphoria"}
|
|
assert all(a.artists == ["Kendrick Lamar"] for a in albums)
|
|
|
|
def test_strips_explicit_from_album_name(self):
|
|
resp = {
|
|
"results": [{"hits": [
|
|
{"document": {**ALBUM_DOC, "albumName": "GNX (Explicit)", "title": "GNX (Explicit)"}},
|
|
]}]
|
|
}
|
|
client = _make_client({"amazon-music/search": resp})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
albums = client.search_albums("GNX")
|
|
assert albums[0].name == "GNX"
|
|
|
|
def test_keeps_clean_suffix(self):
|
|
resp = {
|
|
"results": [{"hits": [
|
|
{"document": {**ALBUM_DOC, "albumName": "GNX [Clean]", "title": "GNX [Clean]"}},
|
|
]}]
|
|
}
|
|
client = _make_client({"amazon-music/search": resp})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
albums = client.search_albums("GNX")
|
|
assert albums[0].name == "GNX [Clean]"
|
|
|
|
def test_deduplicates_explicit_clean_as_separate(self):
|
|
resp = {
|
|
"results": [{"hits": [
|
|
{"document": {**ALBUM_DOC, "asin": "B1", "albumAsin": "B1", "albumName": "GNX (Explicit)", "title": "GNX (Explicit)"}},
|
|
{"document": {**ALBUM_DOC, "asin": "B2", "albumAsin": "B2", "albumName": "GNX [Clean]", "title": "GNX [Clean]"}},
|
|
]}]
|
|
}
|
|
client = _make_client({"amazon-music/search": resp})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
albums = client.search_albums("GNX")
|
|
names = [a.name for a in albums]
|
|
assert "GNX" in names # explicit stripped
|
|
assert "GNX [Clean]" in names
|
|
assert len(albums) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AmazonClient — album_metadata / media_from_asin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAlbumMetadata:
|
|
def test_returns_dict(self):
|
|
client = _make_client({"amazon-music/metadata": ALBUM_METADATA_RESPONSE})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
meta = client.album_metadata("B0ABCDE123")
|
|
assert "albumList" in meta
|
|
assert meta["albumList"][0]["title"] == "GNX"
|
|
|
|
|
|
class TestMediaFromAsin:
|
|
def test_list_response(self):
|
|
client = _make_client({"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC]})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
streams = client.media_from_asin("B09XYZ1234")
|
|
assert len(streams) == 1
|
|
assert isinstance(streams[0], T2TunesStreamInfo)
|
|
assert streams[0].codec == "FLAC"
|
|
|
|
def test_single_dict_response(self):
|
|
client = _make_client({"amazon-music/media-from-asin": MEDIA_RESPONSE_FLAC})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
streams = client.media_from_asin("B09XYZ1234")
|
|
assert len(streams) == 1
|
|
|
|
def test_invalid_response_raises(self):
|
|
client = _make_client({"amazon-music/media-from-asin": "not a list or dict"})
|
|
with pytest.raises(AmazonClientError, match="Unexpected media"):
|
|
client.media_from_asin("B09XYZ1234")
|
|
|
|
def test_uses_preferred_codec(self):
|
|
client = _make_client({"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC]})
|
|
client.preferred_codec = "opus"
|
|
with patch("core.amazon_client._rate_limit"):
|
|
client.media_from_asin("B09XYZ1234")
|
|
call_kwargs = str(client.session.get.call_args)
|
|
assert "opus" in call_kwargs
|
|
|
|
def test_codec_override(self):
|
|
client = _make_client({"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC]})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
client.media_from_asin("B09XYZ1234", codec="eac3")
|
|
call_kwargs = str(client.session.get.call_args)
|
|
assert "eac3" in call_kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AmazonClient — higher-level get_* methods
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGetTrackDetails:
|
|
def _client(self):
|
|
return _make_client({
|
|
"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC],
|
|
"amazon-music/metadata": ALBUM_METADATA_RESPONSE,
|
|
})
|
|
|
|
def test_returns_spotify_compat_dict(self):
|
|
client = self._client()
|
|
with patch("core.amazon_client._rate_limit"):
|
|
details = client.get_track_details("B09XYZ1234")
|
|
assert details is not None
|
|
assert details["id"] == "B09XYZ1234"
|
|
assert details["name"] == "Not Like Us"
|
|
assert "artists" in details
|
|
assert "album" in details
|
|
assert details["is_album_track"] is True
|
|
|
|
def test_album_image_populated(self):
|
|
client = self._client()
|
|
with patch("core.amazon_client._rate_limit"):
|
|
details = client.get_track_details("B09XYZ1234")
|
|
assert details["album"]["images"][0]["url"] == "https://example.com/cover.jpg"
|
|
|
|
def test_raw_data_present(self):
|
|
client = self._client()
|
|
with patch("core.amazon_client._rate_limit"):
|
|
details = client.get_track_details("B09XYZ1234")
|
|
assert "raw_data" in details
|
|
assert details["raw_data"]["codec"] == "FLAC"
|
|
assert details["raw_data"]["sample_rate"] == 44100
|
|
|
|
def test_returns_none_on_empty_streams(self):
|
|
client = _make_client({"amazon-music/media-from-asin": []})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
assert client.get_track_details("B09XYZ1234") is None
|
|
|
|
def test_returns_none_on_api_error(self):
|
|
client = _make_client()
|
|
client.session.get.return_value = _mock_response({}, 500)
|
|
with patch("core.amazon_client._rate_limit"):
|
|
assert client.get_track_details("B09XYZ1234") is None
|
|
|
|
def test_graceful_when_metadata_fails(self):
|
|
session = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
def _get(url, params=None, timeout=None, **_):
|
|
call_count["n"] += 1
|
|
if "media-from-asin" in url:
|
|
return _mock_response([MEDIA_RESPONSE_FLAC])
|
|
return _mock_response({}, 500)
|
|
|
|
session.get.side_effect = _get
|
|
client = AmazonClient(base_url="https://test.local", session=session)
|
|
with patch("core.amazon_client._rate_limit"):
|
|
details = client.get_track_details("B09XYZ1234")
|
|
assert details is not None
|
|
assert details["album"]["images"] == []
|
|
|
|
|
|
class TestGetAlbum:
|
|
def _client(self):
|
|
return _make_client({"amazon-music/metadata": ALBUM_METADATA_RESPONSE,
|
|
"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC]})
|
|
|
|
def test_returns_album_dict(self):
|
|
client = self._client()
|
|
with patch("core.amazon_client._rate_limit"):
|
|
album = client.get_album("B0ABCDE123")
|
|
assert album is not None
|
|
assert album["id"] == "B0ABCDE123"
|
|
assert album["name"] == "GNX"
|
|
assert album["total_tracks"] == 12
|
|
assert album["label"] == "pgLang/Interscope"
|
|
|
|
def test_includes_tracks_by_default(self):
|
|
client = self._client()
|
|
with patch("core.amazon_client._rate_limit"):
|
|
album = client.get_album("B0ABCDE123")
|
|
assert "tracks" in album
|
|
assert isinstance(album["tracks"], dict)
|
|
assert "items" in album["tracks"]
|
|
|
|
def test_excludes_tracks_when_flag_false(self):
|
|
client = _make_client({"amazon-music/metadata": ALBUM_METADATA_RESPONSE})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
album = client.get_album("B0ABCDE123", include_tracks=False)
|
|
assert "tracks" not in album
|
|
|
|
def test_release_date_backfilled_from_stream_tags(self):
|
|
# ALBUM_METADATA_RESPONSE has no release_date — should fall back to track date tag
|
|
client = self._client()
|
|
with patch("core.amazon_client._rate_limit"):
|
|
album = client.get_album("B0ABCDE123")
|
|
assert album["release_date"] == "2024-11-22"
|
|
|
|
def test_returns_none_on_empty_albumlist(self):
|
|
client = _make_client({"amazon-music/metadata": {"albumList": []}})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
assert client.get_album("B0ABCDE123") is None
|
|
|
|
def test_returns_none_on_api_error(self):
|
|
client = _make_client()
|
|
client.session.get.return_value = _mock_response({}, 500)
|
|
with patch("core.amazon_client._rate_limit"):
|
|
assert client.get_album("B0ABCDE123") is None
|
|
|
|
|
|
class TestGetAlbumTracks:
|
|
def test_returns_spotify_pagination(self):
|
|
client = _make_client({"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC]})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
result = client.get_album_tracks("B09XYZ1234")
|
|
assert result is not None
|
|
assert "items" in result
|
|
assert result["total"] == 1
|
|
assert result["next"] is None
|
|
assert result["limit"] == 50
|
|
|
|
def test_item_fields(self):
|
|
client = _make_client({"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC]})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
result = client.get_album_tracks("B09XYZ1234")
|
|
item = result["items"][0]
|
|
assert item["id"] == "B09XYZ1234"
|
|
assert item["name"] == "Not Like Us"
|
|
assert item["isrc"] == "USRC12345678"
|
|
assert item["track_number"] == 3
|
|
assert item["disc_number"] == 1
|
|
|
|
def test_duration_enriched_from_search(self):
|
|
search_resp = {
|
|
"results": [{"hits": [
|
|
{"document": {
|
|
"asin": "B09XYZ1234", "__type": "track",
|
|
"title": "Not Like Us", "artistName": "Kendrick Lamar",
|
|
"albumAsin": "B09XYZ1234", "duration": 217,
|
|
}},
|
|
]}]
|
|
}
|
|
client = _make_client({
|
|
"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC],
|
|
"amazon-music/search": search_resp,
|
|
})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
result = client.get_album_tracks("B09XYZ1234")
|
|
assert result["items"][0]["duration_ms"] == 217_000
|
|
|
|
def test_duration_zero_when_search_fails(self):
|
|
client = _make_client({"amazon-music/media-from-asin": [MEDIA_RESPONSE_FLAC]})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
result = client.get_album_tracks("B09XYZ1234")
|
|
assert result["items"][0]["duration_ms"] == 0
|
|
|
|
def test_returns_none_on_api_error(self):
|
|
client = _make_client()
|
|
client.session.get.return_value = _mock_response({}, 500)
|
|
with patch("core.amazon_client._rate_limit"):
|
|
assert client.get_album_tracks("B09XYZ1234") is None
|
|
|
|
|
|
class TestGetArtist:
|
|
def test_returns_artist_dict(self):
|
|
client = _make_client({"amazon-music/search": SEARCH_RESPONSE_TRACKS})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
artist = client.get_artist("Kendrick Lamar")
|
|
assert artist is not None
|
|
assert artist["name"] == "Kendrick Lamar"
|
|
assert "genres" in artist
|
|
assert "followers" in artist
|
|
|
|
def test_exact_match_preferred(self):
|
|
resp = {
|
|
"results": [
|
|
{
|
|
"hits": [
|
|
{"document": {"asin": "A1", "title": "T1", "artistName": "Kendrick Lamar", "__type": "track"}},
|
|
{"document": {"asin": "A2", "title": "T2", "artistName": "Kendrick Lamar Jr.", "__type": "track"}},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
client = _make_client({"amazon-music/search": resp})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
artist = client.get_artist("Kendrick Lamar")
|
|
assert artist["name"] == "Kendrick Lamar"
|
|
|
|
def test_returns_none_when_no_match(self):
|
|
client = _make_client({"amazon-music/search": {"results": []}})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
assert client.get_artist("Nobody") is None
|
|
|
|
def test_returns_none_on_error(self):
|
|
client = _make_client()
|
|
client.session.get.return_value = _mock_response({}, 500)
|
|
with patch("core.amazon_client._rate_limit"):
|
|
assert client.get_artist("Kendrick") is None
|
|
|
|
|
|
class TestGetArtistAlbums:
|
|
def test_returns_filtered_albums(self):
|
|
resp = {
|
|
"results": [
|
|
{
|
|
"hits": [
|
|
{
|
|
"document": {
|
|
"asin": "B0ABCDE123",
|
|
"albumAsin": "B0ABCDE123",
|
|
"title": "GNX",
|
|
"albumName": "GNX",
|
|
"artistName": "Kendrick Lamar",
|
|
"__type": "album",
|
|
}
|
|
},
|
|
{
|
|
"document": {
|
|
"asin": "B0ZZZ",
|
|
"albumAsin": "B0ZZZ",
|
|
"title": "Other Album",
|
|
"albumName": "Other Album",
|
|
"artistName": "Another Artist",
|
|
"__type": "album",
|
|
}
|
|
},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
client = _make_client({"amazon-music/search": resp})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
albums = client.get_artist_albums("Kendrick Lamar")
|
|
assert len(albums) == 1
|
|
assert albums[0].name == "GNX"
|
|
|
|
def test_respects_limit(self):
|
|
hits = [
|
|
{
|
|
"document": {
|
|
"asin": f"B{i}",
|
|
"albumAsin": f"B{i}",
|
|
"albumName": f"Album {i}",
|
|
"artistName": "Kendrick Lamar",
|
|
"__type": "album",
|
|
}
|
|
}
|
|
for i in range(20)
|
|
]
|
|
client = _make_client({"amazon-music/search": {"results": [{"hits": hits}]}})
|
|
with patch("core.amazon_client._rate_limit"):
|
|
albums = client.get_artist_albums("Kendrick Lamar", limit=5)
|
|
assert len(albums) == 5
|
|
|
|
def test_returns_empty_on_error(self):
|
|
client = _make_client()
|
|
client.session.get.return_value = _mock_response({}, 500)
|
|
with patch("core.amazon_client._rate_limit"):
|
|
assert client.get_artist_albums("Kendrick") == []
|
|
|
|
|
|
class TestGetTrackFeatures:
|
|
def test_always_none(self):
|
|
client = AmazonClient(session=MagicMock())
|
|
assert client.get_track_features("B09XYZ1234") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rate-limit enforcement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRateLimit:
|
|
def test_enforces_min_interval(self):
|
|
import core.amazon_client as mod
|
|
original = mod._last_api_call
|
|
sleeps = []
|
|
|
|
def fake_sleep(t):
|
|
sleeps.append(t)
|
|
|
|
with patch("core.amazon_client.time") as mock_time:
|
|
mock_time.monotonic.return_value = mod._last_api_call + 0.1
|
|
mock_time.sleep = fake_sleep
|
|
with patch("core.amazon_client.api_call_tracker"):
|
|
_rate_limit()
|
|
# Should have slept since interval not elapsed
|
|
assert len(sleeps) > 0
|
|
mod._last_api_call = original
|
|
|
|
def test_no_sleep_when_interval_elapsed(self):
|
|
import core.amazon_client as mod
|
|
original = mod._last_api_call
|
|
sleeps = []
|
|
|
|
with patch("core.amazon_client.time") as mock_time:
|
|
mock_time.monotonic.return_value = mod._last_api_call + 10.0
|
|
mock_time.sleep = lambda t: sleeps.append(t)
|
|
with patch("core.amazon_client.api_call_tracker"):
|
|
_rate_limit()
|
|
assert sleeps == []
|
|
mod._last_api_call = original
|