Compare commits
9 Commits
e04c1c9795
...
947c86bc8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 947c86bc8f | |||
| a8c1600111 | |||
| 202fc0db3c | |||
| e8fd4bb0a9 | |||
| 194b4911c7 | |||
| efdd592761 | |||
| a09093f798 | |||
| e74d1d9c7b | |||
| 9dd235c2bc |
@@ -0,0 +1,247 @@
|
||||
# Discover Unified Rows + Newest/Most-Popular Albums — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Collapse the Discover Artists|Albums tabs into one artist-centric row that shows each similar artist's newest AND most-popular album inline (album-click → tracklist), with the artist name linking to the full page.
|
||||
|
||||
**Architecture:** Worker derives two albums per surfaced artist (newest = existing; most-popular = Last.fm `artist.getTopAlbums` matched into the browsed MB discography), tagging each `DiscoverySuggestion.albumReason`. The `/api/discover` route groups pending suggestions by artist into rows carrying `albums[]` + `seeds[]`. The client renders one row per artist with album thumbnails.
|
||||
|
||||
**Tech Stack:** Python 3 + psycopg (worker), Next.js 15 App Router + Prisma + vitest (web), Playwright (verify).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Popularity source is Last.fm `artist.getTopAlbums`; **graceful fallback to newest-only** when no `lastfm.api_key` or no match. No new required config.
|
||||
- `albumReason` values are exactly `"newest"`, `"popular"`, `"newest,popular"`; badge labels `Newest` / `Most played` / both; `null` → no badge.
|
||||
- Never break the sweep: any Last.fm/popularity error → treat as no popular pick.
|
||||
- Deploy is `docker compose up -d --build web worker` (never `down -v`). Tests: worker `lyra_test`, web `lyra_test`.
|
||||
- Reverts the D2 tab bar (intentional).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `albumReason` column + migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/prisma/schema.prisma` (DiscoverySuggestion model)
|
||||
- Create: `web/prisma/migrations/<ts>_add_discovery_album_reason/migration.sql` (via prisma)
|
||||
|
||||
- [ ] **Step 1:** Add `albumReason String?` to `model DiscoverySuggestion` with a comment (nullable, backward compatible; null → no badge).
|
||||
- [ ] **Step 2:** `cd web && DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx prisma migrate dev --name add_discovery_album_reason`. Expected: "Applying migration … add_discovery_album_reason".
|
||||
- [ ] **Step 3:** Commit (`feat(discover): add DiscoverySuggestion.albumReason`).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: worker Last.fm album-popularity helper
|
||||
|
||||
**Files:**
|
||||
- Create: `worker/lyra_worker/similarity/_lastfm_albums.py`
|
||||
- Modify: `worker/lyra_worker/registry.py` (add `build_album_popularity`)
|
||||
- Test: `worker/tests/test_lastfm_albums.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `LastfmAlbumPopularity(api_key, base_url=_BASE, timeout=15.0)` with
|
||||
`top_albums(conn, artist_name) -> list[[name:str, mbid:str|None, playcount:int]]`
|
||||
(playcount desc; cached in ApiCache key `lfm-artist-top-albums:{norm}`, 12h). Errors → `[]`.
|
||||
- Produces: `build_album_popularity(config, dsn) -> LastfmAlbumPopularity | None`
|
||||
(None when `lastfm.api_key` unset).
|
||||
|
||||
- [ ] **Step 1: failing test** — parse a canned `artist.gettopalbums` JSON body into
|
||||
`[[name, mbid, playcount], …]` ordered by playcount desc; a `dict` (single album) is
|
||||
wrapped; missing mbid → None; a body with `error` → `[]`. Inject the parse via a helper
|
||||
`_parse_top_albums(data)` so no network is needed. Also assert `build_album_popularity({}, None) is None`.
|
||||
- [ ] **Step 2:** run → FAIL (module missing).
|
||||
- [ ] **Step 3: implement.**
|
||||
|
||||
```python
|
||||
# _lastfm_albums.py
|
||||
import requests
|
||||
from lyra_worker.apicache import cached_api
|
||||
|
||||
_BASE = "https://ws.audioscrobbler.com/2.0/"
|
||||
|
||||
|
||||
def _parse_top_albums(data) -> list[list]:
|
||||
if isinstance(data.get("error"), (int, float)):
|
||||
return []
|
||||
rows = data.get("topalbums", {}).get("album", [])
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
out = []
|
||||
for r in rows:
|
||||
name = r.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
out.append([name, r.get("mbid") or None, int(r.get("playcount") or 0)])
|
||||
out.sort(key=lambda a: a[2], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
class LastfmAlbumPopularity:
|
||||
def __init__(self, api_key, base_url=_BASE, timeout: float = 15.0, limit: int = 25):
|
||||
self._key, self._base, self._timeout, self._limit = api_key, base_url, timeout, limit
|
||||
|
||||
def _fetch(self, artist_name: str) -> list[list]:
|
||||
try:
|
||||
res = requests.get(self._base, params={
|
||||
"method": "artist.gettopalbums", "artist": artist_name,
|
||||
"api_key": self._key, "format": "json", "limit": str(self._limit),
|
||||
"autocorrect": "1",
|
||||
}, timeout=self._timeout)
|
||||
return _parse_top_albums(res.json())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def top_albums(self, conn, artist_name: str) -> list[list]:
|
||||
norm = (artist_name or "").strip().lower()
|
||||
if not norm:
|
||||
return []
|
||||
return cached_api(conn, f"lfm-artist-top-albums:{norm}", 43200,
|
||||
lambda: self._fetch(artist_name))
|
||||
```
|
||||
|
||||
```python
|
||||
# registry.py — add
|
||||
from lyra_worker.similarity._lastfm_albums import LastfmAlbumPopularity
|
||||
|
||||
def build_album_popularity(config: dict, dsn: str | None = None):
|
||||
key = config.get("lastfm.api_key")
|
||||
return LastfmAlbumPopularity(api_key=key) if key else None
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** run → PASS.
|
||||
- [ ] **Step 5:** commit (`feat(discover): Last.fm album-popularity helper`).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `_derive_albums` picks newest + most-popular
|
||||
|
||||
**Files:**
|
||||
- Modify: `worker/lyra_worker/discovery.py` (`_derive_albums`, `_upsert_album`, `run_discovery`)
|
||||
- Test: `worker/tests/test_discovery_albums.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `popularity.top_albums(conn, name)` from Task 2 (or None).
|
||||
- Produces: `_derive_albums(conn, browser, surfaced, cfg, popularity=None)`; `run_discovery(conn, sources, browser, cfg, popularity=None)`; `_upsert_album(conn, artist, rg, reason)`.
|
||||
|
||||
- [ ] **Step 1: failing tests** (inject a fake popularity — no live Last.fm):
|
||||
- newest + most-popular → two album rows, `albumReason` `"newest"` / `"popular"`.
|
||||
- popular pick == newest rg → one row, `albumReason == "newest,popular"`.
|
||||
- `popularity=None` → newest-only, `albumReason == "newest"`.
|
||||
- popular match resolves by title when Last.fm mbid absent; skips owned/non-qualifying and falls to next-highest playcount; no match → newest-only.
|
||||
|
||||
```python
|
||||
class FakePopularity:
|
||||
def __init__(self, data): self._d = data # {artist_name: [[name, mbid, plays], …]}
|
||||
def top_albums(self, conn, name): return self._d.get(name, [])
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** run → FAIL.
|
||||
- [ ] **Step 3: implement.** In `_derive_albums`, after building `core` (qualifying un-owned, newest-first):
|
||||
|
||||
```python
|
||||
def _norm(s): return (s or "").strip().lower()
|
||||
|
||||
# newest picks (existing)
|
||||
picks = {} # rg_mbid -> reason
|
||||
for rg in core[: cfg.albums_per_artist]:
|
||||
picks[rg.rg_mbid] = "newest"
|
||||
|
||||
# most-popular pick via Last.fm, matched into `core`
|
||||
if popularity is not None and core:
|
||||
by_mbid = {g.rg_mbid: g for g in core}
|
||||
by_title = {_norm(g.title): g for g in core}
|
||||
for name, mbid, _plays in popularity.top_albums(conn, artist["name"]):
|
||||
g = (by_mbid.get(mbid) if mbid else None) or by_title.get(_norm(name))
|
||||
if g is None:
|
||||
continue
|
||||
picks[g.rg_mbid] = "newest,popular" if picks.get(g.rg_mbid) == "newest" else "popular"
|
||||
break
|
||||
|
||||
for rg in core:
|
||||
if rg.rg_mbid in picks:
|
||||
albums += _upsert_album(conn, artist, rg, picks[rg.rg_mbid])
|
||||
have.add(rg.rg_mbid)
|
||||
```
|
||||
|
||||
Add `reason` param to `_upsert_album` → include `"albumReason"` in the INSERT column list +
|
||||
`ON CONFLICT DO UPDATE SET … "albumReason" = EXCLUDED."albumReason"`. Thread `popularity`
|
||||
through `run_discovery` → `_derive_albums`.
|
||||
|
||||
- [ ] **Step 4:** run → PASS; then full worker suite green.
|
||||
- [ ] **Step 5:** commit (`feat(discover): derive newest + most-popular album per artist`).
|
||||
|
||||
---
|
||||
|
||||
### Task 4: wire popularity into the worker loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `worker/lyra_worker/main.py` (`_run_discovery` builds + passes popularity)
|
||||
- Test: existing `test_discovery_trigger.py` still green (popularity defaults None there).
|
||||
|
||||
- [ ] **Step 1:** In `main.py`, import `build_album_popularity`; in `_run_discovery` build it from `get_config(conn)` and pass to `run_discovery(conn, sources, browser, cfg, popularity=pop)`.
|
||||
- [ ] **Step 2:** run worker suite → PASS.
|
||||
- [ ] **Step 3:** commit (`feat(discover): supply Last.fm popularity to the sweep`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `/api/discover` groups by artist
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/app/api/discover/route.ts`
|
||||
- Test: `web/src/app/api/discover/route.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GET` returns `{ rows: [{ id, artistMbid, artistName, score, seedCount, sources, seeds, albums: [{ id, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate, albumReason }] }], search? }`. Rows ordered by score desc.
|
||||
|
||||
- [ ] **Step 1: failing test** — seed pending artist + its two album suggestions (`albumReason` newest/popular) sharing `artistMbid`; assert one row with `albums.length === 2`, ordered; seeds present; an album whose artist has no pending artist-suggestion still yields a row.
|
||||
- [ ] **Step 2:** run → FAIL.
|
||||
- [ ] **Step 3: implement.** Fetch all pending suggestions (as today) + the seeds map (as today). Group by `artistMbid`: artist-kind rows seed the row header (id/score/seedCount/sources); album-kind rows push into `albums[]` (include `albumReason`). For an `artistMbid` with only album rows, synthesize a header from the album's `artistName`/`artistMbid` (no `id`, score = max album score). Sort rows by score desc; sort each `albums[]` by `firstReleaseDate` desc. Return `{ rows }`.
|
||||
- [ ] **Step 4:** run → PASS.
|
||||
- [ ] **Step 5:** commit (`feat(discover): group suggestions into artist rows`).
|
||||
|
||||
---
|
||||
|
||||
### Task 6: dismiss cascade
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/app/api/discover/[id]/route.ts`
|
||||
- Test: `web/src/app/api/discover/[id]/route.test.ts`
|
||||
|
||||
- [ ] **Step 1: failing test** — dismissing an artist suggestion also marks its pending album suggestions (same `artistMbid`) dismissed; a dismissed album is not returned by `/api/discover`.
|
||||
- [ ] **Step 2:** run → FAIL.
|
||||
- [ ] **Step 3: implement.** In the dismiss branch, after dismissing the artist suggestion, `updateMany({ where: { kind: "album", artistMbid, status: "pending" }, data: { status: "dismissed" } })`. Follow/Want unchanged.
|
||||
- [ ] **Step 4:** run → PASS.
|
||||
- [ ] **Step 5:** commit (`fix(discover): dismissing an artist also dismisses its albums`).
|
||||
|
||||
---
|
||||
|
||||
### Task 7: unified-row client (drop tabs + ArtistModal)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/app/discover/discover-client.tsx`
|
||||
- Modify: `web/src/app/design.css` (row + thumb strip + badge styles)
|
||||
|
||||
- [ ] **Step 1:** Replace `Feed`/`tab` state with `rows` from `/api/discover` (`{ rows }`). Remove the tab bar, `ArtistModal` import + `artistModal` state + its JSX + the `isFullAlbum`/albums-tab code.
|
||||
- [ ] **Step 2:** Render one `<li class="list-row disco-row">` per row:
|
||||
- artist block: `<a href={`/discover/artist/${row.artistMbid}`}>{row.artistName}</a>`, `score`, `similarTo(row.seeds)` (keep existing helper).
|
||||
- album strip: `row.albums.map` → a `<button class="disco-thumb" onClick={() => setAlbumModal(album with artistMbid=row.artistMbid)}>`: `<CoverArt>` + title + badge (`badgeFor(albumReason)`) + a `Want` button (`act(album.id, "want")`).
|
||||
- row actions: `Follow` (`act(row.id, "follow")` when `row.id`, else follow-by-mbid via `POST /api/artists`), `Dismiss` (`act(row.id, "dismiss")`, hidden when no `row.id`).
|
||||
- `badgeFor(r)`: `newest`→`Newest`, `popular`→`Most played`, `newest,popular`→`Newest · Most played`, null→none.
|
||||
- [ ] **Step 3:** keep `AlbumModal` (thumb click) + `find-similar` search block (its result artist links become `<a href>` to the full page, no modal). Keep Discover-now/freshness header.
|
||||
- [ ] **Step 4:** CSS: `.disco-row` flex (artist block | thumb strip | actions), `.disco-thumb` (small cover + title + `.badge`), wraps under artist on narrow widths.
|
||||
- [ ] **Step 5:** `npx tsc --noEmit` + `npm test` + `npm run build` → all green.
|
||||
- [ ] **Step 6:** commit (`feat(discover): unified artist rows with inline album thumbnails`).
|
||||
|
||||
---
|
||||
|
||||
### Task 8: deploy + live verify
|
||||
|
||||
- [ ] **Step 1:** `docker compose up -d --build web worker`; confirm migration applied + all healthy.
|
||||
- [ ] **Step 2:** Playwright on `/discover`: rows render; artist name navigates to `/discover/artist/[mbid]`; album thumbnail opens `AlbumModal` with a tracklist; badges show.
|
||||
- [ ] **Step 3:** Trigger a library **Scan** (populates artistMbid) then **Discover now**; confirm `albumReason` populates (`Most played` badges appear) once a fresh sweep runs. (If a full sweep is impractical to force cheaply, rely on the injected-data Playwright check + unit tests, and note it.)
|
||||
- [ ] **Step 4:** Update `MEMORY.md` / project-state with the shipped feature + commit range.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** Part A → Tasks 2–4; Part B → Task 5; Part C dismiss → Task 6, client/modal → Task 7; schema → Task 1; tests throughout; live verify → Task 8. ✓
|
||||
- **Placeholders:** none — code shown for each implementing step. ✓
|
||||
- **Type consistency:** `top_albums(conn, name) -> list[[name, mbid|None, plays]]` used identically in Tasks 2–3; `_upsert_album(…, reason)` and `_derive_albums(…, popularity=None)` consistent; route emits `rows[]` consumed by Task 7. ✓
|
||||
@@ -0,0 +1,156 @@
|
||||
# Discover redesign: unified artist rows + newest/most-popular albums
|
||||
|
||||
**Date:** 2026-07-14
|
||||
**Status:** Approved (design), pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
`/discover` splits suggested artists and suggested albums into two tabs, but album
|
||||
suggestions are not independent — each one is just "a notable album by a suggested similar
|
||||
artist" (derived in `_derive_albums` from the surfaced artists). The two-tab split fragments a
|
||||
single idea. Two further gaps:
|
||||
|
||||
- Only the **newest** un-owned album is surfaced per similar artist; the album the artist is
|
||||
best known for (their **most popular**) is invisible unless it happens to be newest.
|
||||
- Seeing an album's tracklist requires an extra modal hop, and the artist modal on Discover is
|
||||
a lightweight duplicate of the richer full artist page.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Keep album suggestions artist-mediated (no album-level similarity data exists in our
|
||||
stack), but surface **two** albums per similar artist: **newest** and **most popular**.
|
||||
2. Collapse the Artists|Albums tabs into **one unified list** — one row per suggested artist,
|
||||
carrying that artist's album thumbnails inline.
|
||||
3. On Discover, replace the artist modal with a direct link to the full artist page; keep
|
||||
album-click → the existing `AlbumModal` (cover + tracklist + Want).
|
||||
|
||||
Non-goals: album-to-album similarity (no data source); collection-completion suggestions;
|
||||
changing the Last.fm page's artist modal (it keeps its personal most-played view).
|
||||
|
||||
## Data-source decision: popularity
|
||||
|
||||
- **Chosen:** Last.fm `artist.getTopAlbums` — returns an artist's albums ranked by playcount,
|
||||
each with a name and (usually) an MBID. The user already has Last.fm configured; no new
|
||||
config. Confirmed live: the method exists (error 10 "invalid key" with a bogus key, not
|
||||
error 3 "unknown method").
|
||||
- **Rejected:** ListenBrainz `/1/popularity/release-group` — MBID-native and cleaner in
|
||||
principle, but now requires a ListenBrainz auth token (anti-scraper), which is new config
|
||||
friction. MusicBrainz exposes no popularity signal at all.
|
||||
- **Fallback:** without Last.fm creds, or when no top album matches a qualifying un-owned
|
||||
release group, the artist yields **newest-only** — byte-for-byte today's behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### Part A — Album derivation (worker)
|
||||
|
||||
`_derive_albums(conn, browser, surfaced, cfg, popularity=None)` in
|
||||
`worker/lyra_worker/discovery.py`.
|
||||
|
||||
For each surfaced artist:
|
||||
1. Browse the artist's release groups from MB (already done) → filter to qualifying,
|
||||
un-owned albums (`_album_kind_ok` + not in `_existing_rg_mbids`) — unchanged.
|
||||
2. **Newest:** the newest `cfg.albums_per_artist` (default 1) by `first_release_date` desc —
|
||||
unchanged, tagged `reason="newest"`.
|
||||
3. **Most popular:** if `popularity` is available, fetch the artist's Last.fm top albums, and
|
||||
pick the highest-playcount album that maps to a qualifying un-owned release group in the
|
||||
browsed set — match by MBID first (when Last.fm supplies one that is in the browsed set),
|
||||
else by normalized title (`stripEditionQualifiers`-equivalent, lowercased/trimmed). Tag
|
||||
`reason="popular"`.
|
||||
4. If the popular pick is the same release group as a newest pick, do not insert a second
|
||||
row — the existing row's reason becomes `"newest,popular"`.
|
||||
5. Upsert each as a `DiscoverySuggestion` (kind `album`) as today, additionally writing
|
||||
`albumReason`.
|
||||
|
||||
**Popularity helper (injected, testable — mirrors `#16`'s `measure_duration`).** An object
|
||||
with `top_albums(artist_name) -> list[(name, mbid_or_None, playcount)]`, ordered by playcount
|
||||
desc. Built only when `lastfm.api_key` is set (worker decrypts via `crypto.py`); the fetch is
|
||||
wrapped in `cached_api` (`ApiCache`, ~12h TTL, key `lfm-artist-top-albums:{norm}`). Errors are
|
||||
swallowed → treated as "no popular pick" (newest-only). `run_discovery` constructs it from
|
||||
config and passes it to `_derive_albums`; tests inject a fake or `None`.
|
||||
|
||||
**Cost:** ≤1 Last.fm call per surfaced artist per sweep, cached; sweeps are infrequent and
|
||||
chunked. Only surfaced artists (post `min_score`) are queried.
|
||||
|
||||
### Part B — `/api/discover` groups suggestions by artist
|
||||
|
||||
`web/src/app/api/discover/route.ts` returns **rows** instead of two flat arrays. Build rows
|
||||
from the union of pending artist-suggestions and the distinct artists of pending
|
||||
album-suggestions (so an album is never orphaned), keyed by `artistMbid`:
|
||||
|
||||
```
|
||||
rows: [{
|
||||
id, // the artist suggestion id (for Follow/Dismiss), when present
|
||||
artistMbid, artistName,
|
||||
score, seedCount, sources,
|
||||
seeds: string[], // "why suggested" (existing DiscoverySeedContribution join)
|
||||
albums: [{
|
||||
id, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate,
|
||||
albumReason, // "newest" | "popular" | "newest,popular"
|
||||
}]
|
||||
}]
|
||||
```
|
||||
|
||||
Rows are ordered by `score` desc (artist suggestion score; album-only rows use the album's
|
||||
score). The `find-similar` search flow is unchanged (separate endpoint, no albums).
|
||||
|
||||
### Part C — dismiss cascade
|
||||
|
||||
`POST /api/discover/[id]` (dismiss): when the target is an **artist** suggestion, also mark
|
||||
its pending **album** suggestions (same `artistMbid`) dismissed, so the unified row disappears
|
||||
cleanly rather than leaving stray albums. Follow/Want are unchanged.
|
||||
|
||||
### Part D — `/discover` client (unified rows, no tabs, no modal)
|
||||
|
||||
`web/src/app/discover/discover-client.tsx`:
|
||||
- Remove the Artists|Albums tab bar and the `tab` state; render one `<ul>` of artist rows.
|
||||
- Remove `ArtistModal` import + usage entirely. The artist **name links** to
|
||||
`/discover/artist/{artistMbid}` (an `<a href>`; matches the existing full-page route).
|
||||
- Each row renders its `albums[]` as small `CoverArt` thumbnails, each with:
|
||||
- a **badge** from `albumReason`: `"newest"`→`Newest`, `"popular"`→`Most played`,
|
||||
`"newest,popular"`→both; `null`→no badge;
|
||||
- a **Want** action (`act(album.id, "want")`);
|
||||
- **click → `AlbumModal`** (kept) with the album's `rgMbid`/title/artist → cover + tracklist
|
||||
+ Want.
|
||||
- Row actions stay: **Follow** / **Dismiss** (on `row.id`, the artist suggestion). If a row
|
||||
has no artist suggestion id (album-only, e.g. artist dismissed earlier), Follow resolves by
|
||||
MBID via `POST /api/artists`; Dismiss is hidden for that (degenerate) case.
|
||||
- `find-similar` results keep their existing rendering, minus `ArtistModal` — their artist
|
||||
links also go to the full page (consistency; `ArtistModal` is being removed from this file).
|
||||
- New CSS for the row layout (artist block + horizontal album-thumb strip + actions),
|
||||
responsive: the thumb strip wraps/stacks under the artist on narrow viewports.
|
||||
|
||||
### Schema / migration
|
||||
|
||||
`DiscoverySuggestion.albumReason String?` — nullable, backward compatible. Existing album
|
||||
rows read as `null` (client treats null as no badge) until the next sweep re-derives them.
|
||||
Migration `add_discovery_album_reason`.
|
||||
|
||||
## Testing
|
||||
|
||||
**Worker (`test_discovery_albums.py`):**
|
||||
- newest + most-popular produce two rows with correct `albumReason`;
|
||||
- dedupe when newest == popular → one row, `reason="newest,popular"`;
|
||||
- no popularity helper → newest-only (unchanged), `reason="newest"`;
|
||||
- popular pick skips owned / non-qualifying release groups and falls to the next-highest
|
||||
playcount; no match → newest-only;
|
||||
- injected fake popularity (no live Last.fm).
|
||||
|
||||
**Web:**
|
||||
- `/api/discover` groups albums under their artist row; row carries `albums[]` + `seeds[]` +
|
||||
`albumReason`; album-only artist still yields a row.
|
||||
- dismiss cascade: dismissing an artist marks its album suggestions dismissed.
|
||||
- existing route tests updated to the grouped shape.
|
||||
|
||||
**Live verify (Playwright):** unified rows render; artist name navigates to the full page;
|
||||
album thumbnail opens `AlbumModal` with a tracklist; badges show; a fresh "Discover now" sweep
|
||||
populates `albumReason` (needs a re-scan first so owned-artist MBIDs exist — pre-existing
|
||||
caveat from `#18`).
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Last.fm top-album title↔MB release-group matching can miss (edition wording, localized
|
||||
titles); acceptable — a miss just means newest-only for that artist. Normalized-title
|
||||
matching + MBID-first keeps misses rare.
|
||||
- Adds a Last.fm dependency to the album step of the sweep; fully optional + cached, and the
|
||||
artist step already uses Last.fm as a similarity source.
|
||||
- Reverts the D2 tab bar shipped earlier the same day; intentional per this design.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DiscoverySuggestion" ADD COLUMN "albumReason" TEXT;
|
||||
@@ -171,6 +171,10 @@ model DiscoverySuggestion {
|
||||
score Float
|
||||
seedCount Int
|
||||
sources String[]
|
||||
// Why this album is suggested (kind='album' only): "newest" (the artist's newest un-owned
|
||||
// release), "popular" (their most-played per Last.fm), or "newest,popular" when they're the
|
||||
// same release. Null for artist suggestions / rows from before this column existed.
|
||||
albumReason String?
|
||||
status SuggestionStatus @default(pending)
|
||||
dedupeKey String @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -63,6 +63,20 @@ describe("discover action API", () => {
|
||||
expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("followed");
|
||||
});
|
||||
|
||||
it("dismissing an artist also dismisses its pending album suggestions", async () => {
|
||||
const a = await prisma.discoverySuggestion.create({
|
||||
data: { kind: "artist", artistMbid: "shared", artistName: "Cand", score: 1, seedCount: 1,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:shared:" },
|
||||
});
|
||||
const alb = await prisma.discoverySuggestion.create({
|
||||
data: { kind: "album", artistMbid: "shared", artistName: "Cand", rgMbid: "rgS", album: "Rec",
|
||||
albumReason: "newest", score: 1, seedCount: 1, sources: ["listenbrainz"],
|
||||
secondaryTypes: [], dedupeKey: "album:shared:rgS" },
|
||||
});
|
||||
await post(a.id, { action: "dismiss" });
|
||||
expect((await prisma.discoverySuggestion.findUnique({ where: { id: alb.id } }))!.status).toBe("dismissed");
|
||||
});
|
||||
|
||||
it("want upserts a monitored release from the suggestion and marks wanted", async () => {
|
||||
const s = await album("rgW");
|
||||
const res = await post(s.id, { action: "want" });
|
||||
|
||||
@@ -19,6 +19,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
if (action === "dismiss") {
|
||||
await prisma.discoverySuggestion.update({ where: { id }, data: { status: "dismissed" } });
|
||||
// Dismissing an artist also dismisses its album suggestions, so the whole unified row
|
||||
// disappears cleanly instead of leaving the artist's albums stranded.
|
||||
if (s.kind === "artist") {
|
||||
await prisma.discoverySuggestion.updateMany({
|
||||
where: { kind: "album", artistMbid: s.artistMbid, status: "pending" },
|
||||
data: { status: "dismissed" },
|
||||
});
|
||||
}
|
||||
return Response.json({ id, status: "dismissed" });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,51 +3,61 @@ import { prisma } from "@/lib/db";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("discover feed API", () => {
|
||||
it("returns pending artists and albums split, highest score first, excluding non-pending", async () => {
|
||||
it("groups pending suggestions into artist rows with their albums, highest score first", async () => {
|
||||
await prisma.discoverySuggestion.createMany({
|
||||
data: [
|
||||
{ kind: "artist", artistMbid: "a1", artistName: "Low", score: 0.2, seedCount: 1,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a1:" },
|
||||
{ kind: "artist", artistMbid: "a2", artistName: "High", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a2:" },
|
||||
{ kind: "album", artistMbid: "a3", artistName: "Band", rgMbid: "rg1", album: "Rec",
|
||||
score: 0.5, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a3:rg1" },
|
||||
{ kind: "artist", artistMbid: "a4", artistName: "Gone", score: 5, seedCount: 1,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a4:" },
|
||||
{ kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-new", album: "Latest",
|
||||
firstReleaseDate: "2022-01-01", albumReason: "newest", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-new" },
|
||||
{ kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-hit", album: "Big Hit",
|
||||
firstReleaseDate: "2018-01-01", albumReason: "popular", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-hit" },
|
||||
{ kind: "artist", artistMbid: "a3", artistName: "Gone", score: 5, seedCount: 1,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a3:" },
|
||||
],
|
||||
});
|
||||
const body = await (await GET()).json();
|
||||
expect(body.artists.map((a: any) => a.artistName)).toEqual(["High", "Low"]); // dismissed excluded
|
||||
expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]);
|
||||
expect(body.artists[0]).toMatchObject({ artistMbid: "a2", seedCount: 2, sources: ["listenbrainz"] });
|
||||
expect(body.rows.map((r: { artistName: string }) => r.artistName)).toEqual(["High", "Low"]); // dismissed excluded, score desc
|
||||
const high = body.rows[0];
|
||||
expect(high).toMatchObject({ id: expect.any(String), artistMbid: "a2", seedCount: 2 });
|
||||
// albums sorted newest-first, each carrying its reason
|
||||
expect(high.albums.map((a: { album: string; albumReason: string }) => [a.album, a.albumReason]))
|
||||
.toEqual([["Latest", "newest"], ["Big Hit", "popular"]]);
|
||||
expect(body.rows[1].albums).toEqual([]); // "Low" has no album suggestions
|
||||
});
|
||||
|
||||
it("annotates each suggestion with the followed-artist seeds that surfaced it", async () => {
|
||||
await prisma.watchedArtist.createMany({
|
||||
data: [
|
||||
{ mbid: "seed-am", name: "Arctic Monkeys" },
|
||||
{ mbid: "seed-cp", name: "Coldplay" },
|
||||
{ mbid: "seed-gone", name: "Unfollowed" },
|
||||
],
|
||||
it("yields a row for an album whose artist has no pending artist suggestion", async () => {
|
||||
await prisma.discoverySuggestion.create({
|
||||
data: { kind: "album", artistMbid: "orphan", artistName: "Orphan Band", rgMbid: "rgX",
|
||||
album: "Stray", albumReason: "newest", score: 0.4, seedCount: 1, sources: ["lastfm"],
|
||||
secondaryTypes: [], dedupeKey: "album:orphan:rgX" },
|
||||
});
|
||||
await prisma.discoverySuggestion.createMany({
|
||||
data: [
|
||||
{ kind: "artist", artistMbid: "cand1", artistName: "The Strokes", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:cand1:" },
|
||||
{ kind: "album", artistMbid: "cand1", artistName: "The Strokes", rgMbid: "rgX", album: "Room on Fire",
|
||||
score: 0.7, seedCount: 2, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:cand1:rgX" },
|
||||
],
|
||||
const body = await (await GET()).json();
|
||||
expect(body.rows).toHaveLength(1);
|
||||
expect(body.rows[0]).toMatchObject({ id: null, artistName: "Orphan Band" });
|
||||
expect(body.rows[0].albums[0].album).toBe("Stray");
|
||||
});
|
||||
|
||||
it("annotates rows with the followed-artist seeds that surfaced them", async () => {
|
||||
await prisma.watchedArtist.createMany({
|
||||
data: [{ mbid: "seed-am", name: "Arctic Monkeys" }, { mbid: "seed-cp", name: "Coldplay" }],
|
||||
});
|
||||
await prisma.discoverySuggestion.create({
|
||||
data: { kind: "artist", artistMbid: "cand1", artistName: "The Strokes", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:cand1:" },
|
||||
});
|
||||
await prisma.discoverySeedContribution.createMany({
|
||||
data: [
|
||||
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-am", score: 0.6, sources: ["listenbrainz"] },
|
||||
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-cp", score: 0.3, sources: ["listenbrainz"] },
|
||||
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "no-such-seed", score: 0.9, sources: ["listenbrainz"] },
|
||||
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "gone", score: 0.9, sources: ["listenbrainz"] },
|
||||
],
|
||||
});
|
||||
const body = await (await GET()).json();
|
||||
// highest-scoring seed first; an unresolved/unfollowed seed is dropped
|
||||
expect(body.artists[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]);
|
||||
expect(body.albums[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]); // album inherits its artist's seeds
|
||||
expect(body.rows[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]); // score desc, unfollowed dropped
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// One Discover row = a suggested artist plus that artist's suggested albums (newest /
|
||||
// most-played), since album suggestions are always derived from a surfaced artist. Pending
|
||||
// suggestions are grouped by artistMbid so the UI shows a single artist-centric row.
|
||||
type Row = {
|
||||
id: string | null; // the artist suggestion id (Follow/Dismiss); null if only albums exist
|
||||
artistMbid: string;
|
||||
artistName: string;
|
||||
score: number;
|
||||
seedCount: number;
|
||||
sources: string[];
|
||||
seeds: string[];
|
||||
albums: {
|
||||
id: string;
|
||||
rgMbid: string | null;
|
||||
album: string | null;
|
||||
primaryType: string | null;
|
||||
secondaryTypes: string[];
|
||||
firstReleaseDate: string | null;
|
||||
albumReason: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const rows = await prisma.discoverySuggestion.findMany({
|
||||
where: { status: "pending" },
|
||||
orderBy: [{ score: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
// "Why suggested": the followed artists (seeds) that surfaced each candidate. A suggestion's
|
||||
// candidate is its artistMbid (for both artist and album kinds — the album's artist is what
|
||||
// was found similar). Join the per-seed contributions to the current watched roster so an
|
||||
// unfollowed seed drops out. Highest-scoring seed first.
|
||||
// "Why suggested": the followed artists (seeds) that surfaced each candidate, from the
|
||||
// per-seed contributions joined to the current watched roster (an unfollowed seed drops out).
|
||||
const candidateMbids = [...new Set(rows.map((r) => r.artistMbid))];
|
||||
const contribs = candidateMbids.length
|
||||
? await prisma.discoverySeedContribution.findMany({
|
||||
@@ -23,28 +43,57 @@ export async function GET() {
|
||||
const seedsByCandidate = new Map<string, string[]>();
|
||||
for (const c of contribs) {
|
||||
const name = seedNames.get(c.seedMbid);
|
||||
if (!name) continue; // seed no longer followed
|
||||
if (!name) continue;
|
||||
const list = seedsByCandidate.get(c.candidateMbid) ?? [];
|
||||
if (!list.includes(name)) list.push(name);
|
||||
seedsByCandidate.set(c.candidateMbid, list);
|
||||
}
|
||||
|
||||
const map = (r: (typeof rows)[number]) => ({
|
||||
id: r.id,
|
||||
artistMbid: r.artistMbid,
|
||||
artistName: r.artistName,
|
||||
rgMbid: r.rgMbid,
|
||||
album: r.album,
|
||||
primaryType: r.primaryType,
|
||||
secondaryTypes: r.secondaryTypes,
|
||||
firstReleaseDate: r.firstReleaseDate,
|
||||
score: r.score,
|
||||
seedCount: r.seedCount,
|
||||
sources: r.sources,
|
||||
seeds: seedsByCandidate.get(r.artistMbid) ?? [],
|
||||
});
|
||||
return Response.json({
|
||||
artists: rows.filter((r) => r.kind === "artist").map(map),
|
||||
albums: rows.filter((r) => r.kind === "album").map(map),
|
||||
});
|
||||
// Group by artist. An artist suggestion sets the row header; album suggestions fill albums[].
|
||||
// An album whose artist has no pending artist suggestion still yields a header (never orphaned).
|
||||
const byArtist = new Map<string, Row>();
|
||||
const ensure = (r: (typeof rows)[number]): Row => {
|
||||
let row = byArtist.get(r.artistMbid);
|
||||
if (!row) {
|
||||
row = {
|
||||
id: null,
|
||||
artistMbid: r.artistMbid,
|
||||
artistName: r.artistName,
|
||||
score: r.score,
|
||||
seedCount: r.seedCount,
|
||||
sources: r.sources,
|
||||
seeds: seedsByCandidate.get(r.artistMbid) ?? [],
|
||||
albums: [],
|
||||
};
|
||||
byArtist.set(r.artistMbid, row);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
for (const r of rows) {
|
||||
const row = ensure(r);
|
||||
if (r.kind === "artist") {
|
||||
row.id = r.id;
|
||||
row.score = r.score;
|
||||
row.seedCount = r.seedCount;
|
||||
row.sources = r.sources;
|
||||
} else {
|
||||
row.albums.push({
|
||||
id: r.id,
|
||||
rgMbid: r.rgMbid,
|
||||
album: r.album,
|
||||
primaryType: r.primaryType,
|
||||
secondaryTypes: r.secondaryTypes,
|
||||
firstReleaseDate: r.firstReleaseDate,
|
||||
albumReason: r.albumReason,
|
||||
});
|
||||
row.score = Math.max(row.score, r.score); // album-only row scores by its best album
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of byArtist.values()) {
|
||||
row.albums.sort((a, b) => (b.firstReleaseDate ?? "").localeCompare(a.firstReleaseDate ?? ""));
|
||||
}
|
||||
const out = [...byArtist.values()].sort((a, b) => b.score - a.score);
|
||||
return Response.json({ rows: out });
|
||||
}
|
||||
|
||||
@@ -286,6 +286,17 @@ nav.contents .sep { flex: 1; }
|
||||
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
||||
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||
.rmeta.why { font-style: italic; color: var(--graphite); margin-top: 2px; }
|
||||
|
||||
/* Discover unified row: artist block | inline album thumbnails | actions */
|
||||
.disco-row { align-items: flex-start; flex-wrap: wrap; gap: 14px; }
|
||||
.disco-row .main { flex: 1 1 180px; }
|
||||
.disco-albums { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.disco-album { display: flex; flex-direction: column; gap: 4px; width: 92px; }
|
||||
.disco-thumb { display: flex; flex-direction: column; gap: 4px; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
|
||||
.disco-thumb .thumb { width: 92px; height: 92px; }
|
||||
.disco-album .da-title { font-size: 12px; line-height: 1.2; color: var(--ink); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.disco-album .badge { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--graphite); }
|
||||
.disco-album .btn.sm { padding: 2px 8px; }
|
||||
.sel-tools { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); }
|
||||
.album-card { position: relative; }
|
||||
|
||||
@@ -4,23 +4,28 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { PageHead } from "../_ui/page-head";
|
||||
import { timeAgo } from "../_ui/status";
|
||||
import { SectionHeader } from "../_ui/section-header";
|
||||
import { ArtistModal } from "../_ui/artist-modal";
|
||||
import { AlbumModal } from "../_ui/album-modal";
|
||||
import { CoverArt } from "../_ui/cover-art";
|
||||
import { toast } from "../_ui/toast";
|
||||
|
||||
type Suggestion = {
|
||||
type Album = {
|
||||
id: string;
|
||||
artistMbid: string;
|
||||
artistName: string;
|
||||
rgMbid: string | null;
|
||||
album: string | null;
|
||||
primaryType: string | null;
|
||||
secondaryTypes: string[];
|
||||
firstReleaseDate: string | null;
|
||||
albumReason: string | null;
|
||||
};
|
||||
type Row = {
|
||||
id: string | null;
|
||||
artistMbid: string;
|
||||
artistName: string;
|
||||
score: number;
|
||||
seedCount: number;
|
||||
sources: string[];
|
||||
seeds: string[];
|
||||
albums: Album[];
|
||||
};
|
||||
|
||||
/** "Similar to Radiohead, Coldplay +2" — the followed artists that surfaced this suggestion. */
|
||||
@@ -31,13 +36,12 @@ function similarTo(seeds: string[]): string | null {
|
||||
return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
|
||||
}
|
||||
|
||||
type Tab = "Artists" | "Albums";
|
||||
|
||||
/** Only full-length albums belong in Suggested albums — hide singles/EPs and
|
||||
* secondary-type releases (live/comp). New sweeps already filter server-side
|
||||
* (discover.albumsOnly); this also sweeps any pre-filter rows still in the DB. */
|
||||
function isFullAlbum(s: Suggestion): boolean {
|
||||
return (s.primaryType ?? "Album") === "Album" && (s.secondaryTypes?.length ?? 0) === 0;
|
||||
/** Human badge for why an album is surfaced. */
|
||||
function badgeFor(reason: string | null): string | null {
|
||||
if (reason === "newest") return "Newest";
|
||||
if (reason === "popular") return "Most played";
|
||||
if (reason === "newest,popular") return "Newest · Most played";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "just now" / "3h ago" / "2d ago" — timeAgo has no suffix, so add one (except "just now"). */
|
||||
@@ -46,14 +50,13 @@ function agoLabel(value: string): string {
|
||||
return !a || a === "just now" ? a || "recently" : `${a} ago`;
|
||||
}
|
||||
|
||||
type Feed = { artists: Suggestion[]; albums: Suggestion[] };
|
||||
type SearchResult = {
|
||||
seed: { mbid: string; name: string } | null;
|
||||
similar: { mbid: string; name: string; score: number }[];
|
||||
};
|
||||
|
||||
export function DiscoverClient() {
|
||||
const [feed, setFeed] = useState<Feed>({ artists: [], albums: [] });
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
@@ -61,18 +64,14 @@ export function DiscoverClient() {
|
||||
const [search, setSearch] = useState<SearchResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
|
||||
const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null);
|
||||
const [albumModal, setAlbumModal] = useState<Suggestion | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("Artists");
|
||||
const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null);
|
||||
|
||||
const albums = feed.albums.filter(isFullAlbum);
|
||||
|
||||
const loadFeed = useCallback(async () => {
|
||||
setFeed(await (await fetch("/api/discover")).json());
|
||||
const loadRows = useCallback(async () => {
|
||||
setRows((await (await fetch("/api/discover")).json()).rows ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeed();
|
||||
loadRows();
|
||||
fetch("/api/discover/run")
|
||||
.then((r) => r.json())
|
||||
.then((s: { result: string | null; requested: boolean; inProgress: boolean; lastRunAt: string | null }) => {
|
||||
@@ -80,7 +79,7 @@ export function DiscoverClient() {
|
||||
setLastRunAt(s.lastRunAt);
|
||||
setRunning(s.requested || s.inProgress);
|
||||
});
|
||||
}, [loadFeed]);
|
||||
}, [loadRows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
@@ -89,13 +88,12 @@ export function DiscoverClient() {
|
||||
setResult(s.result);
|
||||
setLastRunAt(s.lastRunAt);
|
||||
if (!s.requested && !s.inProgress) {
|
||||
// the sweep finished (neither queued nor in progress) — refresh the feed
|
||||
setRunning(false);
|
||||
loadFeed();
|
||||
loadRows();
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [running, loadFeed]);
|
||||
}, [running, loadRows]);
|
||||
|
||||
async function discoverNow() {
|
||||
await fetch("/api/discover/run", { method: "POST" });
|
||||
@@ -113,18 +111,26 @@ export function DiscoverClient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function followSimilar(a: { mbid: string; name: string }) {
|
||||
async function followByMbid(mbid: string, name: string) {
|
||||
const res = await fetch("/api/artists", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mbid: a.mbid, name: a.name }),
|
||||
});
|
||||
if (res.ok || res.status === 409) {
|
||||
body: JSON.stringify({ mbid, name }),
|
||||
}).catch(() => null);
|
||||
return !!res && (res.ok || res.status === 409);
|
||||
}
|
||||
|
||||
async function followSimilar(a: { mbid: string; name: string }) {
|
||||
if (await followByMbid(a.mbid, a.name)) {
|
||||
setFollowedSimilar((s) => new Set(s).add(a.mbid));
|
||||
toast(`Following ${a.name}`);
|
||||
} else {
|
||||
toast(`Couldn't follow ${a.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Suggestion actions key off a suggestion id. A row with no artist-suggestion id (album-only,
|
||||
// e.g. the artist was dismissed earlier) falls back to a plain follow-by-MBID.
|
||||
async function act(id: string, action: "follow" | "want" | "dismiss") {
|
||||
const res = await fetch(`/api/discover/${id}`, {
|
||||
method: "POST",
|
||||
@@ -136,7 +142,17 @@ export function DiscoverClient() {
|
||||
return;
|
||||
}
|
||||
toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed");
|
||||
loadFeed();
|
||||
loadRows();
|
||||
}
|
||||
|
||||
async function followRow(row: Row) {
|
||||
if (row.id) return act(row.id, "follow");
|
||||
if (await followByMbid(row.artistMbid, row.artistName)) {
|
||||
toast(`Following ${row.artistName}`);
|
||||
loadRows();
|
||||
} else {
|
||||
toast(`Couldn't follow ${row.artistName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -173,9 +189,7 @@ export function DiscoverClient() {
|
||||
<li key={s.mbid} className="list-row">
|
||||
<div className="main">
|
||||
<div className="rtitle">
|
||||
<button className="linkish" onClick={() => setArtistModal({ mbid: s.mbid, name: s.name })}>
|
||||
{s.name}
|
||||
</button>
|
||||
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>
|
||||
</div>
|
||||
<div className="rmeta">
|
||||
<span className="score">similarity {s.score.toFixed(1)}</span>
|
||||
@@ -195,96 +209,80 @@ export function DiscoverClient() {
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="tabs">
|
||||
<button className={`tab${tab === "Artists" ? " active" : ""}`} onClick={() => setTab("Artists")}>
|
||||
Suggested artists
|
||||
<span className="n">{feed.artists.length}</span>
|
||||
</button>
|
||||
<button className={`tab${tab === "Albums" ? " active" : ""}`} onClick={() => setTab("Albums")}>
|
||||
Suggested albums
|
||||
<span className="n">{albums.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "Artists" ? (
|
||||
feed.artists.length === 0 ? (
|
||||
<SectionHeader title="Suggested artists" note={`${rows.length}`} />
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted-note">No suggestions yet — run Discover, or follow a few artists to seed it.</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{feed.artists.map((s) => (
|
||||
<li key={s.id} className="list-row">
|
||||
{rows.map((row) => (
|
||||
<li key={row.artistMbid} className="list-row disco-row">
|
||||
<div className="main">
|
||||
<div className="rtitle">
|
||||
<button className="linkish" onClick={() => setArtistModal({ mbid: s.artistMbid, name: s.artistName })}>
|
||||
{s.artistName}
|
||||
</button>
|
||||
<a href={`/discover/artist/${row.artistMbid}?name=${encodeURIComponent(row.artistName)}`}>
|
||||
{row.artistName}
|
||||
</a>
|
||||
</div>
|
||||
<div className="rmeta">
|
||||
<span className="score">score {s.score.toFixed(2)}</span>
|
||||
<span className="score">score {row.score.toFixed(2)}</span>
|
||||
<span className="dot">·</span>
|
||||
<span>{s.seedCount} seed{s.seedCount === 1 ? "" : "s"}</span>
|
||||
<span className="dot">·</span>
|
||||
<span>{s.sources.join(", ")}</span>
|
||||
<span>{row.sources.join(", ")}</span>
|
||||
</div>
|
||||
{similarTo(s.seeds) ? <div className="rmeta why">{similarTo(s.seeds)}</div> : null}
|
||||
{similarTo(row.seeds) ? <div className="rmeta why">{similarTo(row.seeds)}</div> : null}
|
||||
</div>
|
||||
|
||||
{row.albums.length > 0 ? (
|
||||
<div className="disco-albums">
|
||||
{row.albums.map((a) => (
|
||||
<div key={a.id} className="disco-album">
|
||||
<button
|
||||
className="disco-thumb"
|
||||
onClick={() => setAlbumModal({ a, artistName: row.artistName, artistMbid: row.artistMbid })}
|
||||
aria-label={`Open ${a.album ?? ""}`}
|
||||
>
|
||||
<CoverArt rgMbid={a.rgMbid} alt={a.album ?? ""} className="thumb" />
|
||||
<span className="da-title">{a.album}</span>
|
||||
{badgeFor(a.albumReason) ? <span className="badge">{badgeFor(a.albumReason)}</span> : null}
|
||||
</button>
|
||||
<button className="btn sm" onClick={() => act(a.id, "want")}>
|
||||
Want
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="actions">
|
||||
<button className="btn sm" onClick={() => act(s.id, "follow")}>
|
||||
<button className="btn sm" onClick={() => followRow(row)}>
|
||||
Follow
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
) : albums.length === 0 ? (
|
||||
<p className="muted-note">No album suggestions yet.</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{albums.map((s) => (
|
||||
<li key={s.id} className="list-row">
|
||||
<div className="main">
|
||||
<button className="disco-open" onClick={() => setAlbumModal(s)} aria-label={`Open ${s.album ?? ""}`}>
|
||||
<CoverArt rgMbid={s.rgMbid} alt={s.album ?? ""} className="thumb" />
|
||||
<span>
|
||||
<span className="rtitle">
|
||||
{s.album} <span className="artist">· {s.artistName}</span>
|
||||
</span>
|
||||
<span className="rmeta">
|
||||
<span className="score">score {s.score.toFixed(2)}</span>
|
||||
</span>
|
||||
{similarTo(s.seeds) ? <span className="rmeta why">{similarTo(s.seeds)}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn sm" onClick={() => act(s.id, "want")}>
|
||||
Want
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
|
||||
Dismiss
|
||||
</button>
|
||||
{row.id ? (
|
||||
<button className="btn sm ghost" onClick={() => act(row.id!, "dismiss")}>
|
||||
Dismiss
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{artistModal ? (
|
||||
<ArtistModal open onClose={() => setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} />
|
||||
) : null}
|
||||
|
||||
{albumModal ? (
|
||||
<AlbumModal
|
||||
open
|
||||
onClose={() => setAlbumModal(null)}
|
||||
album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null, artistMbid: albumModal.artistMbid }}
|
||||
album={{
|
||||
rgMbid: albumModal.a.rgMbid,
|
||||
album: albumModal.a.album ?? "",
|
||||
artist: albumModal.artistName,
|
||||
year: albumModal.a.firstReleaseDate?.slice(0, 4) ?? null,
|
||||
type: albumModal.a.primaryType,
|
||||
artistMbid: albumModal.artistMbid,
|
||||
}}
|
||||
actions={
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
act(albumModal.id, "want");
|
||||
act(albumModal.a.id, "want");
|
||||
setAlbumModal(null);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -143,21 +143,21 @@ def _existing_rg_mbids(conn: psycopg.Connection) -> set[str]:
|
||||
return {r[0] for r in cur.fetchall()}
|
||||
|
||||
|
||||
def _upsert_album(conn: psycopg.Connection, artist: dict, rg) -> int:
|
||||
def _upsert_album(conn: psycopg.Connection, artist: dict, rg, reason: str) -> int:
|
||||
dedupe = f"album:{artist['mbid']}:{rg.rg_mbid}"
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", '
|
||||
'"rgMbid", album, "primaryType", "secondaryTypes", "firstReleaseDate", '
|
||||
'score, "seedCount", sources, status, "dedupeKey", "createdAt", "updatedAt") '
|
||||
"VALUES (gen_random_uuid()::text, 'album', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
|
||||
'score, "seedCount", sources, "albumReason", status, "dedupeKey", "createdAt", "updatedAt") '
|
||||
"VALUES (gen_random_uuid()::text, 'album', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
|
||||
"'pending', %s, now(), now()) "
|
||||
'ON CONFLICT ("dedupeKey") DO UPDATE SET score = EXCLUDED.score, '
|
||||
'"updatedAt" = now() '
|
||||
'"albumReason" = EXCLUDED."albumReason", "updatedAt" = now() '
|
||||
"WHERE \"DiscoverySuggestion\".status = 'pending'",
|
||||
(artist["mbid"], artist["name"], rg.rg_mbid, rg.title,
|
||||
rg.primary_type or None, list(rg.secondary_types), rg.first_release_date or None,
|
||||
artist["score"], artist["seed_count"], sorted(artist["sources"]), dedupe),
|
||||
artist["score"], artist["seed_count"], sorted(artist["sources"]), reason, dedupe),
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
@@ -174,8 +174,26 @@ def _album_kind_ok(g, albums_only: bool) -> bool:
|
||||
return is_core_release(g.primary_type, g.secondary_types)
|
||||
|
||||
|
||||
def _norm_title(s: str) -> str:
|
||||
return (s or "").strip().lower()
|
||||
|
||||
|
||||
def _popular_pick(conn, popularity, artist_name: str, core: list) -> str | None:
|
||||
"""The rg_mbid of the artist's most-played qualifying un-owned album (Last.fm), matched
|
||||
into the already-browsed `core` by MBID first then normalized title. None if unavailable."""
|
||||
if popularity is None or not core:
|
||||
return None
|
||||
by_mbid = {g.rg_mbid: g for g in core}
|
||||
by_title = {_norm_title(g.title): g for g in core}
|
||||
for name, mbid, _plays in popularity.top_albums(conn, artist_name):
|
||||
g = (by_mbid.get(mbid) if mbid else None) or by_title.get(_norm_title(name))
|
||||
if g is not None:
|
||||
return g.rg_mbid
|
||||
return None
|
||||
|
||||
|
||||
def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[dict],
|
||||
cfg: DiscoveryConfig) -> int:
|
||||
cfg: DiscoveryConfig, popularity=None) -> int:
|
||||
if cfg.albums_per_artist <= 0:
|
||||
return 0
|
||||
have = _existing_rg_mbids(conn)
|
||||
@@ -189,9 +207,21 @@ def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[
|
||||
core = [g for g in groups
|
||||
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)
|
||||
|
||||
# newest picks (existing behavior) + the single most-played album (Last.fm); merged so
|
||||
# an album that is both newest AND most-played is one row tagged "newest,popular".
|
||||
reasons: dict[str, str] = {}
|
||||
for rg in core[: cfg.albums_per_artist]:
|
||||
albums += _upsert_album(conn, artist, rg)
|
||||
have.add(rg.rg_mbid)
|
||||
reasons[rg.rg_mbid] = "newest"
|
||||
popular = _popular_pick(conn, popularity, artist["name"], core)
|
||||
if popular is not None:
|
||||
reasons[popular] = "newest,popular" if reasons.get(popular) == "newest" else "popular"
|
||||
|
||||
for rg in core: # preserve newest-first order among the chosen
|
||||
reason = reasons.get(rg.rg_mbid)
|
||||
if reason is not None:
|
||||
albums += _upsert_album(conn, artist, rg, reason)
|
||||
have.add(rg.rg_mbid)
|
||||
return albums
|
||||
|
||||
|
||||
@@ -276,7 +306,7 @@ def _recompute_suggestions(conn: psycopg.Connection, affected: set[str],
|
||||
|
||||
|
||||
def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource],
|
||||
browser: MbBrowser, cfg: DiscoveryConfig) -> DiscoveryResult:
|
||||
browser: MbBrowser, cfg: DiscoveryConfig, popularity=None) -> DiscoveryResult:
|
||||
"""Record each seed's similar-artist contributions, then recompute affected
|
||||
suggestions from the summed contributions — so a candidate's score is the sum of
|
||||
every seed's latest contribution, independent of chunk and sweep boundaries."""
|
||||
@@ -304,7 +334,7 @@ def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource],
|
||||
affected |= _record_contributions(conn, seed_mbid, live, known, cfg)
|
||||
|
||||
surfaced = _recompute_suggestions(conn, affected, cfg)
|
||||
albums = _derive_albums(conn, browser, surfaced, cfg)
|
||||
albums = _derive_albums(conn, browser, surfaced, cfg, popularity)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
for seed_mbid, _seed_name, source in seeds:
|
||||
|
||||
@@ -15,7 +15,7 @@ from lyra_worker.library import clear_staging_root
|
||||
from lyra_worker.monitor import MonitorConfig, fulfill_owned_releases, reconcile, sweep
|
||||
from lyra_worker.pipeline import run_pipeline
|
||||
from lyra_worker.registry import (
|
||||
build_adapters, build_browser, build_probe, build_resolver,
|
||||
build_adapters, build_album_popularity, build_browser, build_probe, build_resolver,
|
||||
build_similarity_sources, build_tagger,
|
||||
)
|
||||
from lyra_worker.scan import build_worklist, clear_worklist, scan_chunk
|
||||
@@ -115,8 +115,10 @@ def maybe_run_scan(conn, resolver, probe, browser, config, dest_root: str = DEST
|
||||
|
||||
|
||||
def _run_discovery(conn, sources, browser, config=None) -> DiscoveryResult:
|
||||
cfg = DiscoveryConfig.from_config(config if config is not None else get_config(conn))
|
||||
return run_discovery(conn, sources, browser, cfg)
|
||||
resolved = config if config is not None else get_config(conn)
|
||||
cfg = DiscoveryConfig.from_config(resolved)
|
||||
popularity = build_album_popularity(resolved)
|
||||
return run_discovery(conn, sources, browser, cfg, popularity=popularity)
|
||||
|
||||
|
||||
def finish_job(conn, job_id, mcfg) -> None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from lyra_worker.browser import MbBrowser
|
||||
from lyra_worker.probe import AudioProbe
|
||||
from lyra_worker.resolver import MbResolver
|
||||
from lyra_worker.similarity._lastfm import LastfmSource
|
||||
from lyra_worker.similarity._lastfm_albums import LastfmAlbumPopularity
|
||||
from lyra_worker.similarity._listenbrainz import ListenBrainzSource
|
||||
from lyra_worker.similarity.base import SimilaritySource
|
||||
from lyra_worker.tagger import Tagger
|
||||
@@ -67,3 +68,10 @@ def build_similarity_sources(config: dict, dsn: str | None = None) -> list[Simil
|
||||
if key:
|
||||
sources.append(LastfmSource(api_key=key, browser=build_browser(dsn)))
|
||||
return sources
|
||||
|
||||
|
||||
def build_album_popularity(config: dict, dsn: str | None = None):
|
||||
"""Last.fm album-popularity provider for discovery, or None when no Last.fm key is set
|
||||
(discovery then surfaces newest albums only)."""
|
||||
key = config.get("lastfm.api_key")
|
||||
return LastfmAlbumPopularity(api_key=key) if key else None
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import requests
|
||||
|
||||
from lyra_worker.apicache import cached_api
|
||||
|
||||
_BASE = "https://ws.audioscrobbler.com/2.0/"
|
||||
|
||||
|
||||
def _parse_top_albums(data) -> list[list]:
|
||||
"""Parse a Last.fm artist.gettopalbums response into [[name, mbid|None, playcount], …]
|
||||
ordered by playcount desc. A single-album body arrives as a dict; an error body → []."""
|
||||
if isinstance(data.get("error"), (int, float)):
|
||||
return []
|
||||
rows = data.get("topalbums", {}).get("album", [])
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
out: list[list] = []
|
||||
for r in rows:
|
||||
name = r.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
out.append([name, r.get("mbid") or None, int(r.get("playcount") or 0)])
|
||||
out.sort(key=lambda a: a[2], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
class LastfmAlbumPopularity:
|
||||
"""Ranks an artist's albums by Last.fm playcount, so discovery can surface each similar
|
||||
artist's most-played album (alongside their newest). Read-through cached in ApiCache; any
|
||||
failure yields [] so the sweep just falls back to newest-only."""
|
||||
|
||||
def __init__(self, api_key, base_url=_BASE, timeout: float = 15.0, limit: int = 25):
|
||||
self._key = api_key
|
||||
self._base = base_url
|
||||
self._timeout = timeout
|
||||
self._limit = limit
|
||||
|
||||
def _fetch(self, artist_name: str) -> list[list]:
|
||||
try:
|
||||
res = requests.get(self._base, params={
|
||||
"method": "artist.gettopalbums", "artist": artist_name,
|
||||
"api_key": self._key, "format": "json", "limit": str(self._limit),
|
||||
"autocorrect": "1",
|
||||
}, timeout=self._timeout)
|
||||
return _parse_top_albums(res.json())
|
||||
except Exception: # a Last.fm outage must never abort the sweep
|
||||
return []
|
||||
|
||||
def top_albums(self, conn, artist_name: str) -> list[list]:
|
||||
norm = (artist_name or "").strip().lower()
|
||||
if not norm:
|
||||
return []
|
||||
return cached_api(conn, f"lfm-artist-top-albums:{norm}", 43200,
|
||||
lambda: self._fetch(artist_name))
|
||||
@@ -12,6 +12,21 @@ def _album_rows(conn):
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def _reasons(conn):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT "rgMbid", "albumReason" FROM "DiscoverySuggestion" '
|
||||
'WHERE kind = \'album\' ORDER BY "rgMbid"')
|
||||
return dict(cur.fetchall())
|
||||
|
||||
|
||||
class FakePopularity:
|
||||
"""Maps artist NAME -> [[album_name, mbid_or_None, playcount], …] (Last.fm shape)."""
|
||||
def __init__(self, data):
|
||||
self._d = data
|
||||
def top_albums(self, conn, name):
|
||||
return self._d.get(name, [])
|
||||
|
||||
|
||||
def test_derives_most_recent_core_album_for_surfaced_artist(conn):
|
||||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||
@@ -37,6 +52,57 @@ def test_album_skips_release_groups_already_in_library(conn):
|
||||
assert _album_rows(conn) == [("c1", "rg-fresh", "Fresh", "Album")] # owned rg excluded
|
||||
|
||||
|
||||
def test_derives_newest_and_most_popular_albums(conn):
|
||||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||
browser = FakeMbBrowser(releases={"c1": [
|
||||
ReleaseGroupInfo("rg-hit", "Big Hit", "Album", (), "2005-01-01"),
|
||||
ReleaseGroupInfo("rg-new", "Latest", "Album", (), "2022-01-01"),
|
||||
]})
|
||||
pop = FakePopularity({"Cand": [["Big Hit", "rg-hit", 90000], ["Latest", "rg-new", 100]]})
|
||||
result = run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1), popularity=pop)
|
||||
assert result.albums == 2
|
||||
assert _reasons(conn) == {"rg-new": "newest", "rg-hit": "popular"}
|
||||
|
||||
|
||||
def test_newest_and_popular_the_same_album_is_one_row(conn):
|
||||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||
browser = FakeMbBrowser(releases={"c1": [
|
||||
ReleaseGroupInfo("rg-only", "Only", "Album", (), "2022-01-01"),
|
||||
]})
|
||||
pop = FakePopularity({"Cand": [["Only", "rg-only", 90000]]})
|
||||
result = run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1), popularity=pop)
|
||||
assert result.albums == 1
|
||||
assert _reasons(conn) == {"rg-only": "newest,popular"}
|
||||
|
||||
|
||||
def test_popular_matches_by_title_when_lastfm_mbid_missing_and_skips_owned(conn):
|
||||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||
insert_monitored_release(conn, artist_mbid="c1", rg_mbid="rg-owned", album="Owned Hit")
|
||||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||
browser = FakeMbBrowser(releases={"c1": [
|
||||
ReleaseGroupInfo("rg-owned", "Owned Hit", "Album", (), "2010-01-01"), # excluded (owned)
|
||||
ReleaseGroupInfo("rg-match", "Second Hit", "Album", (), "2012-01-01"),
|
||||
ReleaseGroupInfo("rg-new", "Latest", "Album", (), "2022-01-01"),
|
||||
]})
|
||||
# top album is owned (skipped), next has no mbid -> matched by title into core
|
||||
pop = FakePopularity({"Cand": [["Owned Hit", "rg-owned", 99999], ["Second Hit", None, 80000]]})
|
||||
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1), popularity=pop)
|
||||
assert _reasons(conn) == {"rg-new": "newest", "rg-match": "popular"}
|
||||
|
||||
|
||||
def test_no_popularity_provider_yields_newest_only(conn):
|
||||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||
browser = FakeMbBrowser(releases={"c1": [
|
||||
ReleaseGroupInfo("rg-old", "Old", "Album", (), "2005-01-01"),
|
||||
ReleaseGroupInfo("rg-new", "New", "Album", (), "2022-01-01"),
|
||||
]})
|
||||
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1)) # popularity=None
|
||||
assert _reasons(conn) == {"rg-new": "newest"}
|
||||
|
||||
|
||||
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)]})
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from lyra_worker.registry import build_album_popularity
|
||||
from lyra_worker.similarity._lastfm_albums import _parse_top_albums
|
||||
|
||||
|
||||
def test_parse_orders_by_playcount_and_keeps_mbids():
|
||||
data = {"topalbums": {"album": [
|
||||
{"name": "Older Hit", "mbid": "rg-old", "playcount": "5000"},
|
||||
{"name": "Newer", "mbid": "", "playcount": "9000"},
|
||||
{"name": "No Plays", "playcount": "0"},
|
||||
]}}
|
||||
assert _parse_top_albums(data) == [
|
||||
["Newer", None, 9000], # highest playcount first; empty mbid -> None
|
||||
["Older Hit", "rg-old", 5000],
|
||||
["No Plays", None, 0],
|
||||
]
|
||||
|
||||
|
||||
def test_parse_wraps_a_single_album_dict():
|
||||
data = {"topalbums": {"album": {"name": "Only One", "mbid": "rg1", "playcount": "3"}}}
|
||||
assert _parse_top_albums(data) == [["Only One", "rg1", 3]]
|
||||
|
||||
|
||||
def test_parse_returns_empty_on_error_body():
|
||||
assert _parse_top_albums({"error": 6, "message": "bad"}) == []
|
||||
|
||||
|
||||
def test_build_album_popularity_none_without_key():
|
||||
assert build_album_popularity({}) is None
|
||||
assert build_album_popularity({"lastfm.api_key": "abc"}) is not None
|
||||
Reference in New Issue
Block a user