feat: let a mood exclude tags, and widen the eighties one to the canon
Build and publish container / build (pull_request) Successful in 2m52s

The eighties mood selected on synth-specific tags only, on the grounds that the
bare `80s` tag drags in Def Leppard and Bon Jovi. Checking the canon against
live Last.fm pages shows what that costs. Eurythmics is tagged 80s, new wave,
pop, female vocalists, synth pop -- four of those five are tags no mood may use.
Frankie Goes to Hollywood is tagged 80s, new wave, pop, british, dance, and
survives only on new wave. An act tagged purely 80s, pop and dance, which is a
great deal of eighties pop, was missed outright.

Two spellings also matter more than the census suggested. Of Depeche Mode, Duran
Duran, Eurythmics and Frankie Goes to Hollywood, three carry "synth pop" with a
space and only one carries "synthpop" without. The census could not show this
because it only sees artists already in the library; the spelling that was kept
was the rarer one.

Add an `exclude` list to a mood. An excluded tag drops the artist outright
rather than docking their score, which is the only construction that expresses
"the eighties, but not the stadium rock" -- weighting cannot do it, because the
tag it would weight is the one both share. The synth acts do not carry hard rock
or hair metal, and that is the whole of the difference.

A mood may not both select on and exclude the same tag; that is rejected when
the definitions are read rather than silently producing nothing.
This commit is contained in:
Emma Thorpe
2026-08-24 18:39:59 +01:00
parent 41f6b290d8
commit 8c4da6e14e
3 changed files with 128 additions and 15 deletions
+26 -4
View File
@@ -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
View File
@@ -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 ?
"""
+61 -4
View File
@@ -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))