feat(discover): suggest full albums only (filter singles/EPs)
Discovery's "Suggested albums" surfaced singles and EPs because _derive_albums filtered with is_core_release (Album/Single/EP). Add a discovery-only _album_kind_ok gate that, when discover.albumsOnly (new config, default true), requires primary_type == "Album" with no secondary types. is_core_release is untouched — the monitor/scan still use it. Exposed discover.albumsOnly in the discover config route + a Discovery settings toggle. Worker + web tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ const DEFAULTS: Record<string, string> = {
|
|||||||
"discover.intervalHours": "168",
|
"discover.intervalHours": "168",
|
||||||
"discover.similarPerSeed": "20",
|
"discover.similarPerSeed": "20",
|
||||||
"discover.albumsPerArtist": "1",
|
"discover.albumsPerArtist": "1",
|
||||||
|
"discover.albumsOnly": "true",
|
||||||
"discover.minScore": "0",
|
"discover.minScore": "0",
|
||||||
"discover.chunkSize": "5",
|
"discover.chunkSize": "5",
|
||||||
"discover.listenBrainzUrl": "",
|
"discover.listenBrainzUrl": "",
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ export function SettingsForm() {
|
|||||||
artists are processed per worker cycle (keeps job-claiming responsive on big rosters).
|
artists are processed per worker cycle (keeps job-claiming responsive on big rosters).
|
||||||
<b> Similar per seed</b> — candidates pulled per artist. <b>Albums per artist</b> —
|
<b> Similar per seed</b> — candidates pulled per artist. <b>Albums per artist</b> —
|
||||||
releases surfaced per suggested artist. <b>Min score</b> — drops weak suggestions.{" "}
|
releases surfaced per suggested artist. <b>Min score</b> — drops weak suggestions.{" "}
|
||||||
|
<b>Albums only</b> — suggest full-length albums only, hiding singles and EPs.{" "}
|
||||||
<b>ListenBrainz base URL</b> — leave blank for the default endpoint.
|
<b>ListenBrainz base URL</b> — leave blank for the default endpoint.
|
||||||
</HelpButton>
|
</HelpButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -405,6 +406,14 @@ export function SettingsForm() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="toggle" style={{ margin: "10px 0 0" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={discoverCfg["discover.albumsOnly"] !== "false"}
|
||||||
|
onChange={(e) => setCfg("discover.albumsOnly", e.target.checked ? "true" : "false")}
|
||||||
|
/>
|
||||||
|
Albums only (hide singles & EPs)
|
||||||
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>ListenBrainz base URL (blank = default)</span>
|
<span>ListenBrainz base URL (blank = default)</span>
|
||||||
<input value={discoverCfg["discover.listenBrainzUrl"] ?? ""}
|
<input value={discoverCfg["discover.listenBrainzUrl"] ?? ""}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class DiscoveryConfig:
|
|||||||
similar_per_seed: int = 20
|
similar_per_seed: int = 20
|
||||||
albums_per_artist: int = 1
|
albums_per_artist: int = 1
|
||||||
min_score: float = 0.0
|
min_score: float = 0.0
|
||||||
|
albums_only: bool = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config: dict) -> "DiscoveryConfig":
|
def from_config(cls, config: dict) -> "DiscoveryConfig":
|
||||||
@@ -32,6 +33,12 @@ class DiscoveryConfig:
|
|||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
def _bool(key: str, default: bool) -> bool:
|
||||||
|
raw = config.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return str(raw).strip().lower() in _TRUE
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
enabled=str(config.get("discover.enabled", "")).strip().lower() in _TRUE,
|
enabled=str(config.get("discover.enabled", "")).strip().lower() in _TRUE,
|
||||||
interval_hours=_int("discover.intervalHours", 168),
|
interval_hours=_int("discover.intervalHours", 168),
|
||||||
@@ -39,6 +46,7 @@ class DiscoveryConfig:
|
|||||||
similar_per_seed=_int("discover.similarPerSeed", 20),
|
similar_per_seed=_int("discover.similarPerSeed", 20),
|
||||||
albums_per_artist=_int("discover.albumsPerArtist", 1),
|
albums_per_artist=_int("discover.albumsPerArtist", 1),
|
||||||
min_score=_float("discover.minScore", 0.0),
|
min_score=_float("discover.minScore", 0.0),
|
||||||
|
albums_only=_bool("discover.albumsOnly", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -122,6 +130,18 @@ def _upsert_album(conn: psycopg.Connection, artist: dict, rg) -> int:
|
|||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def _album_kind_ok(g, albums_only: bool) -> bool:
|
||||||
|
"""Which release groups qualify as discovery album suggestions.
|
||||||
|
|
||||||
|
Discovery-only policy: when albums_only (default), suggest full studio Albums
|
||||||
|
only — no Singles/EPs, no secondary types (live/compilation/etc.). Falls back
|
||||||
|
to the broader is_core_release (Album/Single/EP) when the toggle is off. This
|
||||||
|
deliberately does NOT touch is_core_release, which the monitor/scan still use."""
|
||||||
|
if albums_only:
|
||||||
|
return (g.primary_type or "") == "Album" and len(g.secondary_types or ()) == 0
|
||||||
|
return is_core_release(g.primary_type, g.secondary_types)
|
||||||
|
|
||||||
|
|
||||||
def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[dict],
|
def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[dict],
|
||||||
cfg: DiscoveryConfig) -> int:
|
cfg: DiscoveryConfig) -> int:
|
||||||
if cfg.albums_per_artist <= 0:
|
if cfg.albums_per_artist <= 0:
|
||||||
@@ -135,7 +155,7 @@ def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[
|
|||||||
print(f"worker: discovery album browse failed for {artist['mbid']}: {e}", flush=True)
|
print(f"worker: discovery album browse failed for {artist['mbid']}: {e}", flush=True)
|
||||||
continue
|
continue
|
||||||
core = [g for g in groups
|
core = [g for g in groups
|
||||||
if is_core_release(g.primary_type, g.secondary_types) and g.rg_mbid not in have]
|
if _album_kind_ok(g, cfg.albums_only) and g.rg_mbid not in have]
|
||||||
core.sort(key=lambda g: g.first_release_date or "", reverse=True)
|
core.sort(key=lambda g: g.first_release_date or "", reverse=True)
|
||||||
for rg in core[: cfg.albums_per_artist]:
|
for rg in core[: cfg.albums_per_artist]:
|
||||||
albums += _upsert_album(conn, artist, rg)
|
albums += _upsert_album(conn, artist, rg)
|
||||||
|
|||||||
@@ -37,6 +37,33 @@ def test_album_skips_release_groups_already_in_library(conn):
|
|||||||
assert _album_rows(conn) == [("c1", "rg-fresh", "Fresh", "Album")] # owned rg excluded
|
assert _album_rows(conn) == [("c1", "rg-fresh", "Fresh", "Album")] # owned rg excluded
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_only_excludes_singles_and_eps_by_default(conn):
|
||||||
|
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||||
|
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||||
|
browser = FakeMbBrowser(releases={"c1": [
|
||||||
|
ReleaseGroupInfo("rg-single", "A Single", "Single", (), "2022-01-01"),
|
||||||
|
ReleaseGroupInfo("rg-ep", "An EP", "EP", (), "2021-01-01"),
|
||||||
|
ReleaseGroupInfo("rg-album", "A Record", "Album", (), "2020-01-01"),
|
||||||
|
]})
|
||||||
|
# albums_only defaults True: only the full-length Album is suggested
|
||||||
|
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=5))
|
||||||
|
assert _album_rows(conn) == [("c1", "rg-album", "A Record", "Album")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_albums_only_false_includes_singles_and_eps(conn):
|
||||||
|
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||||
|
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||||
|
browser = FakeMbBrowser(releases={"c1": [
|
||||||
|
ReleaseGroupInfo("rg-single", "A Single", "Single", (), "2022-01-01"),
|
||||||
|
ReleaseGroupInfo("rg-album", "A Record", "Album", (), "2020-01-01"),
|
||||||
|
]})
|
||||||
|
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=5, albums_only=False))
|
||||||
|
assert _album_rows(conn) == [
|
||||||
|
("c1", "rg-album", "A Record", "Album"),
|
||||||
|
("c1", "rg-single", "A Single", "Single"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_albums_per_artist_zero_derives_none(conn):
|
def test_albums_per_artist_zero_derives_none(conn):
|
||||||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||||
|
|||||||
Reference in New Issue
Block a user