diff --git a/README.md b/README.md index 7b8cde2..2fb209e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/music_curator.py b/music_curator.py index 7c15463..ec3c143 100644 --- a/music_curator.py +++ b/music_curator.py @@ -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 ? """ diff --git a/tests/test_music_curator.py b/tests/test_music_curator.py index 738e914..78e752f 100644 --- a/tests/test_music_curator.py +++ b/tests/test_music_curator.py @@ -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))