mirror of
https://github.com/beetbox/beets.git
synced 2026-07-20 09:16:50 -04:00
1030 lines
33 KiB
Python
1030 lines
33 KiB
Python
"""Tests for the 'tidal' plugin."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from beets.library.models import Item
|
|
from beets.test.helper import PluginTestCase
|
|
from beetsplug.tidal import TidalPlugin
|
|
|
|
if TYPE_CHECKING:
|
|
from beetsplug.tidal.api_types import (
|
|
AlbumAttributes,
|
|
RelationshipData,
|
|
ResourceIdentifier,
|
|
TidalAlbum,
|
|
TidalArtist,
|
|
TidalArtwork,
|
|
TidalTrack,
|
|
TrackAttributes,
|
|
)
|
|
|
|
CURRENT_TS = 150000000
|
|
|
|
|
|
def _make_artwork(id_: str, href: str = "") -> TidalArtwork:
|
|
return {
|
|
"id": id_,
|
|
"type": "artworks",
|
|
"attributes": {
|
|
"mediaType": "IMAGE",
|
|
"files": [{"href": href, "meta": {"width": 1280, "height": 1280}}]
|
|
if href
|
|
else [],
|
|
},
|
|
}
|
|
|
|
|
|
def _make_artist(id_: str, name: str) -> TidalArtist:
|
|
return {
|
|
"id": id_,
|
|
"type": "artists",
|
|
"attributes": {"name": name, "popularity": 0.5},
|
|
}
|
|
|
|
|
|
def _make_album(
|
|
id_: str,
|
|
title: str,
|
|
tracks: list[TidalTrack],
|
|
artist_ids: list[str],
|
|
release_date: str = "2024-01-15",
|
|
version: str | None = None,
|
|
cover_art_id: str | None = None,
|
|
) -> tuple[TidalAlbum, dict[str, TidalTrack], dict[str, TidalArtist]]:
|
|
artist_lookup = {
|
|
aid: _make_artist(aid, f"Artist {aid}") for aid in artist_ids
|
|
}
|
|
track_lookup = {t["id"]: t for t in tracks}
|
|
|
|
attrs: AlbumAttributes = {
|
|
"albumType": "ALBUM",
|
|
"barcodeId": "123456",
|
|
"duration": "PT45M",
|
|
"explicit": False,
|
|
"mediaTags": [],
|
|
"numberOfItems": len(tracks),
|
|
"numberOfVolumes": 1,
|
|
"popularity": 0.5,
|
|
"title": title,
|
|
"releaseDate": release_date,
|
|
}
|
|
if version:
|
|
attrs["version"] = version
|
|
|
|
relationships: dict[str, RelationshipData] = {
|
|
"artists": {
|
|
"data": [{"id": aid, "type": "artists"} for aid in artist_ids],
|
|
"links": {},
|
|
},
|
|
"items": {
|
|
"data": [{"id": t["id"], "type": "tracks"} for t in tracks],
|
|
"links": {},
|
|
},
|
|
}
|
|
if cover_art_id:
|
|
relationships["coverArt"] = {
|
|
"data": [{"id": cover_art_id, "type": "artworks"}],
|
|
"links": {},
|
|
}
|
|
|
|
album: TidalAlbum = {
|
|
"id": id_,
|
|
"type": "albums",
|
|
"attributes": attrs,
|
|
"relationships": relationships,
|
|
}
|
|
return album, track_lookup, artist_lookup
|
|
|
|
|
|
def _make_track(
|
|
id_: str,
|
|
title: str,
|
|
duration: str = "PT3M30S",
|
|
isrc: str = "ISRC123",
|
|
artist_ids: list[str] | None = None,
|
|
version: str | None = None,
|
|
) -> TidalTrack:
|
|
artist_relationships: list[ResourceIdentifier] = [
|
|
{"id": aid, "type": "artists"} for aid in (artist_ids or [])
|
|
]
|
|
attrs: TrackAttributes = {
|
|
"title": title,
|
|
"duration": duration,
|
|
"explicit": False,
|
|
"isrc": isrc,
|
|
"key": "C",
|
|
"keyScale": "MAJOR",
|
|
"mediaTags": [],
|
|
"popularity": 0.5,
|
|
}
|
|
if version:
|
|
attrs["version"] = version
|
|
return {
|
|
"id": id_,
|
|
"type": "tracks",
|
|
"attributes": attrs,
|
|
"relationships": {
|
|
"artists": {"data": artist_relationships, "links": {}}
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def set_current_ts(monkeypatch):
|
|
monkeypatch.setattr("beetsplug.tidal.time.time", lambda: CURRENT_TS)
|
|
|
|
|
|
class TidalPluginTest(PluginTestCase):
|
|
plugin = "tidal"
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.tidal = TidalPlugin()
|
|
|
|
|
|
class TestParsing(TidalPluginTest):
|
|
"""High-level tests for album parsing."""
|
|
|
|
def test_parse_album(self):
|
|
tracks = [
|
|
_make_track("101", "Track One", "PT3M", "ISRC1", ["1001"]),
|
|
_make_track(
|
|
"102",
|
|
"Track Two",
|
|
"PT4M",
|
|
"ISRC2",
|
|
["1001"],
|
|
version="Remastered",
|
|
),
|
|
]
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"1", "My Album", tracks, ["1001"], version="Deluxe Edition"
|
|
)
|
|
|
|
info = self.tidal._get_album_info(
|
|
album, track_lookup, artist_lookup, {}
|
|
)
|
|
|
|
assert info.raw_data == {
|
|
"album": "My Album (Deluxe Edition)",
|
|
"album_id": "1",
|
|
"albumdisambig": None,
|
|
"albumstatus": None,
|
|
"albumtype": "album",
|
|
"albumtypes": ["album"],
|
|
"artist": "Artist 1001",
|
|
"artist_credit": None,
|
|
"artist_id": "1001",
|
|
"artist_sort": None,
|
|
"artists": ["Artist 1001"],
|
|
"artists_credit": None,
|
|
"artists_ids": ["1001"],
|
|
"artists_sort": None,
|
|
"asin": None,
|
|
"barcode": "123456",
|
|
"catalognum": None,
|
|
"country": None,
|
|
"cover_art_url": None,
|
|
"data_source": "Tidal",
|
|
"data_url": None,
|
|
"day": 15,
|
|
"discogs_albumid": None,
|
|
"discogs_artistid": None,
|
|
"discogs_labelid": None,
|
|
"duration": 2700,
|
|
"genres": None,
|
|
"label": None,
|
|
"language": None,
|
|
"media": None,
|
|
"mediums": None,
|
|
"month": 1,
|
|
"original_day": None,
|
|
"original_month": None,
|
|
"original_year": None,
|
|
"release_group_title": None,
|
|
"releasegroup_id": None,
|
|
"releasegroupdisambig": None,
|
|
"script": None,
|
|
"style": None,
|
|
"tidal_album_id": "1",
|
|
"tidal_album_popularity": 50,
|
|
"tidal_artist_id": "1001",
|
|
"tidal_updated": CURRENT_TS,
|
|
"tracks": [
|
|
{
|
|
"album": None,
|
|
"arrangers": None,
|
|
"arrangers_ids": [],
|
|
"artist": "Artist 1001",
|
|
"artist_credit": None,
|
|
"artist_id": None,
|
|
"artist_sort": None,
|
|
"artists": ["Artist 1001"],
|
|
"artists_credit": None,
|
|
"artists_ids": ["1001"],
|
|
"artists_sort": None,
|
|
"bpm": None,
|
|
"composer_sort": None,
|
|
"composers": None,
|
|
"composers_ids": [],
|
|
"data_source": "Tidal",
|
|
"data_url": None,
|
|
"disctitle": None,
|
|
"duration": 180,
|
|
"genres": None,
|
|
"index": 1,
|
|
"initial_key": None,
|
|
"isrc": "ISRC1",
|
|
"label": None,
|
|
"length": None,
|
|
"lyricists": None,
|
|
"lyricists_ids": [],
|
|
"mb_workid": None,
|
|
"media": None,
|
|
"medium": None,
|
|
"medium_index": None,
|
|
"medium_total": None,
|
|
"release_track_id": None,
|
|
"remixers": None,
|
|
"remixers_ids": [],
|
|
"tidal_artist_id": "1001",
|
|
"tidal_track_id": "101",
|
|
"tidal_track_popularity": 50,
|
|
"tidal_updated": CURRENT_TS,
|
|
"title": "Track One",
|
|
"track_alt": None,
|
|
"track_id": "101",
|
|
"work": None,
|
|
"work_disambig": None,
|
|
},
|
|
{
|
|
"album": None,
|
|
"arrangers": None,
|
|
"arrangers_ids": [],
|
|
"artist": "Artist 1001",
|
|
"artist_credit": None,
|
|
"artist_id": None,
|
|
"artist_sort": None,
|
|
"artists": ["Artist 1001"],
|
|
"artists_credit": None,
|
|
"artists_ids": ["1001"],
|
|
"artists_sort": None,
|
|
"bpm": None,
|
|
"composer_sort": None,
|
|
"composers": None,
|
|
"composers_ids": [],
|
|
"data_source": "Tidal",
|
|
"data_url": None,
|
|
"disctitle": None,
|
|
"duration": 240,
|
|
"genres": None,
|
|
"index": 2,
|
|
"initial_key": None,
|
|
"isrc": "ISRC2",
|
|
"label": None,
|
|
"length": None,
|
|
"lyricists": None,
|
|
"lyricists_ids": [],
|
|
"mb_workid": None,
|
|
"media": None,
|
|
"medium": None,
|
|
"medium_index": None,
|
|
"medium_total": None,
|
|
"release_track_id": None,
|
|
"remixers": None,
|
|
"remixers_ids": [],
|
|
"tidal_artist_id": "1001",
|
|
"tidal_track_id": "102",
|
|
"tidal_track_popularity": 50,
|
|
"tidal_updated": CURRENT_TS,
|
|
"title": "Track Two (Remastered)",
|
|
"track_alt": None,
|
|
"track_id": "102",
|
|
"work": None,
|
|
"work_disambig": None,
|
|
},
|
|
],
|
|
"va": False,
|
|
"year": 2024,
|
|
}
|
|
|
|
def test_parse_album_with_version(self):
|
|
"""Album title should have version appended."""
|
|
track = _make_track("101", "My Song", "PT3M", "ISRC001", ["1001"])
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"3", "My Album", [track], ["1001"], version="Deluxe Edition"
|
|
)
|
|
|
|
info = self.tidal._get_album_info(
|
|
album, track_lookup, artist_lookup, {}
|
|
)
|
|
|
|
assert info.album == "My Album (Deluxe Edition)"
|
|
|
|
|
|
class TestArtworkParsing(TidalPluginTest):
|
|
"""Tests for artwork URL parsing."""
|
|
|
|
def test_artwork_with_url(self):
|
|
"""Cover art URL from included resources."""
|
|
track = _make_track("t1", "Song", "PT3M", "ISRC001", ["a1"])
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"al1", "Album", [track], ["a1"], cover_art_id="ca1"
|
|
)
|
|
artwork_lookup = {
|
|
"ca1": _make_artwork(
|
|
"ca1", "https://resources.tidal.com/images/ca1/1280x1280.jpg"
|
|
)
|
|
}
|
|
|
|
info = self.tidal._get_album_info(
|
|
album, track_lookup, artist_lookup, artwork_lookup
|
|
)
|
|
|
|
assert (
|
|
info.cover_art_url
|
|
== "https://resources.tidal.com/images/ca1/1280x1280.jpg"
|
|
)
|
|
|
|
def test_artwork_without_files_returns_none(self):
|
|
"""Cover art returns None when files array is empty."""
|
|
track = _make_track("t1", "Song", "PT3M", "ISRC001", ["a1"])
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"al1", "Album", [track], ["a1"], cover_art_id="ca1"
|
|
)
|
|
artwork_lookup = {"ca1": _make_artwork("ca1")}
|
|
|
|
info = self.tidal._get_album_info(
|
|
album, track_lookup, artist_lookup, artwork_lookup
|
|
)
|
|
|
|
assert info.cover_art_url is None
|
|
|
|
def test_artwork_without_relationship_returns_none(self):
|
|
"""No cover_art_url when album has no coverArt relationship."""
|
|
track = _make_track("t1", "Song", "PT3M", "ISRC001", ["a1"])
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"al1", "Album", [track], ["a1"]
|
|
)
|
|
|
|
info = self.tidal._get_album_info(
|
|
album, track_lookup, artist_lookup, {}
|
|
)
|
|
|
|
assert info.cover_art_url is None
|
|
|
|
|
|
class TestTrackParsing(TidalPluginTest):
|
|
"""High-level tests for track parsing."""
|
|
|
|
def test_parse_track(self):
|
|
track = _make_track("101", "My Track", "PT4M", "ISRC456", ["1001"])
|
|
artist_lookup = {"1001": _make_artist("1001", "My Artist")}
|
|
|
|
info = self.tidal._get_track_info(track, artist_lookup)
|
|
|
|
assert info.title == "My Track"
|
|
assert info.track_id == "101"
|
|
assert info.duration == 240 # PT4M = 240 seconds
|
|
assert info.isrc == "ISRC456"
|
|
assert info.artist == "My Artist"
|
|
assert info.tidal_track_id == "101"
|
|
assert info.tidal_artist_id == "1001"
|
|
assert info.tidal_track_popularity == 50
|
|
|
|
def test_parse_track_with_version(self):
|
|
"""Track title should have version appended."""
|
|
track = _make_track(
|
|
"102", "My Song", "PT3M", "ISRC002", ["1001"], version="Remastered"
|
|
)
|
|
artist_lookup = {"1001": _make_artist("1001", "My Artist")}
|
|
|
|
info = self.tidal._get_track_info(track, artist_lookup)
|
|
|
|
assert info.title == "My Song (Remastered)"
|
|
assert info.tidal_track_id == "102"
|
|
|
|
|
|
class TestTrackForID(TidalPluginTest):
|
|
"""Tests for track_for_id with mocked API."""
|
|
|
|
def test_track_for_id(self):
|
|
"""Test fetching track by ID via API."""
|
|
track = _make_track(
|
|
"490839595", "API Track", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
artist = _make_artist("1001", "API Artist")
|
|
|
|
self.tidal.api.get_tracks = Mock(
|
|
return_value={"data": [track], "included": [artist]}
|
|
)
|
|
|
|
info = self.tidal.track_for_id("https://tidal.com/track/490839595")
|
|
self.tidal.api.get_tracks.assert_called_once()
|
|
|
|
assert info is not None
|
|
assert info.title == "API Track"
|
|
assert info.track_id == "490839595"
|
|
assert info.artist == "API Artist"
|
|
|
|
def test_track_for_id_not_found(self):
|
|
"""Test track_for_id returns None when not found."""
|
|
self.tidal.api.get_tracks = Mock(return_value={"data": []})
|
|
info = self.tidal.track_for_id("https://tidal.com/track/490839595")
|
|
assert info is None
|
|
|
|
info = self.tidal.track_for_id("does_not_exist")
|
|
assert info is None
|
|
|
|
|
|
class TestTracksForIDs(TidalPluginTest):
|
|
"""Tests for tracks_for_ids with mocked API."""
|
|
|
|
def test_tracks_for_ids(self):
|
|
"""Test fetching multiple tracks by IDs via API."""
|
|
track1 = _make_track(
|
|
"490839595", "API Track 1", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
track2 = _make_track(
|
|
"490839596", "API Track 2", "PT4M", "ISRC002", ["1001"]
|
|
)
|
|
artist = _make_artist("1001", "API Artist")
|
|
|
|
self.tidal.api.get_tracks = Mock(
|
|
return_value={"data": [track1, track2], "included": [artist]}
|
|
)
|
|
|
|
results = list(
|
|
self.tidal.tracks_for_ids(
|
|
[
|
|
"https://tidal.com/track/490839595",
|
|
"https://tidal.com/track/490839596",
|
|
]
|
|
)
|
|
)
|
|
self.tidal.api.get_tracks.assert_called_once()
|
|
assert len(results) == 2
|
|
assert results[0] is not None
|
|
assert results[0].title == "API Track 1"
|
|
assert results[1] is not None
|
|
assert results[1].title == "API Track 2"
|
|
|
|
def test_tracks_for_ids_with_missing(self):
|
|
"""Test tracks_for_ids yields None for IDs not found."""
|
|
track = _make_track(
|
|
"490839595", "API Track", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
artist = _make_artist("1001", "API Artist")
|
|
|
|
self.tidal.api.get_tracks = Mock(
|
|
return_value={"data": [track], "included": [artist]}
|
|
)
|
|
|
|
results = list(
|
|
self.tidal.tracks_for_ids(
|
|
["https://tidal.com/track/490839595", "does_not_exist"]
|
|
)
|
|
)
|
|
|
|
assert len(results) == 2
|
|
assert results[0] is not None
|
|
assert results[0].title == "API Track"
|
|
assert results[1] is None
|
|
|
|
|
|
class TestAlbumForID(TidalPluginTest):
|
|
"""Tests for album_for_id with mocked API."""
|
|
|
|
def test_album_for_id(self):
|
|
"""Test fetching album by ID via API."""
|
|
track = _make_track(
|
|
"101", "Album Track", "PT3M30S", "ISRC001", ["1001"]
|
|
)
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"226495055", "API Album", [track], ["1001"]
|
|
)
|
|
self.tidal.api.get_albums = Mock(
|
|
return_value={
|
|
"data": [album],
|
|
"included": [*artist_lookup.values(), *track_lookup.values()],
|
|
}
|
|
)
|
|
|
|
info = self.tidal.album_for_id("https://tidal.com/album/226495055")
|
|
|
|
assert info is not None
|
|
assert info.album == "API Album"
|
|
assert info.album_id == "226495055"
|
|
assert len(info.tracks) == 1
|
|
assert info.tracks[0].title == "Album Track"
|
|
|
|
def test_album_for_id_not_found(self):
|
|
"""Test album_for_id returns None when not found."""
|
|
self.tidal.api.get_albums = Mock(return_value={"data": []})
|
|
info = self.tidal.album_for_id("https://tidal.com/album/226495055")
|
|
assert info is None
|
|
|
|
info = self.tidal.album_for_id("does_not_exist")
|
|
assert info is None
|
|
|
|
|
|
class TestAlbumsForIDs(TidalPluginTest):
|
|
"""Tests for albums_for_ids with mocked API."""
|
|
|
|
def test_albums_for_ids(self):
|
|
"""Test fetching multiple albums by IDs via API."""
|
|
track1 = _make_track(
|
|
"101", "Album Track 1", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
track2 = _make_track(
|
|
"102", "Album Track 2", "PT4M", "ISRC002", ["1001"]
|
|
)
|
|
album1, track_lookup1, artist_lookup1 = _make_album(
|
|
"226495055", "API Album 1", [track1], ["1001"]
|
|
)
|
|
album2, track_lookup2, artist_lookup2 = _make_album(
|
|
"226495056", "API Album 2", [track2], ["1001"]
|
|
)
|
|
|
|
# Combine lookups to simulate API response
|
|
all_included = [
|
|
*artist_lookup1.values(),
|
|
*artist_lookup2.values(),
|
|
*track_lookup1.values(),
|
|
*track_lookup2.values(),
|
|
]
|
|
|
|
self.tidal.api.get_albums = Mock(
|
|
return_value={"data": [album1, album2], "included": all_included}
|
|
)
|
|
|
|
results = list(
|
|
self.tidal.albums_for_ids(
|
|
[
|
|
"https://tidal.com/album/226495055",
|
|
"https://tidal.com/album/226495056",
|
|
]
|
|
)
|
|
)
|
|
|
|
self.tidal.api.get_albums.assert_called_once()
|
|
# Note: yields album then None for each ID
|
|
assert len(results) == 2
|
|
assert results[0] is not None
|
|
assert results[0].album == "API Album 1"
|
|
assert results[1] is not None
|
|
assert results[1].album == "API Album 2"
|
|
|
|
def test_albums_for_ids_with_missing(self):
|
|
"""Test albums_for_ids yields None for IDs not found."""
|
|
track = _make_track("101", "Album Track", "PT3M", "ISRC001", ["1001"])
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"226495055", "API Album", [track], ["1001"]
|
|
)
|
|
|
|
self.tidal.api.get_albums = Mock(
|
|
return_value={
|
|
"data": [album],
|
|
"included": [*artist_lookup.values(), *track_lookup.values()],
|
|
}
|
|
)
|
|
|
|
results = list(
|
|
self.tidal.albums_for_ids(
|
|
["https://tidal.com/album/226495055", "does_not_exist"]
|
|
)
|
|
)
|
|
|
|
# yields (album, None) for (found, not_found)
|
|
assert len(results) == 2
|
|
assert results[0] is not None
|
|
assert results[0].album == "API Album"
|
|
assert results[1] is None
|
|
|
|
|
|
class TestCandidates(TidalPluginTest):
|
|
"""Tests for candidates method."""
|
|
|
|
def test_candidates_with_barcode(self):
|
|
"""Test that candidates uses barcode lookup first."""
|
|
track = _make_track("101", "Album Track", "PT3M", "ISRC001", ["1001"])
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"1", "Barcode Album", [track], ["1001"]
|
|
)
|
|
|
|
self.tidal.api.get_albums = Mock(
|
|
return_value={
|
|
"data": [album],
|
|
"included": [*artist_lookup.values(), *track_lookup.values()],
|
|
}
|
|
)
|
|
|
|
items = [Item(barcode="123456")]
|
|
|
|
candidates = list(
|
|
self.tidal.candidates(items, "Artist", "Album", False)
|
|
)
|
|
|
|
self.tidal.api.get_albums.assert_called_once()
|
|
assert len(candidates) == 1
|
|
assert candidates[0].album == "Barcode Album"
|
|
|
|
def test_candidates_with_query_fallback(self):
|
|
"""Test that candidates falls back to query search when no barcode."""
|
|
items = [Item(title="My Song", artist="My Artist", album="My Album")]
|
|
|
|
# Mock search returning album IDs
|
|
self.tidal.api.search_results = Mock(
|
|
return_value={
|
|
"data": {
|
|
"relationships": {
|
|
"albums": {"data": [{"id": "1", "type": "albums"}]}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
# Mock album lookup by ID
|
|
track = _make_track("101", "Album Track", "PT3M", "ISRC001", ["1001"])
|
|
album, track_lookup, artist_lookup = _make_album(
|
|
"1", "Query Album", [track], ["1001"]
|
|
)
|
|
self.tidal.api.get_albums = Mock(
|
|
return_value={
|
|
"data": [album],
|
|
"included": [*artist_lookup.values(), *track_lookup.values()],
|
|
}
|
|
)
|
|
|
|
candidates = list(
|
|
self.tidal.candidates(items, "My Artist", "My Album", False)
|
|
)
|
|
|
|
# Should have called search_results
|
|
assert self.tidal.api.search_results.called
|
|
assert len(candidates) == 1
|
|
assert candidates[0].album == "Query Album"
|
|
|
|
|
|
class TestItemCandidates(TidalPluginTest):
|
|
"""Tests for item_candidates method."""
|
|
|
|
def test_item_candidates_with_isrc(self):
|
|
"""Test that item_candidates uses ISRC lookup first."""
|
|
track = _make_track(
|
|
"490839595", "ISRC Track", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
artist = _make_artist("1001", "ISRC Artist")
|
|
|
|
self.tidal.api.get_tracks = Mock(
|
|
return_value={"data": [track], "included": [artist]}
|
|
)
|
|
|
|
item = Item(isrc="ISRC001")
|
|
|
|
results = list(self.tidal.item_candidates(item, "Artist", "Title"))
|
|
|
|
self.tidal.api.get_tracks.assert_called_once()
|
|
assert len(results) == 1
|
|
assert results[0].title == "ISRC Track"
|
|
|
|
def test_item_candidates_with_query_fallback(self):
|
|
"""Test item_candidates falls back to query search when no ISRC."""
|
|
item = Item(title="Query Song", artist="Query Artist")
|
|
|
|
self.tidal.api.search_results = Mock(
|
|
return_value={
|
|
"data": {
|
|
"relationships": {
|
|
"tracks": {
|
|
"data": [{"id": "490839595", "type": "tracks"}]
|
|
}
|
|
}
|
|
},
|
|
"included": [
|
|
_make_track(
|
|
"490839595", "Query Track", "PT3M", "ISRC002", ["1001"]
|
|
),
|
|
_make_artist("1001", "Query Artist"),
|
|
],
|
|
}
|
|
)
|
|
|
|
results = list(
|
|
self.tidal.item_candidates(item, "Query Artist", "Query Song")
|
|
)
|
|
|
|
assert self.tidal.api.search_results.called
|
|
assert results[0].title == "Query Track"
|
|
|
|
|
|
class TestStaticHelpers:
|
|
"""Tests for static helper methods."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"attrs, expected",
|
|
[
|
|
(
|
|
{
|
|
"externalLinks": [
|
|
{"href": "https://tidal.com/b/123", "meta": ""}
|
|
]
|
|
},
|
|
"https://tidal.com/b/123",
|
|
),
|
|
({}, None),
|
|
],
|
|
)
|
|
def test_parse_data_url(self, attrs, expected):
|
|
assert TidalPlugin._parse_data_url(attrs) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
"attrs, expected",
|
|
[
|
|
({"copyright": {"text": "(P) 2024 Tidal"}}, "(P) 2024 Tidal"),
|
|
({}, None),
|
|
],
|
|
)
|
|
def test_parse_label(self, attrs, expected):
|
|
assert TidalPlugin._parse_label(attrs) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
"attrs, expected",
|
|
[
|
|
({"releaseDate": "2024-01-15"}, (2024, 1, 15)),
|
|
({}, None),
|
|
({"releaseDate": "2024"}, None),
|
|
],
|
|
)
|
|
def test_parse_release_date(self, attrs, expected):
|
|
assert TidalPlugin._parse_release_date(attrs) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
"duration,expected",
|
|
[
|
|
("PT30S", 30),
|
|
("PT3M30S", 210),
|
|
("PT4M", 240),
|
|
("PT1H", 3600),
|
|
("PT1H30M", 5400),
|
|
],
|
|
)
|
|
def test_duration_conversions(self, duration, expected):
|
|
assert TidalPlugin._duration_to_seconds(duration) == expected
|
|
|
|
def test_duration_invalid_raises(self, caplog):
|
|
with caplog.at_level(logging.WARNING):
|
|
TidalPlugin._duration_to_seconds("invalid")
|
|
assert "Invalid ISO 8601 duration: invalid" in caplog.text
|
|
|
|
def test_popularity_with_float(self):
|
|
assert TidalPlugin._parse_popularity({"popularity": 0.5}) == 50
|
|
assert TidalPlugin._parse_popularity({"popularity": 1.0}) == 100
|
|
assert TidalPlugin._parse_popularity({"popularity": 0.0}) == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
"artwork_data, artwork_by_id, expected",
|
|
[
|
|
(
|
|
[{"id": "ca1", "type": "artworks"}],
|
|
{
|
|
"ca1": {
|
|
"id": "ca1",
|
|
"type": "artworks",
|
|
"attributes": {
|
|
"mediaType": "IMAGE",
|
|
"files": [
|
|
{
|
|
"href": "https://example.com/cover.jpg",
|
|
"meta": {"width": 1280, "height": 1280},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
},
|
|
"https://example.com/cover.jpg",
|
|
),
|
|
(
|
|
[{"id": "ca1", "type": "artworks"}],
|
|
{
|
|
"ca1": {
|
|
"id": "ca1",
|
|
"type": "artworks",
|
|
"attributes": {"mediaType": "IMAGE", "files": []},
|
|
}
|
|
},
|
|
None,
|
|
),
|
|
# No artworks in relationship data
|
|
([{"id": "ca1", "type": "coverArts"}], {}, None),
|
|
# No cover art lookup
|
|
([{"id": "ca1", "type": "artworks"}], {}, None),
|
|
# Empty relationships
|
|
({}, {}, None),
|
|
],
|
|
)
|
|
def test_parse_artwork_url(self, artwork_data, artwork_by_id, expected):
|
|
album: TidalAlbum = {
|
|
"id": "al1",
|
|
"type": "albums",
|
|
"attributes": {
|
|
"albumType": "ALBUM",
|
|
"barcodeId": "123",
|
|
"duration": "PT45M",
|
|
"explicit": False,
|
|
"mediaTags": [],
|
|
"numberOfItems": 1,
|
|
"numberOfVolumes": 1,
|
|
"popularity": 0.5,
|
|
"title": "Album",
|
|
},
|
|
"relationships": {"coverArt": {"data": artwork_data, "links": {}}}
|
|
if artwork_data
|
|
else {},
|
|
}
|
|
assert TidalPlugin._parse_artwork_url(album, artwork_by_id) == expected
|
|
|
|
|
|
class TestTidalsync(TidalPluginTest):
|
|
"""Tests for the tidalsync command."""
|
|
|
|
def _run_tidalsync(self, *args: str) -> None:
|
|
command = next(
|
|
cmd for cmd in self.tidal.commands() if cmd.name == "tidalsync"
|
|
)
|
|
opts, subargs = command.parser.parse_args(list(args))
|
|
command.func(self.lib, opts, subargs)
|
|
|
|
def test_sync_updates_popularity(self):
|
|
"""Test sync_item_popularity fetches and stores popularity."""
|
|
item = self.add_item(
|
|
tidal_track_id=490839595, title="Test Track", artist="Test Artist"
|
|
)
|
|
|
|
track = _make_track(
|
|
"490839595", "Test Track", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
artist = _make_artist("1001", "Test Artist")
|
|
|
|
self.tidal.api.get_tracks = Mock(
|
|
return_value={"data": [track], "included": [artist]}
|
|
)
|
|
|
|
self.tidal.sync_item_popularity([item], write=False)
|
|
|
|
assert item["tidal_track_popularity"] == 50
|
|
assert item["tidal_updated"] is not None
|
|
assert isinstance(item["tidal_updated"], (int, float))
|
|
|
|
def test_sync_skips_existing(self):
|
|
"""Test sync_item_popularity skips items with existing popularity."""
|
|
item = self.add_item(
|
|
tidal_track_id=490839595,
|
|
tidal_track_popularity=42,
|
|
tidal_updated=time.time(),
|
|
title="Test Track",
|
|
artist="Test Artist",
|
|
)
|
|
|
|
self.tidal.sync_item_popularity([item], write=False)
|
|
|
|
assert item["tidal_track_popularity"] == 42
|
|
|
|
def test_sync_force_updates_existing(self):
|
|
"""Test sync_item_popularity with force re-fetches."""
|
|
item = self.add_item(
|
|
tidal_track_id=490839595,
|
|
tidal_track_popularity=42,
|
|
tidal_updated=time.time(),
|
|
title="Test Track",
|
|
artist="Test Artist",
|
|
)
|
|
|
|
track = _make_track(
|
|
"490839595", "Test Track", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
artist = _make_artist("1001", "Test Artist")
|
|
|
|
self.tidal.api.get_tracks = Mock(
|
|
return_value={"data": [track], "included": [artist]}
|
|
)
|
|
|
|
self.tidal.sync_item_popularity([item], write=False, force=True)
|
|
|
|
assert item["tidal_track_popularity"] == 50
|
|
|
|
def test_sync_skips_items_without_track_id(self):
|
|
"""Test sync_item_popularity skips items without tidal_track_id."""
|
|
item = self.add_item(title="No ID Track", artist="No ID Artist")
|
|
|
|
self.tidal.sync_item_popularity([item], write=False)
|
|
|
|
assert item.get("tidal_track_popularity") is None
|
|
|
|
def test_sync_does_not_update_when_lookup_is_missing(self):
|
|
"""Test sync_item_popularity leaves stale data untouched on miss."""
|
|
item = self.add_item(
|
|
tidal_track_id="490839595",
|
|
title="Missing Track",
|
|
artist="Missing Artist",
|
|
)
|
|
|
|
self.tidal.api.get_tracks = Mock(
|
|
return_value={"data": [], "included": []}
|
|
)
|
|
|
|
self.tidal.sync_item_popularity([item], write=False)
|
|
|
|
assert item.get("tidal_track_popularity") is None
|
|
assert item.get("tidal_updated") is None
|
|
|
|
def test_sync_updates_album_popularity(self):
|
|
"""Test sync_album_popularity fetches and stores album popularity."""
|
|
item = self.add_item(
|
|
tidal_track_id="490839595",
|
|
tidal_album_id="251380836",
|
|
title="Test Track",
|
|
artist="Test Artist",
|
|
)
|
|
|
|
track = _make_track(
|
|
"490839595", "Test Track", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
artist = _make_artist("1001", "Test Artist")
|
|
|
|
album_track = _make_track(
|
|
"101", "Album Track", "PT3M", "ISRC001", ["1001"]
|
|
)
|
|
album_data, _, artist_lookup = _make_album(
|
|
"251380836", "Test Album", [album_track], ["1001"]
|
|
)
|
|
|
|
album = self.lib.add_album([item])
|
|
album["tidal_album_id"] = "251380836"
|
|
album.store()
|
|
|
|
self.tidal.api.get_tracks = Mock(
|
|
return_value={"data": [track], "included": [artist]}
|
|
)
|
|
self.tidal.api.get_albums = Mock(
|
|
return_value={
|
|
"data": [album_data],
|
|
"included": [*artist_lookup.values()],
|
|
}
|
|
)
|
|
|
|
self.tidal.sync_item_popularity([item], write=False)
|
|
self.tidal.sync_album_popularity([album], write=False)
|
|
|
|
assert item["tidal_track_popularity"] == 50
|
|
assert album["tidal_album_popularity"] == 50
|
|
assert item["tidal_updated"] is not None
|
|
|
|
def test_tidalsync_uses_first_query_argument(self):
|
|
self.lib.items = Mock(return_value=["items"])
|
|
self.tidal.sync_item_popularity = Mock()
|
|
|
|
self._run_tidalsync("path::aaa")
|
|
|
|
self.lib.items.assert_called_once_with(
|
|
["data_source:tidal", "path::aaa"]
|
|
)
|
|
self.tidal.sync_item_popularity.assert_called_once_with(
|
|
["items"], write=False, force=False
|
|
)
|
|
|
|
def test_tidalsync_defaults_to_items(self):
|
|
self.lib.items = Mock(return_value=["items"])
|
|
self.tidal.sync_item_popularity = Mock()
|
|
self.tidal.sync_album_popularity = Mock()
|
|
|
|
self._run_tidalsync()
|
|
|
|
self.lib.items.assert_called_once_with(["data_source:tidal"])
|
|
self.tidal.sync_item_popularity.assert_called_once_with(
|
|
["items"], write=False, force=False
|
|
)
|
|
self.tidal.sync_album_popularity.assert_not_called()
|
|
|
|
def test_tidalsync_album_mode_uses_album_query(self):
|
|
self.lib.albums = Mock(return_value=["albums"])
|
|
self.tidal.sync_item_popularity = Mock()
|
|
self.tidal.sync_album_popularity = Mock()
|
|
|
|
self._run_tidalsync("-a", "artist:Test")
|
|
|
|
self.lib.albums.assert_called_once_with(
|
|
["data_source:tidal", "artist:Test"]
|
|
)
|
|
self.tidal.sync_album_popularity.assert_called_once_with(
|
|
["albums"], write=False, force=False
|
|
)
|
|
self.tidal.sync_item_popularity.assert_not_called()
|