2026-06-24 19:11:02 -04:00
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
from mopidy.models import Artist, Playlist, SearchResult, Track
|
|
|
|
|
|
|
|
|
|
from mopidy_smartplaylists.generators import (
|
|
|
|
|
_extract_tracks,
|
|
|
|
|
_flatten_lookup,
|
|
|
|
|
_sanitize_name,
|
|
|
|
|
build_artist_mix,
|
|
|
|
|
build_decade_mix,
|
|
|
|
|
build_genre_mix,
|
|
|
|
|
build_instant_mix,
|
|
|
|
|
parse_decade,
|
|
|
|
|
refresh_smart_playlists,
|
|
|
|
|
save_smart_playlist,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestParseDecade:
|
|
|
|
|
def test_four_digit_year(self):
|
|
|
|
|
assert parse_decade("1980") == "198*"
|
|
|
|
|
|
|
|
|
|
def test_with_suffix(self):
|
|
|
|
|
assert parse_decade("1980s") == "198*"
|
|
|
|
|
|
|
|
|
|
def test_three_digit(self):
|
|
|
|
|
assert parse_decade("199") == "199*"
|
|
|
|
|
|
|
|
|
|
def test_whitespace(self):
|
|
|
|
|
assert parse_decade(" 2000 ") == "200*"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSanitizeName:
|
|
|
|
|
def test_lowercase(self):
|
|
|
|
|
assert _sanitize_name("Hello World") == "hello_world"
|
|
|
|
|
|
|
|
|
|
def test_special_chars(self):
|
|
|
|
|
assert _sanitize_name("Jazz/Funk Mix!") == "jazz_funk_mix_"
|
|
|
|
|
|
|
|
|
|
def test_already_sanitized(self):
|
|
|
|
|
assert _sanitize_name("hello_world") == "hello_world"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExtractTracks:
|
|
|
|
|
def test_extracts_from_search_results(self):
|
2026-06-24 19:43:20 -04:00
|
|
|
tracks = [
|
|
|
|
|
Track(uri="dummy:1", name="Track 1"),
|
|
|
|
|
Track(uri="dummy:2", name="Track 2"),
|
|
|
|
|
]
|
2026-06-24 19:11:02 -04:00
|
|
|
results = [SearchResult(uri="dummy:search", tracks=tracks)]
|
|
|
|
|
assert _extract_tracks(results) == tracks
|
|
|
|
|
|
|
|
|
|
def test_empty_results(self):
|
|
|
|
|
assert _extract_tracks([]) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFlattenLookup:
|
|
|
|
|
def test_flattens_dict_of_lists(self):
|
|
|
|
|
result = {
|
|
|
|
|
"dummy:1": [Track(uri="dummy:1", name="Track 1")],
|
|
|
|
|
"dummy:2": [Track(uri="dummy:2", name="Track 2")],
|
|
|
|
|
}
|
|
|
|
|
flat = _flatten_lookup(result)
|
|
|
|
|
assert len(flat) == 2
|
|
|
|
|
|
|
|
|
|
def test_empty_lookup(self):
|
|
|
|
|
assert _flatten_lookup({}) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBuildDecadeMix:
|
|
|
|
|
def test_searches_with_decade_query(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
expected_tracks = [Track(uri="dummy:1", name="Track 1")]
|
|
|
|
|
core.library.search.return_value.get.return_value = [
|
|
|
|
|
SearchResult(uri="dummy:search", tracks=expected_tracks)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = build_decade_mix(core, "1980")
|
|
|
|
|
|
|
|
|
|
core.library.search.assert_called_once()
|
|
|
|
|
query_arg = core.library.search.call_args[0][0]
|
|
|
|
|
assert "date" in query_arg
|
|
|
|
|
assert query_arg["date"] == ["198*"]
|
|
|
|
|
assert result == expected_tracks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBuildGenreMix:
|
|
|
|
|
def test_searches_with_genre_query(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
expected_tracks = [Track(uri="dummy:1", name="Jazz Track")]
|
|
|
|
|
core.library.search.return_value.get.return_value = [
|
|
|
|
|
SearchResult(uri="dummy:search", tracks=expected_tracks)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = build_genre_mix(core, "Jazz")
|
|
|
|
|
|
|
|
|
|
core.library.search.assert_called_once()
|
|
|
|
|
query_arg = core.library.search.call_args[0][0]
|
|
|
|
|
assert query_arg["genre"] == ["Jazz"]
|
|
|
|
|
assert result == expected_tracks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBuildArtistMix:
|
|
|
|
|
def test_searches_with_artist_query(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
expected_tracks = [Track(uri="dummy:1", name="Artist Track")]
|
|
|
|
|
core.library.search.return_value.get.return_value = [
|
|
|
|
|
SearchResult(uri="dummy:search", tracks=expected_tracks)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = build_artist_mix(core, "Miles Davis")
|
|
|
|
|
|
|
|
|
|
core.library.search.assert_called_once()
|
|
|
|
|
query_arg = core.library.search.call_args[0][0]
|
|
|
|
|
assert query_arg["artist"] == ["Miles Davis"]
|
|
|
|
|
assert result == expected_tracks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBuildInstantMix:
|
|
|
|
|
def test_returns_similar_tracks_by_genre(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
seed = Track(
|
|
|
|
|
uri="dummy:seed",
|
|
|
|
|
name="Seed Track",
|
|
|
|
|
genre="Jazz",
|
|
|
|
|
artists=[Artist(name="Miles Davis")],
|
|
|
|
|
)
|
|
|
|
|
core.library.lookup.return_value.get.return_value = {
|
|
|
|
|
"dummy:seed": [seed],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
similar = [
|
|
|
|
|
Track(uri="dummy:similar1", name="Similar 1", genre="Jazz"),
|
|
|
|
|
Track(uri="dummy:similar2", name="Similar 2", genre="Jazz"),
|
|
|
|
|
]
|
|
|
|
|
core.library.search.return_value.get.return_value = [
|
|
|
|
|
SearchResult(uri="dummy:search", tracks=similar),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = build_instant_mix(core, "dummy:seed", limit=50)
|
|
|
|
|
|
|
|
|
|
assert len(result) == 2
|
|
|
|
|
assert result[0].uri == "dummy:similar1"
|
|
|
|
|
|
|
|
|
|
def test_excludes_seed_track(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
seed = Track(uri="dummy:seed", name="Seed Track", genre="Rock")
|
|
|
|
|
core.library.lookup.return_value.get.return_value = {
|
|
|
|
|
"dummy:seed": [seed],
|
|
|
|
|
}
|
|
|
|
|
same_as_seed = [Track(uri="dummy:seed", name="Seed Track", genre="Rock")]
|
|
|
|
|
core.library.search.return_value.get.return_value = [
|
|
|
|
|
SearchResult(uri="dummy:search", tracks=same_as_seed),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = build_instant_mix(core, "dummy:seed")
|
|
|
|
|
|
|
|
|
|
assert len(result) == 0
|
|
|
|
|
|
|
|
|
|
def test_empty_lookup_returns_empty(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
core.library.lookup.return_value.get.return_value = {}
|
|
|
|
|
result = build_instant_mix(core, "dummy:nonexistent")
|
|
|
|
|
assert result == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSaveSmartPlaylist:
|
|
|
|
|
def test_creates_new_playlist(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
core.playlists.lookup.return_value.get.return_value = None
|
|
|
|
|
saved = Playlist(
|
|
|
|
|
name="[Smart] Jazz Mix",
|
|
|
|
|
uri="mopidy:smartplaylists:jazz_mix",
|
|
|
|
|
tracks=[Track(uri="dummy:1", name="Track")],
|
|
|
|
|
)
|
|
|
|
|
core.playlists.save.return_value.get.return_value = saved
|
|
|
|
|
|
|
|
|
|
tracks = [Track(uri="dummy:1", name="Track")]
|
|
|
|
|
result = save_smart_playlist(core, "[Smart]", "Jazz Mix", tracks)
|
|
|
|
|
|
|
|
|
|
assert result == saved
|
|
|
|
|
core.playlists.save.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_deletes_existing_before_save(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
existing = Playlist(
|
|
|
|
|
name="[Smart] Old Jazz Mix",
|
|
|
|
|
uri="mopidy:smartplaylists:jazz_mix",
|
|
|
|
|
tracks=[],
|
|
|
|
|
)
|
|
|
|
|
core.playlists.lookup.return_value.get.return_value = existing
|
|
|
|
|
saved = Playlist(
|
|
|
|
|
name="[Smart] Jazz Mix",
|
|
|
|
|
uri="mopidy:smartplaylists:jazz_mix",
|
|
|
|
|
tracks=[Track(uri="dummy:1", name="Track")],
|
|
|
|
|
)
|
|
|
|
|
core.playlists.save.return_value.get.return_value = saved
|
|
|
|
|
|
|
|
|
|
tracks = [Track(uri="dummy:1", name="Track")]
|
|
|
|
|
result = save_smart_playlist(core, "[Smart]", "Jazz Mix", tracks)
|
|
|
|
|
|
|
|
|
|
assert result == saved
|
|
|
|
|
core.playlists.delete.assert_called_once_with(existing.uri)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRefreshSmartPlaylists:
|
|
|
|
|
def test_skips_when_no_recipes(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
config = {}
|
|
|
|
|
refresh_smart_playlists(core, config)
|
|
|
|
|
core.library.search.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_generates_decade_mixes(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
config = {"decades": "1980,1990", "playlist_prefix": "[Smart]"}
|
|
|
|
|
core.library.search.return_value.get.return_value = [
|
|
|
|
|
SearchResult(uri="dummy:search", tracks=[Track(uri="dummy:1", name="T")])
|
|
|
|
|
]
|
|
|
|
|
core.playlists.lookup.return_value.get.return_value = None
|
|
|
|
|
core.playlists.save.return_value.get.return_value = Playlist(
|
|
|
|
|
name="test", uri="dummy:playlist", tracks=[]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
refresh_smart_playlists(core, config)
|
|
|
|
|
|
|
|
|
|
assert core.library.search.call_count == 2
|
|
|
|
|
assert core.playlists.save.call_count == 2
|
|
|
|
|
|
|
|
|
|
def test_generates_genre_mixes(self):
|
|
|
|
|
core = mock.Mock()
|
|
|
|
|
config = {"genres": "Jazz,Rock", "playlist_prefix": "[Smart]"}
|
|
|
|
|
core.library.search.return_value.get.return_value = [
|
|
|
|
|
SearchResult(uri="dummy:search", tracks=[Track(uri="dummy:1", name="T")])
|
|
|
|
|
]
|
|
|
|
|
core.playlists.lookup.return_value.get.return_value = None
|
|
|
|
|
core.playlists.save.return_value.get.return_value = Playlist(
|
|
|
|
|
name="test", uri="dummy:playlist", tracks=[]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
refresh_smart_playlists(core, config)
|
|
|
|
|
|
|
|
|
|
assert core.library.search.call_count == 2
|
|
|
|
|
assert core.playlists.save.call_count == 2
|