feat: rebuild the moods from real tags, add exclusions, fix playlist ownership #12
@@ -188,7 +188,7 @@ measured, rather than from a general taxonomy:
|
||||
| `hair-metal` | hair metal, glam metal, glam rock, arena rock, AOR — 1975-1994 |
|
||||
| `nu-metal` | nu metal, alternative metal, rapcore, industrial |
|
||||
| `classic-rock` | classic rock, prog, psychedelic, blues rock, 70s, 60s |
|
||||
| `80s-synths` | synthpop, electropop, new wave, post-punk — 1975-1992 |
|
||||
| `80s-synths` | 80s, new wave, synth pop, electropop, post-punk — 1975-1992, rock excluded |
|
||||
| `indie` | indie, indie rock, indie pop, britpop, singer-songwriter |
|
||||
|
||||
Measuring first mattered. `synthwave`, `edm`, `big room` and `hardstyle` are
|
||||
@@ -205,9 +205,31 @@ Three kinds of tag are never used, and there is a test enforcing it:
|
||||
their own name. `green day`, `paramore` and `queen` are single-artist
|
||||
playlists waiting to happen.
|
||||
|
||||
`80s-synths` deliberately excludes the bare `80s` tag, which in this library
|
||||
sits on Def Leppard and Bon Jovi rather than on anything with a synthesiser in
|
||||
it. Those belong to `hair-metal`, which is why that mood now exists.
|
||||
### Exclusions
|
||||
|
||||
A mood may also list `exclude`. An excluded tag drops the artist outright rather
|
||||
than docking their score, and it exists because `80s-synths` cannot be written
|
||||
any other way.
|
||||
|
||||
`80s` is the eleventh most-played tag here, and it sits on Def Leppard and Bon
|
||||
Jovi exactly as heavily as on Eurythmics. Weighting cannot separate them,
|
||||
because the tag it would weight is the one they share. What does separate them
|
||||
is that the stadium rock also carries `hard rock` and `hair metal`, and the
|
||||
synth acts do not.
|
||||
|
||||
Checked against live Last.fm pages, since the tag census only sees artists
|
||||
already in the library:
|
||||
|
||||
| Artist | Tags |
|
||||
| --- | --- |
|
||||
| Eurythmics | `80s`, `new wave`, `pop`, `female vocalists`, `synth pop` |
|
||||
| Frankie Goes to Hollywood | `80s`, `new wave`, `pop`, `british`, `dance` |
|
||||
| Depeche Mode | `electronic`, `synthpop`, `new wave`, `80s`, `synth pop` |
|
||||
| Duran Duran | `new wave`, `80s`, `pop`, `synth pop`, `rock` |
|
||||
|
||||
Four of Eurythmics' five tags are ones no mood may use. Three of the four
|
||||
artists spell it **`synth pop`** with a space; only one spells it `synthpop`.
|
||||
Guessing one spelling would have missed most of the canon.
|
||||
|
||||
An artist qualifies when their tag weights inside a mood sum to at least 30 out
|
||||
of Last.fm's 0-100 scale. One low-weight tag is not a genre, it is somebody's
|
||||
|
||||
+41
-7
@@ -1320,12 +1320,23 @@ DEFAULT_VIBES = (
|
||||
},
|
||||
{
|
||||
"name": "80s-synths",
|
||||
# Without the bare `80s` tag, which in this library sits on Def Leppard
|
||||
# and Bon Jovi rather than on anything with a synthesiser in it. The
|
||||
# year window is kept as a second filter, not as the only one.
|
||||
# `80s` is included, which on its own would drag in Def Leppard and Bon
|
||||
# Jovi -- they carry it as heavily as Eurythmics does. The exclusion is
|
||||
# what separates them: the stadium rock is also tagged hard rock and
|
||||
# hair metal, and the synth acts are not. Weighting cannot do this,
|
||||
# because the tag it would weight is the one they share.
|
||||
#
|
||||
# Both spellings of synth pop are listed. Of Depeche Mode, Duran Duran,
|
||||
# Eurythmics and Frankie Goes to Hollywood, three carry "synth pop" with
|
||||
# a space and only one carries "synthpop" without.
|
||||
"tags": [
|
||||
"synthpop", "electropop", "new wave", "post-punk",
|
||||
"post-punk revival", "new romantic",
|
||||
"80s", "new wave", "synth pop", "synthpop", "synth-pop", "synthwave",
|
||||
"electropop", "new romantic", "post-punk", "post-punk revival",
|
||||
],
|
||||
"exclude": [
|
||||
"hard rock", "hair metal", "glam metal", "glam rock", "heavy metal",
|
||||
"metal", "arena rock", "aor", "nwobhm", "thrash metal",
|
||||
"classic rock", "southern rock", "blues rock",
|
||||
],
|
||||
"years": [1975, 1992],
|
||||
},
|
||||
@@ -1358,6 +1369,13 @@ def load_vibes(path):
|
||||
raise ValueError(f"{path}: {name!r} is not a usable vibe name (a-z, 0-9, -)")
|
||||
if not vibe.get("tags"):
|
||||
raise ValueError(f"{path}: vibe {name!r} lists no tags")
|
||||
overlap = {str(tag).casefold() for tag in vibe["tags"]} & {
|
||||
str(tag).casefold() for tag in vibe.get("exclude", [])
|
||||
}
|
||||
if overlap:
|
||||
raise ValueError(
|
||||
f"{path}: vibe {name!r} both selects on and excludes {sorted(overlap)}"
|
||||
)
|
||||
return tuple(loaded)
|
||||
|
||||
|
||||
@@ -1464,8 +1482,24 @@ def build_vibe_playlists(store, vibes, mirror_root, library_root, limit, now):
|
||||
for vibe in vibes:
|
||||
tags = [str(tag).strip().casefold() for tag in vibe["tags"]]
|
||||
placeholders = ",".join("?" * len(tags))
|
||||
years = vibe.get("years")
|
||||
parameters = [*tags, vibe.get("min_score", VIBE_MIN_SCORE)]
|
||||
|
||||
# An exclusion drops the artist outright rather than docking their
|
||||
# score. It is the only way to write "the eighties, but not the stadium
|
||||
# rock": those artists carry `80s` as heavily as the synth acts do, so
|
||||
# no amount of weighting separates them -- but they also carry `hard
|
||||
# rock`, and the synth acts do not.
|
||||
excluded = [str(tag).strip().casefold() for tag in vibe.get("exclude", [])]
|
||||
exclude_clause = ""
|
||||
if excluded:
|
||||
exclude_clause = (
|
||||
" AND NOT EXISTS (SELECT 1 FROM artist_tag x"
|
||||
" WHERE x.norm_artist = a.norm_name"
|
||||
f" AND x.tag IN ({','.join('?' * len(excluded))}))"
|
||||
)
|
||||
parameters += excluded
|
||||
|
||||
years = vibe.get("years")
|
||||
year_clause = ""
|
||||
if years:
|
||||
year_clause = (
|
||||
@@ -1488,7 +1522,7 @@ def build_vibe_playlists(store, vibes, mirror_root, library_root, limit, now):
|
||||
JOIN lidarr_artist a ON a.id = t.artist_id
|
||||
JOIN vibe v ON v.norm_artist = a.norm_name
|
||||
LEFT JOIN lidarr_album al ON al.id = t.album_id
|
||||
WHERE {PLAYABLE}{year_clause}
|
||||
WHERE {PLAYABLE}{exclude_clause}{year_clause}
|
||||
ORDER BY ((t.id * {SHUFFLE_MULTIPLIER}) + ?) % {SHUFFLE_MODULUS}
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
@@ -1226,9 +1226,66 @@ def test_mood_names_are_unique():
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
|
||||
def test_the_eighties_mood_does_not_lean_on_the_bare_decade_tag():
|
||||
"""In this library `80s` sits on Def Leppard and Bon Jovi, not on
|
||||
synthesisers. The hair-metal mood is where those belong."""
|
||||
ROCK_TAGS = {
|
||||
"classic rock", "hard rock", "blues rock", "southern rock", "arena rock",
|
||||
"glam rock", "hair metal", "glam metal", "heavy metal", "metal", "art rock",
|
||||
"psychedelic rock", "progressive rock", "rock and roll", "rock n roll",
|
||||
}
|
||||
|
||||
|
||||
def test_a_decade_tag_in_a_non_rock_mood_must_exclude_the_rock():
|
||||
"""`80s` sits on Def Leppard and Bon Jovi as heavily as on Eurythmics, so a
|
||||
mood that reaches for a decade without wanting rock has to say so. A mood
|
||||
that does want it -- classic-rock reaching for 70s -- is exempt."""
|
||||
for vibe in music_curator.DEFAULT_VIBES:
|
||||
tags = {tag.casefold() for tag in vibe["tags"]}
|
||||
decades = {"60s", "70s", "80s", "90s"} & tags
|
||||
if not decades or tags & ROCK_TAGS:
|
||||
continue
|
||||
excluded = {tag.casefold() for tag in vibe.get("exclude", [])}
|
||||
missing = {"hard rock", "hair metal"} - excluded
|
||||
assert not missing, f"{vibe['name']} selects on {decades} without excluding {missing}"
|
||||
|
||||
|
||||
def test_the_eighties_mood_covers_the_canon():
|
||||
"""Depeche Mode, Duran Duran, Eurythmics and Frankie Goes to Hollywood --
|
||||
checked against their live Last.fm tags. Three of the four carry "synth pop"
|
||||
with a space; only one carries it without."""
|
||||
synths = next(v for v in music_curator.DEFAULT_VIBES if v["name"] == "80s-synths")
|
||||
assert "80s" not in synths["tags"]
|
||||
tags = {t.casefold() for t in synths["tags"]}
|
||||
for artist_tags in (
|
||||
{"80s", "new wave", "pop", "female vocalists", "synth pop"}, # Eurythmics
|
||||
{"80s", "new wave", "pop", "british", "dance"}, # Frankie
|
||||
{"electronic", "synthpop", "new wave", "80s", "synth pop"}, # Depeche Mode
|
||||
{"new wave", "80s", "pop", "synth pop", "rock"}, # Duran Duran
|
||||
):
|
||||
assert tags & artist_tags, artist_tags
|
||||
assert synths["years"] == [1975, 1992]
|
||||
|
||||
|
||||
def test_an_excluded_tag_drops_the_artist(tmp_path):
|
||||
"""Weighting cannot separate the eighties synth acts from the eighties
|
||||
stadium rock, because the tag they would be weighted on is the one they
|
||||
share."""
|
||||
store, source, mirror = tagged_store(
|
||||
tmp_path,
|
||||
{
|
||||
"Played Band": [{"name": "80s", "count": 100}, {"name": "hard rock", "count": 90}],
|
||||
"Silent Band": [{"name": "80s", "count": 100}, {"name": "synth pop", "count": 90}],
|
||||
},
|
||||
)
|
||||
vibes = [{"name": "eighties", "tags": ["80s", "synth pop"], "exclude": ["hard rock"]}]
|
||||
|
||||
music_curator.build_vibe_playlists(store, vibes, mirror, str(source), 100, NOW)
|
||||
|
||||
written = (mirror / "_playlists" / "eighties.m3u").read_text()
|
||||
assert "Silent Band" in written
|
||||
assert "Played Band" not in written
|
||||
|
||||
|
||||
def test_a_vibe_cannot_both_select_and_exclude_a_tag(tmp_path):
|
||||
path = tmp_path / "vibes.json"
|
||||
path.write_text(json.dumps([{"name": "x", "tags": ["80s"], "exclude": ["80s"]}]))
|
||||
|
||||
with pytest.raises(ValueError, match="selects on and excludes"):
|
||||
music_curator.load_vibes(str(path))
|
||||
|
||||
Reference in New Issue
Block a user