feat: let a mood exclude tags, and widen the eighties one to the canon
Build and publish container / build (pull_request) Successful in 2m52s
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:
@@ -188,7 +188,7 @@ measured, rather than from a general taxonomy:
|
|||||||
| `hair-metal` | hair metal, glam metal, glam rock, arena rock, AOR — 1975-1994 |
|
| `hair-metal` | hair metal, glam metal, glam rock, arena rock, AOR — 1975-1994 |
|
||||||
| `nu-metal` | nu metal, alternative metal, rapcore, industrial |
|
| `nu-metal` | nu metal, alternative metal, rapcore, industrial |
|
||||||
| `classic-rock` | classic rock, prog, psychedelic, blues rock, 70s, 60s |
|
| `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 |
|
| `indie` | indie, indie rock, indie pop, britpop, singer-songwriter |
|
||||||
|
|
||||||
Measuring first mattered. `synthwave`, `edm`, `big room` and `hardstyle` are
|
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
|
their own name. `green day`, `paramore` and `queen` are single-artist
|
||||||
playlists waiting to happen.
|
playlists waiting to happen.
|
||||||
|
|
||||||
`80s-synths` deliberately excludes the bare `80s` tag, which in this library
|
### Exclusions
|
||||||
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.
|
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
|
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
|
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",
|
"name": "80s-synths",
|
||||||
# Without the bare `80s` tag, which in this library sits on Def Leppard
|
# `80s` is included, which on its own would drag in Def Leppard and Bon
|
||||||
# and Bon Jovi rather than on anything with a synthesiser in it. The
|
# Jovi -- they carry it as heavily as Eurythmics does. The exclusion is
|
||||||
# year window is kept as a second filter, not as the only one.
|
# 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": [
|
"tags": [
|
||||||
"synthpop", "electropop", "new wave", "post-punk",
|
"80s", "new wave", "synth pop", "synthpop", "synth-pop", "synthwave",
|
||||||
"post-punk revival", "new romantic",
|
"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],
|
"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, -)")
|
raise ValueError(f"{path}: {name!r} is not a usable vibe name (a-z, 0-9, -)")
|
||||||
if not vibe.get("tags"):
|
if not vibe.get("tags"):
|
||||||
raise ValueError(f"{path}: vibe {name!r} lists no 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)
|
return tuple(loaded)
|
||||||
|
|
||||||
|
|
||||||
@@ -1464,8 +1482,24 @@ def build_vibe_playlists(store, vibes, mirror_root, library_root, limit, now):
|
|||||||
for vibe in vibes:
|
for vibe in vibes:
|
||||||
tags = [str(tag).strip().casefold() for tag in vibe["tags"]]
|
tags = [str(tag).strip().casefold() for tag in vibe["tags"]]
|
||||||
placeholders = ",".join("?" * len(tags))
|
placeholders = ",".join("?" * len(tags))
|
||||||
years = vibe.get("years")
|
|
||||||
parameters = [*tags, vibe.get("min_score", VIBE_MIN_SCORE)]
|
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 = ""
|
year_clause = ""
|
||||||
if years:
|
if years:
|
||||||
year_clause = (
|
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 lidarr_artist a ON a.id = t.artist_id
|
||||||
JOIN vibe v ON v.norm_artist = a.norm_name
|
JOIN vibe v ON v.norm_artist = a.norm_name
|
||||||
LEFT JOIN lidarr_album al ON al.id = t.album_id
|
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}
|
ORDER BY ((t.id * {SHUFFLE_MULTIPLIER}) + ?) % {SHUFFLE_MODULUS}
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1226,9 +1226,66 @@ def test_mood_names_are_unique():
|
|||||||
assert len(names) == len(set(names))
|
assert len(names) == len(set(names))
|
||||||
|
|
||||||
|
|
||||||
def test_the_eighties_mood_does_not_lean_on_the_bare_decade_tag():
|
ROCK_TAGS = {
|
||||||
"""In this library `80s` sits on Def Leppard and Bon Jovi, not on
|
"classic rock", "hard rock", "blues rock", "southern rock", "arena rock",
|
||||||
synthesisers. The hair-metal mood is where those belong."""
|
"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")
|
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]
|
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