feat: mood playlists from Last.fm crowd tags
Build and publish container / build (pull_request) Successful in 3m50s
Build and publish container / build (pull_request) Successful in 3m50s
A second set of playlists selecting by genre and mood rather than by play history: eighties synths, high energy rock, screamo, drum and bass, dance, classic rock. The tags come from Last.fm rather than MusicBrainz. MusicBrainz genres arrive free with the Lidarr index, which makes them the obvious choice and the wrong one: they are sparse and formal, and will not tell you a record is screamo or synthwave. Crowd tags will, because people typed them. One request per artist, by MusicBrainz id where Lidarr has one, refreshed every ninety days. An artist Last.fm has never heard of is recorded as fetched with no tags rather than left unmarked, so it is not asked about again on every pass forever. The tag table is keyed on the normalised artist name, not the Lidarr id, so it survives an artist being removed and re-added there. Weights are taken from the response's count where it has one. The documented sample carries only a name and a URL, a live response also carries a 0-100 count, and depending on either alone would be a guess -- so the count is used when present and the documented ordering by popularity stands in when it is not. An artist qualifies for a mood when their weights inside it sum to at least thirty. A single low-weight tag is not a genre, it is somebody's stray opinion. A mood may also restrict release years, which is what separates eighties synth records from everything else a synthpop tag drags in. The built-in set is chosen for this library rather than as a taxonomy, and --vibes replaces it wholesale with a JSON file so a new mood does not need a new release. Names are validated when that file is read: an invalid one would otherwise only surface as a playlist written somewhere unintended.
This commit is contained in:
+6
-1
@@ -56,7 +56,9 @@ class FakeLastfm:
|
||||
is prepended to the first page with no `date`.
|
||||
"""
|
||||
|
||||
def __init__(self, tracks=(), loved=(), nowplaying=None, outcomes=()):
|
||||
def __init__(self, tracks=(), loved=(), nowplaying=None, outcomes=(), tags=None):
|
||||
# Keyed by mbid or by artist name, whichever the caller asked with.
|
||||
self.tags = tags or {}
|
||||
self.tracks = sorted(tracks, key=lambda track: int(track["date"]["uts"]), reverse=True)
|
||||
self.loved = list(loved)
|
||||
self.nowplaying = nowplaying
|
||||
@@ -82,6 +84,9 @@ class FakeLastfm:
|
||||
return json.dumps(self._recent(query))
|
||||
if method == "user.getlovedtracks":
|
||||
return json.dumps(self._loved(query))
|
||||
if method == "artist.gettoptags":
|
||||
key = query.get("mbid") or query.get("artist", "")
|
||||
return json.dumps({"toptags": {"tag": self.tags.get(key, [])}})
|
||||
raise AssertionError(f"unexpected method {method}")
|
||||
|
||||
def _recent(self, query):
|
||||
|
||||
@@ -983,3 +983,147 @@ def test_playlists_are_group_readable(tmp_path):
|
||||
|
||||
for playlist in (mirror / "_playlists").glob("*.m3u"):
|
||||
assert playlist.stat().st_mode & stat.S_IRGRP, playlist
|
||||
|
||||
|
||||
def test_tag_weights_fall_back_to_rank_when_no_count_is_sent():
|
||||
"""The documented sample carries only a name and a URL; a live response also
|
||||
carries a count. Neither may be relied on alone."""
|
||||
with_count = music_curator.parse_tags(
|
||||
{"toptags": {"tag": [{"name": "Screamo", "count": 100}, {"name": "emo", "count": 40}]}}
|
||||
)
|
||||
assert with_count == [("screamo", 100), ("emo", 40)]
|
||||
|
||||
without = music_curator.parse_tags(
|
||||
{"toptags": {"tag": [{"name": "screamo"}, {"name": "emo"}]}}
|
||||
)
|
||||
assert [tag for tag, _ in without] == ["screamo", "emo"]
|
||||
assert without[0][1] > without[1][1]
|
||||
|
||||
|
||||
def test_a_lone_tag_is_not_a_list():
|
||||
assert music_curator.parse_tags({"toptags": {"tag": {"name": "dnb", "count": 90}}}) == [
|
||||
("dnb", 90)
|
||||
]
|
||||
|
||||
|
||||
def test_an_artist_lastfm_cannot_answer_for_is_not_asked_again(tmp_path):
|
||||
"""Recording the fetch even when it returns nothing is what stops a pass
|
||||
spending a request per unknown artist, forever."""
|
||||
api, source, mirror = playlist_library(tmp_path)
|
||||
store = store_at(tmp_path)
|
||||
music_curator.index_library(
|
||||
music_curator.Lidarr("http://lidarr", "key", transport=api), store
|
||||
)
|
||||
lastfm = FakeLastfm(tags={})
|
||||
|
||||
assert music_curator.sync_tags(client_for(lastfm), store, NOW) == 2
|
||||
before = len(lastfm.calls)
|
||||
|
||||
assert music_curator.sync_tags(client_for(lastfm), store, NOW) == 0
|
||||
assert len(lastfm.calls) == before
|
||||
|
||||
|
||||
def test_tags_are_refetched_once_they_go_stale(tmp_path):
|
||||
api, source, mirror = playlist_library(tmp_path)
|
||||
store = store_at(tmp_path)
|
||||
music_curator.index_library(
|
||||
music_curator.Lidarr("http://lidarr", "key", transport=api), store
|
||||
)
|
||||
lastfm = FakeLastfm(tags={})
|
||||
music_curator.sync_tags(client_for(lastfm), store, NOW)
|
||||
|
||||
later = NOW + music_curator.TAG_REFRESH_SECONDS + 1
|
||||
assert music_curator.sync_tags(client_for(lastfm), store, later) == 2
|
||||
|
||||
|
||||
def tagged_store(tmp_path, tags):
|
||||
api, source, mirror = playlist_library(tmp_path)
|
||||
store = store_at(tmp_path)
|
||||
music_curator.index_library(
|
||||
music_curator.Lidarr("http://lidarr", "key", transport=api), store
|
||||
)
|
||||
music_curator.sync_tags(client_for(FakeLastfm(tags=tags)), store, NOW)
|
||||
return store, source, mirror
|
||||
|
||||
|
||||
def test_a_vibe_selects_by_tag(tmp_path):
|
||||
store, source, mirror = tagged_store(
|
||||
tmp_path,
|
||||
{
|
||||
"artist-mbid-1": [{"name": "screamo", "count": 100}],
|
||||
"artist-mbid-2": [{"name": "classic rock", "count": 100}],
|
||||
},
|
||||
)
|
||||
vibes = [{"name": "screamo", "tags": ["screamo"]}]
|
||||
|
||||
music_curator.build_vibe_playlists(store, vibes, mirror, str(source), 100, NOW)
|
||||
|
||||
written = (mirror / "_playlists" / "screamo.m3u").read_text()
|
||||
assert "Played Band" in written
|
||||
assert "Silent Band" not in written
|
||||
|
||||
|
||||
def test_a_weakly_tagged_artist_is_below_the_threshold(tmp_path):
|
||||
"""A single low-weight tag is not a genre, it is somebody's stray opinion."""
|
||||
store, source, mirror = tagged_store(
|
||||
tmp_path, {"artist-mbid-1": [{"name": "screamo", "count": 3}]}
|
||||
)
|
||||
vibes = [{"name": "screamo", "tags": ["screamo"]}]
|
||||
|
||||
music_curator.build_vibe_playlists(store, vibes, mirror, str(source), 100, NOW)
|
||||
|
||||
assert (mirror / "_playlists" / "screamo.m3u").read_text() == "#EXTM3U\n"
|
||||
|
||||
|
||||
def test_a_vibe_can_be_restricted_by_release_year(tmp_path):
|
||||
"""What separates eighties synth records from everything else a synthpop
|
||||
tag drags in."""
|
||||
store, source, mirror = tagged_store(
|
||||
tmp_path, {"artist-mbid-1": [{"name": "synthpop", "count": 100}]}
|
||||
)
|
||||
inside = [{"name": "eighties", "tags": ["synthpop"], "years": [1975, 1992]}]
|
||||
outside = [{"name": "nineties", "tags": ["synthpop"], "years": [1993, 1999]}]
|
||||
|
||||
# FakeLidarr dates every album 2019, so neither window should catch it.
|
||||
music_curator.build_vibe_playlists(store, inside, mirror, str(source), 100, NOW)
|
||||
music_curator.build_vibe_playlists(store, outside, mirror, str(source), 100, NOW)
|
||||
assert (mirror / "_playlists" / "eighties.m3u").read_text() == "#EXTM3U\n"
|
||||
assert (mirror / "_playlists" / "nineties.m3u").read_text() == "#EXTM3U\n"
|
||||
|
||||
modern = [{"name": "modern", "tags": ["synthpop"], "years": [2000, 2030]}]
|
||||
music_curator.build_vibe_playlists(store, modern, mirror, str(source), 100, NOW)
|
||||
assert "Played Band" in (mirror / "_playlists" / "modern.m3u").read_text()
|
||||
|
||||
|
||||
def test_the_built_in_vibes_are_all_usable_filenames():
|
||||
for vibe in music_curator.DEFAULT_VIBES:
|
||||
assert music_curator.SAFE_VIBE_NAME.fullmatch(vibe["name"]), vibe["name"]
|
||||
assert vibe["tags"]
|
||||
|
||||
|
||||
def test_a_vibes_file_replaces_the_built_in_set(tmp_path):
|
||||
path = tmp_path / "vibes.json"
|
||||
path.write_text(json.dumps([{"name": "mine", "tags": ["shoegaze"]}]))
|
||||
|
||||
assert music_curator.load_vibes(str(path)) == ({"name": "mine", "tags": ["shoegaze"]},)
|
||||
assert music_curator.load_vibes(None) is music_curator.DEFAULT_VIBES
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
'{"not": "a list"}',
|
||||
'[{"tags": ["x"]}]',
|
||||
'[{"name": "../escape", "tags": ["x"]}]',
|
||||
'[{"name": "ok"}]',
|
||||
"not json at all",
|
||||
],
|
||||
)
|
||||
def test_a_bad_vibes_file_is_refused_up_front(tmp_path, content):
|
||||
"""A bad name would otherwise surface as a file written somewhere
|
||||
unintended, which is a poor way to learn about a typo."""
|
||||
path = tmp_path / "vibes.json"
|
||||
path.write_text(content)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
music_curator.load_vibes(str(path))
|
||||
|
||||
Reference in New Issue
Block a user