Discover: listening-driven recommendations + mix (#913), Fresh Tape fix

#913 was silently producing 0 recs: similar_artists.source_artist_id is a SOURCE id (Spotify/etc.), but the scan keyed id->name by internal artists.id (resolved nothing), and the consensus ranker was fed the name-collapsed get_top_similar_artists (consensus could never fire). Fixed + elevated:

- id->name keyed by source-id columns; raw per-seed edges (real consensus); similarity_rank threaded into the score; recency-weighted seeds (recent plays boost lifetime favs)
- new 'Based On Your Listening' artist row (/api/discover/listening-recommendations) with 'because you listen to X' explanations
- new 'Your Listening Mix' track row: each rec's top tracks via a guarded, name-resolved Spotify/Deezer fetch (falls back to the discovery pool), stored as full render dicts so the row can't shrink on pool rotation
- pure tested core: similarity_from_rank, build_recency_weighted_seeds, to_mix_track, names_match (+ rank-aware grouping)

Fresh Tape (5-10 tracks): future-dated albums sorted to the top of get_discovery_recent_albums and ate the 50-album budget before the is_future_release skip ran. Add exclude_future_years + fetch a generous budget; downstream caps unchanged. Regression tested.

Also drop the per-track block 'X' from the compact playlist rows (wrong spot). Plan/audit in DISCOVER_BEST_IN_CLASS_PLAN.md.
This commit is contained in:
BoulderBadgeDad 2026-06-25 10:15:20 -07:00
parent 71aa3397bf
commit 9c91ba29bf
10 changed files with 826 additions and 53 deletions

View file

@ -0,0 +1,74 @@
# discover page — best in class plan (#913 + full generator audit)
morning notes. did the work overnight. tl;dr at top, details below, all of it `break nothing` + tested.
## what i shipped tonight (done, tested, safe)
### 1. listening recommendations (#913) — went from BROKEN to best-in-class
the feature was silently producing **zero** recs on real data. dug in and found three stacked bugs in the generation:
- **wrong id key (the killer).** `similar_artists.source_artist_id` is a *source* id (spotify/itunes/deezer), but the scanner built its id→name map from `artists.id` (the internal row id). so every edge resolved to nothing → 0 recs. proved it on your live db: internal-id join = 0 rows, spotify-id join = 71,636 rows.
- **consensus could never fire.** it fed the ranker `get_top_similar_artists`, which does `GROUP BY similar_artist_name` + `MAX(source_artist_id)` — collapsing every similar artist down to a *single* seed. the whole point of the ranker is "artist X is similar to 3 of your seeds = strong signal," and that signal was being flattened away before it ever reached the ranker.
- **similarity strength thrown away.** each edge stores a 1-10 closeness rank; it was ignored (everything weighted equally).
the fix (all in the pure, tested core + thin scan wiring):
- build id→name from the **source-id columns**, query the **raw per-seed edges** (consensus preserved), and thread **similarity_rank** into the score so a seed's closest matches count for more.
- **recency-weighted seeds**: `weight = lifetime_plays + 1.5 × recent_30d_plays`. picks now track what you're into *now*, not just all-time totals.
result on your actual library (simulated through the real code path): **40 recommendations, 13 with multi-seed consensus, all 40 with cached art.** top picks: Arcangel (Bad Bunny + Ozuna + J Balvin), Melanie Martinez (Ariana + Billie), Maluma, De La Ghetto — all coherent, all explainable.
### 2. its own row on the discover page
new row **"Based On Your Listening"** — play-weighted, consensus-ranked artist cards with a **"Because you listen to X, Y"** line. sits right above the library-driven "Recommended For You" row. purely additive: new endpoint `/api/discover/listening-recommendations`, new loader, hides itself when empty.
**you need to run one watchlist scan** for the row to populate (the data regenerates during the scan — i did NOT touch your live db). before that scan the row just stays hidden; after it, it fills in.
> note: this is deliberately different from the existing "Recommended For You" row. that one is driven by your *whole library / watchlist*. this one is driven by your *actual listening intensity* — the ~30 artists you really play, not the thousands you happen to own.
### 3. Fresh Tape "only 5-10 tracks" — fixed
root cause: `get_discovery_recent_albums` orders `release_date DESC`, so announced-but-unreleased albums sort to the *top* and ate the 50-album budget. the scanner skipped them *after* the budget was already spent → only a handful of released albums left → 5-10 tracks. fixed by fetching a generous budget (300) **and** excluding next-year albums at the query, so released albums fill the budget. the precise same-year `is_future_release` skip stays as a second guard. downstream caps (6/artist, top 75, take 50) unchanged.
**tests:** 25 pure-core cases (consensus/similarity/recency) + 2 Fresh Tape regression tests, all green. full discovery suite (255) green. nothing else touched.
---
## best-in-class roadmap for listening recs (next phases — your call)
these are the levers to take it further. ordered by value-to-risk. none are required; tonight's work stands on its own.
| phase | what | value | risk | notes |
|---|---|---|---|---|
| **3** | **playable track row** ✅ DONE | high | low-med | shipped: "🎧 Your Listening Mix" row — a track playlist (play/queue/download/sync) right under the artist row. stored as full render-ready dicts (not pool-hydrated, so it can't shrink on pool rotation like Fresh Tape does). |
| **4** | **direct top-tracks fetch** ✅ DONE | high | med | shipped: scan fetches each recommended artist's top tracks (Spotify/Deezer), resolving the artist id by name-search when the similar-artist row lacks one — guarded by a strict name-match so it never pulls the wrong artist. bounded (top 20 recs), per-call guarded, fail-soft to the pool. iTunes has no top-tracks API → pool-only there. needs a live scan to populate. |
| **5** | **genre-affinity boost** | med | low | we already compute your genre breakdown. boost recs whose genres match your top genres → tighter taste alignment. pure scoring add. |
| **6** | **adventurousness dial** | med | low | the ranker already supports `min_seed_count` (consensus floor). expose it as a "Safe ↔ Adventurous" slider on the row. |
| **7** | **diversity pass** | low-med | low | avoid 40 recs all orbiting your single heaviest seed — cap picks-per-seed so the row spans your taste. |
the core is built to absorb all of these without re-plumbing — `similarity_from_rank`, `build_recency_weighted_seeds`, and the scoring formula are all pure + tested.
---
## full discover-page generator audit (every soulsync-built row, excluding last.fm + listenbrainz)
how each one is generated today, and whether it can be elevated. "clear win" = safe + additive. "product call" = needs your decision (changes the row's character).
### curated (built during the scan, then hydrated)
- **Fresh Tape / Release Radar** — new releases from watchlist+similar artists. **FIXED tonight** (see above). one more *clear win* available: hydration silently drops any curated id no longer in the discovery pool — could fall back to the stored `track_data_json` blob so the row can't shrink at read time.
- **The Archives / Discovery Weekly** — strong already. nice 3-tier popularity split + serendipity scoring (boost never-played artists, penalize overplayed). same hydration-drop caveat as Fresh Tape; same cheap fallback fix.
- **Seasonal Mix** — cleanest of the bunch. hydrates from a dedicated `seasonal_tracks` table (carries its own data), so it doesn't suffer the pool-drop problem. no bug.
### discovery-pool generators (live queries)
- **Popular Picks** — ranks by popularity DESC. solid. only nit: on iTunes (no popularity scale) it silently degrades to random — indistinguishable from Shuffle there. UI-label thing at most.
- **Hidden Gems***clear win*. currently `ORDER BY RANDOM()` over low-popularity tracks — so it's "random obscure," not "*best* obscure." a light ranking (popularity just under the threshold, or genre-affinity to you) would make it feel curated instead of arbitrary. (a deeper *product call*: add personalization like Archives has — bigger lift, changes its "pure underground" character.)
- **Genre Playlists** — good. pushes the genre match into SQL. `RANDOM()` ordering is fine for a browse; a popularity/affinity tiebreak (*clear win*) would make thin genres feel less arbitrary.
- **Discovery Shuffle** — random by design, correct. only possible add: exclude tracks already shown in other rows this refresh (needs a cross-section seen-set — medium plumbing).
- **Time Machine (by decade)***clear win, low risk*: decades are hardcoded, so a modern-only library shows 7 decade tabs, 5 empty. filter the tabs to decades that actually have pool data.
- **Daily Mix** — the weakest row. the "50% your library" half permanently returns nothing (library tracks have no source ids to play), so each Daily Mix is really just a relabeled Genre Playlist. real fix = backfill source ids into library rows (*schema-level, higher risk*) — worth a dedicated pass, not a quick tweak. also silently falls back to "top artists as pseudo-genres" when genre data is missing → "Daily Mix 1" becomes mislabeled artist-radio. gate/label that (*clear win*).
### cross-cutting
- **hydration fragility** (Fresh Tape + Archives): both depend on curated ids still living in the pool at read time; misses are dropped silently. Seasonal already solved this with a dedicated table. giving the two spotify-style rows the same data-blob fallback is the single most robust cross-cutting fix. low risk, clear win.
- **RANDOM-ordering pattern** (Hidden Gems, Shuffle, Genre, Decade): intentional for variety, but leaves quality signal on the table for the non-shuffle rows. adding a light ranking pass to Hidden Gems + Genre is the biggest "best-in-class" lever after tonight's work.
want me to take any of these? the Hidden Gems ranking + Time Machine empty-decade filter + the Fresh Tape/Archives hydration fallback are all safe, additive, same-shape-as-tonight wins i can knock out next.

View file

@ -46,6 +46,67 @@ def _get(row: object, attr: str):
return getattr(row, attr, None) return getattr(row, attr, None)
def names_match(a: object, b: object) -> bool:
"""Strict artist-name equality after stripping case + non-alphanumerics.
Used to verify a name-search result before fetching that artist's top tracks, so the
"Listening Mix" can never pull the WRONG artist's songs (e.g. a same-name act). Exact
alphanumeric match: "Tyler, The Creator" == "Tyler The Creator", but "Drake" != "Drake Bell".
Pure.
"""
def _alnum(x: object) -> str:
return "".join(ch for ch in str(x or "").lower() if ch.isalnum())
na, nb = _alnum(a), _alnum(b)
return bool(na) and na == nb
def similarity_from_rank(rank: object, max_rank: int = 10) -> float:
"""Turn a stored ``similarity_rank`` (1 = most similar … 10 = least) into a 01 weight.
SoulSync stores each ``(seed similar)`` edge with a 110 rank (``1`` is the closest
match). The ranker multiplies this into the score so a seed's *closest* matches count
for more than its long-tail ones. Linear decay over the documented range: rank 1 1.0,
rank 5 0.6, rank 10 0.1, with a 0.1 floor so a far match still contributes. A
missing/garbage rank falls back to 1.0 (treat as "no rank info, full weight"). Pure.
"""
try:
r = int(rank)
except (TypeError, ValueError):
return 1.0
floor = round(1.0 / max_rank, 4)
if r <= 1:
return 1.0
if r >= max_rank:
return floor
return round((max_rank - r + 1) / max_rank, 4)
def build_recency_weighted_seeds(
top_artists: Sequence[dict],
recent_play_counts: Optional[Dict[str, float]] = None,
*,
recency_factor: float = 1.5,
) -> List[dict]:
"""Blend lifetime + recent play counts into seed weights — "what you're into NOW".
``weight = lifetime_plays + recency_factor × recent_plays``. An artist you've played a
lot *recently* outranks one you played a lot years ago, so the recommendations track
your current taste instead of your all-time history. ``recency_factor`` is the dial
(0 = pure lifetime). Returns ``[{'name', 'weight'}]`` for :func:`rank_recommended_artists`.
Pure the caller supplies both play-count maps from the listening history.
"""
recent = {_norm(k): _positive_float(v, 0.0) for k, v in (recent_play_counts or {}).items()}
out: List[dict] = []
for a in top_artists or ():
name = str(a.get("name") or "").strip()
if not name:
continue
lifetime = _positive_float(a.get("play_count", a.get("weight", 1.0)))
boost = recency_factor * recent.get(_norm(name), 0.0)
out.append({"name": name, "weight": lifetime + boost})
return out
def group_similars_by_seed( def group_similars_by_seed(
seeds: Sequence[dict], seeds: Sequence[dict],
similar_rows: Sequence, similar_rows: Sequence,
@ -53,14 +114,21 @@ def group_similars_by_seed(
*, *,
source_id_attr: str = "source_artist_id", source_id_attr: str = "source_artist_id",
similar_name_attr: str = "similar_artist_name", similar_name_attr: str = "similar_artist_name",
rank_attr: Optional[str] = None,
) -> Dict[str, List[dict]]: ) -> Dict[str, List[dict]]:
"""Reshape flat ``similar_artists`` rows into ``{seed_name_lower: [{'name': similar}]}``. """Reshape flat ``similar_artists`` rows into ``{seed_name_lower: [{'name', 'score'?}]}``.
The stored rows key the similar artist by the SEED's source id (``source_artist_id``), The stored rows key the similar artist by the SEED's source id (``source_artist_id``),
not its name, so :func:`rank_recommended_artists` can't consume them directly. This not its name, so :func:`rank_recommended_artists` can't consume them directly. This
resolves each row's source id to a name via ``id_to_name`` (``{source_artist_id: resolves each row's source id to a name via ``id_to_name`` (``{source_artist_id:
artist_name}`` for the library, built by the caller) and keeps only rows that resolve artist_name}`` for the library, built by the caller) and keeps only rows that resolve
to one of the ``seeds``. Rows may be dataclass objects or dicts. Pure no I/O. to one of the ``seeds``. Rows may be dataclass objects or dicts. Pure no I/O.
``id_to_name`` MUST be keyed by whatever id the edges actually store for SoulSync that
is the artist's SOURCE id (Spotify/iTunes/Deezer/MusicBrainz), NOT the internal row id.
When ``rank_attr`` is given, each row's rank is converted via :func:`similarity_from_rank`
and carried as ``score`` so closer matches weigh more; without it every similar comes out
score-less (the ranker then treats similarity as 1.0 original behavior).
""" """
seed_names = {_norm(s.get("name")) for s in seeds} seed_names = {_norm(s.get("name")) for s in seeds}
seed_names.discard("") seed_names.discard("")
@ -72,8 +140,12 @@ def group_similars_by_seed(
if not seed_name or seed_name not in seed_names: if not seed_name or seed_name not in seed_names:
continue continue
sim_name = str(_get(row, similar_name_attr) or "").strip() sim_name = str(_get(row, similar_name_attr) or "").strip()
if sim_name: if not sim_name:
out.setdefault(seed_name, []).append({"name": sim_name}) continue
entry = {"name": sim_name}
if rank_attr is not None:
entry["score"] = similarity_from_rank(_get(row, rank_attr))
out.setdefault(seed_name, []).append(entry)
return out return out
@ -197,8 +269,56 @@ def aggregate_candidate_tracks(
return out[:limit] return out[:limit]
def to_mix_track(track: object, source: str) -> Optional[dict]:
"""Shape one source "top tracks" API dict into the flat dict the Discover compact
playlist row renders + syncs (the "Listening Mix" #913 playlist).
Spotify's ``artist_top_tracks`` and Deezer's ``get_artist_top_tracks`` both return the
same Spotify-shape object (``id, name, artists[], album{name,images[]}, duration_ms``).
This flattens that into the renderer's field names (``track_name/artist_name/album_name/
album_cover_url/duration_ms``), keeps the original under ``track_data_json`` for sync, and
sets the source-specific id field. Returns None for anything without a usable id/title so
the caller can filter. A ``name`` key is kept so :func:`aggregate_candidate_tracks` can
dedup by title. Pure no I/O.
"""
if not isinstance(track, dict):
return None
tid = track.get("id")
name = str(track.get("name") or "").strip()
if not tid or not name:
return None
artists = track.get("artists") or []
artist_name = ""
if artists and isinstance(artists[0], dict):
artist_name = str(artists[0].get("name") or "").strip()
album = track.get("album") if isinstance(track.get("album"), dict) else {}
album_name = str(album.get("name") or "").strip()
images = album.get("images") or []
cover = images[0].get("url") if images and isinstance(images[0], dict) else None
out = {
"track_id": str(tid),
"name": name, # for aggregate_candidate_tracks dedup
"track_name": name, # for the renderer
"artist_name": artist_name,
"album_name": album_name,
"album_cover_url": cover,
"duration_ms": track.get("duration_ms") or 0,
"track_data_json": track, # full payload for sync/download
"source": source,
}
id_field = {"spotify": "spotify_track_id", "deezer": "deezer_track_id",
"itunes": "itunes_track_id"}.get(source)
if id_field:
out[id_field] = str(tid)
return out
__all__ = [ __all__ = [
"RecommendedArtist", "RecommendedArtist",
"names_match",
"similarity_from_rank",
"build_recency_weighted_seeds",
"to_mix_track",
"group_similars_by_seed", "group_similars_by_seed",
"rank_recommended_artists", "rank_recommended_artists",
"aggregate_candidate_tracks", "aggregate_candidate_tracks",

View file

@ -3633,8 +3633,13 @@ class WatchlistScanner:
for source in sources_to_process: for source in sources_to_process:
logger.info(f"Curating Release Radar for {source}...") logger.info(f"Curating Release Radar for {source}...")
# 1. Curate Release Radar - 50 tracks from recent albums # 1. Curate Release Radar - 50 tracks from recent albums.
recent_albums = self.database.get_discovery_recent_albums(limit=50, source=source, profile_id=profile_id) # Fetch a GENEROUS album budget (not 50) and exclude next-year announcements at the
# query: future-dated albums sort to the top of release_date DESC and used to eat the
# budget before the in-loop is_future_release skip ran, starving Fresh Tape to a few
# tracks. The downstream caps (6/artist, top 75, take 50) still bound the output.
recent_albums = self.database.get_discovery_recent_albums(
limit=300, source=source, profile_id=profile_id, exclude_future_years=True)
release_radar_tracks = [] release_radar_tracks = []
if not recent_albums: if not recent_albums:
@ -4004,45 +4009,129 @@ class WatchlistScanner:
The ranking lives in core.discovery.listening_recommendations (pure + tested); this only The ranking lives in core.discovery.listening_recommendations (pure + tested); this only
gathers inputs all already in the DB, NO new network and stores the result under NEW gathers inputs all already in the DB, NO new network and stores the result under NEW
metadata/curated keys. Fully self-contained and guarded: any failure logs and returns, so metadata/curated keys. Fully self-contained and guarded: any failure logs and returns, so
it can never disturb the scan. Phase-1 candidate tracks come from the discovery pool (like it can never disturb the scan.
BYLT); a later phase swaps in a direct top-tracks fetch for richer coverage.
"Best in class" generation (the elevation over the first cut):
Seeds are recency-weighted recent plays boost lifetime favourites so the picks
track what you're into NOW, not just all-time totals.
The ranker is fed the RAW per-seed edges (one row per seedsimilar), so an artist
similar to several of your seeds accumulates real CONSENSUS the old code fed the
name-collapsed ``get_top_similar_artists`` query, which flattened every similar to a
single seed (consensus could never fire). The raw edges also carry ``similarity_rank``,
so a seed's CLOSEST matches outweigh its long-tail ones.
``source_artist_id`` is a SOURCE id (Spotify/iTunes/Deezer/MusicBrainz), so the idname
map is built from the artists' source-id columns, NOT the internal row id (the first
cut keyed it by ``artists.id`` and resolved nothing the feature produced 0 recs).
Phase-1 candidate tracks still come from the discovery pool (like BYLT); a later phase
swaps in a direct top-tracks fetch for richer coverage.
""" """
try: try:
import json as _json import json as _json
from core.discovery.listening_recommendations import ( from core.discovery.listening_recommendations import (
aggregate_candidate_tracks, aggregate_candidate_tracks,
build_recency_weighted_seeds,
group_similars_by_seed, group_similars_by_seed,
names_match,
rank_recommended_artists, rank_recommended_artists,
to_mix_track,
) )
seeds = [{'name': s['name'], 'weight': s.get('play_count', 1)} # Recency-weighted seeds: lifetime top artists, boosted by recent (30d) plays.
for s in (self.database.get_top_artists('all', 30) or []) if s.get('name')] lifetime = [s for s in (self.database.get_top_artists('all', 30) or []) if s.get('name')]
if not seeds: if not lifetime:
return return
recent_rows = self.database.get_top_artists('30d', 50) or []
recent_counts = {r['name'].lower(): r.get('play_count', 0)
for r in recent_rows if r.get('name')}
seeds = build_recency_weighted_seeds(lifetime, recent_counts)
seed_names = {s['name'].lower() for s in seeds}
# id -> name + owned-artist set for the WHOLE library (similar_artists rows key the # Owned-artist set (for exclusion) + the seeds' SOURCE ids (similar_artists.source_artist_id
# similar artist by the seed artist's id). # is a Spotify/iTunes/Deezer/MusicBrainz id, never the internal artists.id). We only need
id_to_name, owned = {}, set() # id→name for the SEED ids, since the edge query below is already scoped to them.
owned, seed_source_ids, seed_id_to_name = set(), [], {}
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT id, name FROM artists WHERE name IS NOT NULL AND name != ''") cur.execute("SELECT name, spotify_artist_id, itunes_artist_id, deezer_id, "
"musicbrainz_id FROM artists WHERE name IS NOT NULL AND name != ''")
for row in cur.fetchall(): for row in cur.fetchall():
id_to_name[str(row[0])] = row[1] nm = row[0]
owned.add((row[1] or '').lower()) lname = (nm or '').lower()
owned.add(lname)
if lname in seed_names:
for sid in (row[1], row[2], row[3], row[4]):
if sid:
seed_source_ids.append(str(sid))
seed_id_to_name[str(sid)] = nm
# RAW per-seed edges (preserve consensus + similarity_rank). Scoped to the seeds.
edges, edge_cols = [], ('source_artist_id', 'similar_artist_name', 'similarity_rank',
'spotify_id', 'itunes_id', 'deezer_id', 'image_url', 'genres')
if seed_source_ids:
placeholders = ",".join("?" * len(seed_source_ids))
cur.execute(
f"SELECT source_artist_id, similar_artist_name, similarity_rank, "
f"similar_artist_spotify_id, similar_artist_itunes_id, "
f"similar_artist_deezer_id, image_url, genres FROM similar_artists "
f"WHERE profile_id = ? AND source_artist_id IN ({placeholders})",
[profile_id, *seed_source_ids])
edges = [dict(zip(edge_cols, r, strict=False)) for r in cur.fetchall()]
# Per-name enrichment (image/ids/genres) so the Discover row can render rich cards.
artist_meta_by_name = {}
for e in edges:
key = (e['similar_artist_name'] or '').lower()
if not key:
continue
m = artist_meta_by_name.setdefault(key, {})
for src_k, dst_k in (('spotify_id', 'spotify_artist_id'),
('itunes_id', 'itunes_artist_id'),
('deezer_id', 'deezer_artist_id')):
if e.get(src_k) and not m.get(dst_k):
m[dst_k] = e[src_k]
if e.get('image_url') and not m.get('image_url'):
m['image_url'] = e['image_url']
if e.get('genres') and not m.get('genres'):
m['genres'] = e['genres']
similars_by_seed = group_similars_by_seed(
seeds, edges, seed_id_to_name, rank_attr='similarity_rank')
# Fallback: if the raw edges resolved nothing (e.g. source ids not yet populated),
# degrade to the aggregated query so the feature still works rather than going dark.
if not similars_by_seed:
agg = self.database.get_top_similar_artists(limit=1000, profile_id=profile_id)
similars_by_seed = group_similars_by_seed(
seeds, agg, seed_id_to_name, rank_attr='similarity_rank')
similar_rows = self.database.get_top_similar_artists(limit=1000, profile_id=profile_id)
similars_by_seed = group_similars_by_seed(seeds, similar_rows, id_to_name)
recs = rank_recommended_artists(seeds, similars_by_seed, owned, limit=40) recs = rank_recommended_artists(seeds, similars_by_seed, owned, limit=40)
if not recs: if not recs:
logger.info("[Listening Recs] no recommendations yet (no similar-artist coverage)") logger.info("[Listening Recs] no recommendations yet (no similar-artist coverage)")
return return
self.database.set_metadata('listening_recs_artists', _json.dumps([ def _enrich(r):
{'name': r.name, 'seed_count': r.seed_count, 'seeds': r.seeds[:5], 'score': r.score} m = artist_meta_by_name.get(r.name.lower(), {})
for r in recs genres = m.get('genres')
])) if isinstance(genres, str):
try:
genres = _json.loads(genres)
except Exception:
genres = None
return {'name': r.name, 'seed_count': r.seed_count, 'seeds': r.seeds[:5],
'score': r.score, 'spotify_artist_id': m.get('spotify_artist_id'),
'itunes_artist_id': m.get('itunes_artist_id'),
'deezer_artist_id': m.get('deezer_artist_id'),
'image_url': m.get('image_url'),
'genres': (genres[:3] if isinstance(genres, list) else None)}
# Candidate tracks from the discovery pool grouped by artist (phase 1: no new network). self.database.set_metadata('listening_recs_artists',
_json.dumps([_enrich(r) for r in recs]))
# Candidate tracks for the "Listening Mix" playlist row: each recommended artist's
# top tracks. Prefer a DIRECT top-tracks fetch (Spotify/Deezer — richest + most
# accurate), fall back to the discovery pool (covers iTunes + any artist the fetch
# missed). Stored as full render-ready dicts so the row needs NO pool re-hydration —
# robust against pool rotation (the bug that shrinks Fresh Tape/Archives at read time).
pool, active_source = [], None pool, active_source = [], None
for src in (sources_to_process or []): for src in (sources_to_process or []):
pool = self.database.get_discovery_pool_tracks( pool = self.database.get_discovery_pool_tracks(
@ -4050,24 +4139,85 @@ class WatchlistScanner:
if pool: if pool:
active_source = src active_source = src
break break
if not active_source:
active_source = (sources_to_process or ['spotify'])[0]
track_ids = [] # Pool baseline grouped by artist (full render dicts), no network.
if pool: pool_by_artist = {}
by_artist = {} for t in (pool or []):
for t in pool: an = (getattr(t, 'artist_name', '') or '').lower()
an = (getattr(t, 'artist_name', '') or '').lower() tid = (getattr(t, 'spotify_track_id', None) if active_source == 'spotify'
tid = (getattr(t, 'spotify_track_id', None) if active_source == 'spotify' else getattr(t, 'itunes_track_id', None) if active_source == 'itunes'
else getattr(t, 'itunes_track_id', None) if active_source == 'itunes' else getattr(t, 'deezer_track_id', None))
else getattr(t, 'deezer_track_id', None)) name = getattr(t, 'track_name', '') or ''
if an and tid: if not an or not tid or not name:
by_artist.setdefault(an, []).append({'name': getattr(t, 'track_name', ''), 'id': tid}) continue
candidates = aggregate_candidate_tracks(recs, by_artist, per_artist=3, limit=50) tdj = getattr(t, 'track_data_json', None)
track_ids = [c['id'] for c in candidates if c.get('id')] if isinstance(tdj, str):
if track_ids: try:
self.database.save_curated_playlist('listening_recs_tracks', track_ids, profile_id=profile_id) tdj = _json.loads(tdj)
except Exception:
tdj = None
pool_by_artist.setdefault(an, []).append({
'track_id': str(tid), 'name': name, 'track_name': name,
'artist_name': getattr(t, 'artist_name', '') or '',
'album_name': getattr(t, 'album_name', '') or '',
'album_cover_url': getattr(t, 'album_cover_url', None),
'duration_ms': getattr(t, 'duration_ms', 0) or 0,
'track_data_json': tdj, 'source': active_source,
f'{active_source}_track_id': str(tid)})
logger.info("[Listening Recs] %d recommended artists, %d candidate tracks", # Direct top-tracks enrichment — guarded, bounded (top 20 recs), fail-soft per artist.
len(recs), len(track_ids)) fetched_by_artist = {}
try:
if active_source in ('spotify', 'deezer'):
client = get_client_for_source(active_source)
if client and hasattr(client, 'get_artist_top_tracks'):
id_key = 'spotify_artist_id' if active_source == 'spotify' else 'deezer_artist_id'
can_search = hasattr(client, 'search_artists')
for r in recs[:20]:
aid = artist_meta_by_name.get(r.name.lower(), {}).get(id_key)
# Most similar-artist rows store a name but no source id, so resolve
# it by name-search — guarded by names_match so we never fetch the
# WRONG artist's tracks (a same-name act).
if not aid and can_search:
try:
found = client.search_artists(r.name, limit=1) or []
except Exception as _s_err:
logger.debug("[Listening Recs] artist search failed for %s: %s",
r.name, _s_err)
found = []
if found and names_match(r.name, getattr(found[0], 'name', '')):
aid = getattr(found[0], 'id', None)
if not aid:
continue
try:
raw = client.get_artist_top_tracks(str(aid), limit=8) or []
except Exception as _tt_err:
logger.debug("[Listening Recs] top-tracks fetch failed for %s: %s",
r.name, _tt_err)
continue
shaped = [s for s in (to_mix_track(x, active_source) for x in raw) if s][:5]
if shaped:
fetched_by_artist[r.name.lower()] = shaped
except Exception as _enr_err:
logger.debug("[Listening Recs] top-tracks enrichment skipped: %s", _enr_err)
# Merge: prefer fetched top tracks, fall back to the pool per artist.
top_tracks_by_artist = {}
for r in recs:
merged = fetched_by_artist.get(r.name.lower()) or pool_by_artist.get(r.name.lower())
if merged:
top_tracks_by_artist[r.name.lower()] = merged
mix = aggregate_candidate_tracks(recs, top_tracks_by_artist, per_artist=3, limit=50)
track_ids = [m.get('track_id') for m in mix if m.get('track_id')]
if mix:
self.database.set_metadata('listening_recs_tracks_full', _json.dumps(mix))
self.database.save_curated_playlist('listening_recs_tracks', track_ids, profile_id=profile_id)
logger.info("[Listening Recs] %d recommended artists, %d mix tracks (%d artists via top-tracks fetch)",
len(recs), len(mix), len(fetched_by_artist))
except Exception as e: except Exception as e:
logger.debug("[Listening Recs] generation skipped: %s", e) logger.debug("[Listening Recs] generation skipped: %s", e)

View file

@ -10884,23 +10884,40 @@ class MusicDatabase:
logger.error(f"Error caching discovery recent album: {e}") logger.error(f"Error caching discovery recent album: {e}")
return False return False
def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None, profile_id: int = 1) -> List[Dict[str, Any]]: def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None, profile_id: int = 1,
"""Get cached recent albums for discover page, optionally filtered by source""" exclude_future_years: bool = False) -> List[Dict[str, Any]]:
"""Get cached recent albums for discover page, optionally filtered by source.
exclude_future_years: drop announced-but-unreleased albums dated to a LATER YEAR.
Because rows are ordered ``release_date DESC``, future-dated albums otherwise sort to
the very top and consume the ``limit`` budget which is exactly why Fresh Tape / Release
Radar starved down to a handful of tracks. Year-level so it's precision-safe across
'YYYY' / 'YYYY-MM' / 'YYYY-MM-DD'; same-year future months are left for the caller's precise
``is_future_release`` check. NULL/blank dates are kept (treated as released).
"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
future_clause = ""
if exclude_future_years:
future_clause = (
" AND (release_date IS NULL OR release_date = '' "
"OR CAST(substr(release_date, 1, 4) AS INTEGER) "
"<= CAST(strftime('%Y','now') AS INTEGER))"
)
if source: if source:
cursor.execute(""" cursor.execute(f"""
SELECT * FROM discovery_recent_albums SELECT * FROM discovery_recent_albums
WHERE source = ? AND profile_id = ? WHERE source = ? AND profile_id = ?{future_clause}
ORDER BY release_date DESC ORDER BY release_date DESC
LIMIT ? LIMIT ?
""", (source, profile_id, limit)) """, (source, profile_id, limit))
else: else:
cursor.execute(""" cursor.execute(f"""
SELECT * FROM discovery_recent_albums SELECT * FROM discovery_recent_albums
WHERE profile_id = ? WHERE profile_id = ?{future_clause}
ORDER BY release_date DESC ORDER BY release_date DESC
LIMIT ? LIMIT ?
""", (profile_id, limit)) """, (profile_id, limit))

View file

@ -4,14 +4,67 @@ from __future__ import annotations
from core.discovery.listening_recommendations import ( from core.discovery.listening_recommendations import (
aggregate_candidate_tracks, aggregate_candidate_tracks,
build_recency_weighted_seeds,
names_match,
rank_recommended_artists, rank_recommended_artists,
similarity_from_rank,
to_mix_track,
) )
# ── names_match (guards the top-tracks fetch against wrong-artist results) ─────
def test_names_match_ignores_case_and_punctuation():
assert names_match("Tyler, The Creator", "Tyler The Creator")
assert names_match("BEYONCÉ", "beyoncé")
assert names_match("AC/DC", "ac dc")
def test_names_match_rejects_near_misses_and_empty():
assert not names_match("Drake", "Drake Bell")
assert not names_match("", "anything")
assert not names_match("X", None)
def _seed(name, weight=1.0): def _seed(name, weight=1.0):
return {"name": name, "weight": weight} return {"name": name, "weight": weight}
# ── similarity_from_rank (1=closest .. 10=farthest -> 1.0 .. 0.1) ─────────────
def test_similarity_from_rank_decays_over_documented_range():
assert similarity_from_rank(1) == 1.0
assert similarity_from_rank(5) == 0.6
assert similarity_from_rank(10) == 0.1
def test_similarity_from_rank_clamps_and_defaults():
assert similarity_from_rank(0) == 1.0 # <=1 -> full weight
assert similarity_from_rank(50) == 0.1 # beyond range -> floor
assert similarity_from_rank(None) == 1.0 # missing -> full weight (no rank info)
assert similarity_from_rank("nan") == 1.0
# ── build_recency_weighted_seeds (lifetime + factor*recent) ───────────────────
def test_recency_boost_reorders_toward_current_taste():
# Old-fav has more lifetime plays, but New-fav dominates recently.
lifetime = [{"name": "OldFav", "play_count": 100}, {"name": "NewFav", "play_count": 40}]
recent = {"newfav": 60}
seeds = build_recency_weighted_seeds(lifetime, recent, recency_factor=1.5)
by = {s["name"]: s["weight"] for s in seeds}
assert by["OldFav"] == 100.0 # no recent plays -> unchanged
assert by["NewFav"] == 40 + 1.5 * 60 # 130 -> now outranks OldFav
def test_recency_factor_zero_is_pure_lifetime():
seeds = build_recency_weighted_seeds(
[{"name": "A", "play_count": 7}], {"a": 99}, recency_factor=0)
assert seeds == [{"name": "A", "weight": 7.0}]
def test_recency_seeds_skip_blank_names_and_tolerate_missing_recent():
seeds = build_recency_weighted_seeds([{"name": ""}, {"name": "A", "play_count": 3}])
assert seeds == [{"name": "A", "weight": 3.0}]
# ── rank_recommended_artists ───────────────────────────────────────────────── # ── rank_recommended_artists ─────────────────────────────────────────────────
def test_consensus_outranks_single_endorsement(): def test_consensus_outranks_single_endorsement():
# 'Common' is similar to BOTH seeds; 'Solo' to one. Equal weights/scores. # 'Common' is similar to BOTH seeds; 'Solo' to one. Equal weights/scores.
@ -117,6 +170,46 @@ def test_aggregate_skips_artist_with_no_tracks():
assert [t["name"] for t in out] == ["only"] # sim-b had no tracks -> skipped assert [t["name"] for t in out] == ["only"] # sim-b had no tracks -> skipped
# ── to_mix_track (source top-track dict -> Discover compact-row dict) ─────────
def _sp_track(tid="t1", name="Song", artist="Artist", album="Album", cover="http://cdn/c.jpg"):
return {"id": tid, "name": name, "artists": [{"name": artist}],
"album": {"name": album, "images": ([{"url": cover}] if cover else [])},
"duration_ms": 210000, "popularity": 55}
def test_to_mix_track_shapes_render_fields():
out = to_mix_track(_sp_track(), "spotify")
assert out["track_name"] == "Song" and out["artist_name"] == "Artist"
assert out["album_name"] == "Album" and out["album_cover_url"] == "http://cdn/c.jpg"
assert out["duration_ms"] == 210000
assert out["spotify_track_id"] == "t1" and out["track_id"] == "t1"
assert out["track_data_json"]["id"] == "t1" # full payload kept for sync
assert out["name"] == "Song" # kept for aggregate dedup
def test_to_mix_track_source_id_field_per_source():
assert to_mix_track(_sp_track(), "deezer")["deezer_track_id"] == "t1"
assert to_mix_track(_sp_track(), "itunes")["itunes_track_id"] == "t1"
def test_to_mix_track_rejects_unusable_and_tolerates_missing_album():
assert to_mix_track({"id": "x"}, "spotify") is None # no title
assert to_mix_track({"name": "y"}, "spotify") is None # no id
assert to_mix_track("garbage", "spotify") is None
bare = to_mix_track({"id": "z", "name": "Z"}, "spotify") # no artists/album
assert bare["artist_name"] == "" and bare["album_cover_url"] is None
def test_to_mix_track_feeds_aggregate_end_to_end():
# The real pipeline: shape source tracks, then aggregate by recommended artist.
recs = _recs("A") # recommends 'sim-A'
shaped = [to_mix_track(_sp_track(tid="1", name="One", artist="sim-A"), "spotify"),
to_mix_track(_sp_track(tid="2", name="Two", artist="sim-A"), "spotify")]
out = aggregate_candidate_tracks(recs, {"sim-a": shaped}, per_artist=5, limit=10)
assert [t["track_name"] for t in out] == ["One", "Two"]
assert out[0]["spotify_track_id"] == "1"
# ── group_similars_by_seed (id->name join) ─────────────────────────────────── # ── group_similars_by_seed (id->name join) ───────────────────────────────────
from dataclasses import dataclass as _dc # noqa: E402 from dataclasses import dataclass as _dc # noqa: E402
@ -167,3 +260,37 @@ def test_group_then_rank_end_to_end():
ranked = rank_recommended_artists(seeds, grouped, owned_artist_names={"solo"}) ranked = rank_recommended_artists(seeds, grouped, owned_artist_names={"solo"})
assert ranked[0].name == "Common" and ranked[0].seed_count == 2 assert ranked[0].name == "Common" and ranked[0].seed_count == 2
assert all(r.name != "Solo" for r in ranked) # owned excluded assert all(r.name != "Solo" for r in ranked) # owned excluded
# ── rank-aware grouping (similarity_rank -> score) ────────────────────────────
@_dc
class _RankRow:
source_artist_id: str
similar_artist_name: str
similarity_rank: int
def test_group_with_rank_attr_carries_similarity_score():
seeds = [_seed("A")]
rows = [_RankRow("ia", "Close", 1), _RankRow("ia", "Far", 10)]
out = group_similars_by_seed(seeds, rows, {"ia": "A"}, rank_attr="similarity_rank")
by = {e["name"]: e["score"] for e in out["a"]}
assert by == {"Close": 1.0, "Far": 0.1}
def test_group_without_rank_attr_is_scoreless_backcompat():
seeds = [_seed("A")]
rows = [_RankRow("ia", "X", 3)]
out = group_similars_by_seed(seeds, rows, {"ia": "A"})
assert out["a"] == [{"name": "X"}] # no score key -> original behavior
def test_rank_threading_changes_winner_within_a_seed():
# The production fix: a CLOSER match (rank 1) on a heavy seed beats a far match (rank 9),
# even though both come from the same seed. Without rank threading they'd tie.
seeds = [_seed("Fav", weight=10)]
rows = [_RankRow("if", "Close", 1), _RankRow("if", "Far", 9)]
grouped = group_similars_by_seed(seeds, rows, {"if": "Fav"}, rank_attr="similarity_rank")
ranked = rank_recommended_artists(seeds, grouped)
assert [r.name for r in ranked] == ["Close", "Far"]
assert ranked[0].score > ranked[1].score

View file

@ -0,0 +1,57 @@
"""Fresh Tape / Release Radar candidate fetch must not be starved by future albums.
Regression: get_discovery_recent_albums orders release_date DESC, so announced-but-
unreleased albums dated to a LATER YEAR sort to the very top and consumed the album
budget before the scanner's in-loop is_future_release skip ran — leaving only a handful
of released albums to draw tracks from (the reported "Fresh Tape only has 5-10 tracks").
exclude_future_years drops next-year albums at the query so released ones fill the budget.
"""
from __future__ import annotations
from datetime import datetime
from database.music_database import MusicDatabase
def _insert(db, **kw):
with db._get_connection() as conn:
conn.execute(
"""INSERT INTO discovery_recent_albums
(album_spotify_id, album_name, artist_name, artist_spotify_id,
album_cover_url, release_date, album_type, source, profile_id)
VALUES (?,?,?,?,?,?,?,?,?)""",
(kw['id'], kw['name'], kw['artist'], kw['id'] + '_a', '',
kw['release_date'], kw.get('album_type', 'album'), kw.get('source', 'spotify'), 1))
conn.commit()
def test_future_year_albums_excluded_released_kept(tmp_path):
db = MusicDatabase(database_path=str(tmp_path / "t.db"))
this_year = datetime.now().year
next_year = this_year + 1
_insert(db, id='past1', name='Released A', artist='X', release_date=f'{this_year - 1}-03-01')
_insert(db, id='past2', name='Released B', artist='Y', release_date=f'{this_year}-01-15')
_insert(db, id='fut1', name='Announced', artist='Z', release_date=f'{next_year}-02-01')
_insert(db, id='fut2', name='Year Only Future', artist='W', release_date=str(next_year))
_insert(db, id='blank', name='Unknown Date', artist='Q', release_date='')
names_all = {a['album_name'] for a in db.get_discovery_recent_albums(limit=50, source='spotify')}
assert 'Announced' in names_all # without the flag, futures are present (and sort first)
filtered = db.get_discovery_recent_albums(limit=50, source='spotify', exclude_future_years=True)
names = {a['album_name'] for a in filtered}
assert 'Announced' not in names # next-year album dropped
assert 'Year Only Future' not in names # YYYY-only future dropped
assert {'Released A', 'Released B'} <= names # released kept
assert 'Unknown Date' in names # blank date kept (treated as released)
def test_future_filter_does_not_over_trim_when_all_released(tmp_path):
db = MusicDatabase(database_path=str(tmp_path / "t2.db"))
this_year = datetime.now().year
for i in range(8):
_insert(db, id=f'r{i}', name=f'Album {i}', artist=f'A{i}',
release_date=f'{this_year}-0{(i % 9) + 1}-01')
filtered = db.get_discovery_recent_albums(limit=300, source='spotify', exclude_future_years=True)
assert len(filtered) == 8 # every released album survives, budget honored

View file

@ -1171,7 +1171,7 @@ def test_curate_discovery_playlists_uses_source_priority_for_recent_albums(monke
"avg_daily_plays": 0.0, "avg_daily_plays": 0.0,
"artist_play_counts": {}, "artist_play_counts": {},
}) })
monkeypatch.setattr(scanner.database, "get_discovery_recent_albums", lambda limit, source, profile_id: [recent_album] if source == "deezer" else [], raising=False) monkeypatch.setattr(scanner.database, "get_discovery_recent_albums", lambda limit, source, profile_id, exclude_future_years=False: [recent_album] if source == "deezer" else [], raising=False)
monkeypatch.setattr(scanner.database, "get_discovery_pool_tracks", lambda *args, **kwargs: [discovery_track] if kwargs.get("source") == "deezer" else [], raising=False) monkeypatch.setattr(scanner.database, "get_discovery_pool_tracks", lambda *args, **kwargs: [discovery_track] if kwargs.get("source") == "deezer" else [], raising=False)
monkeypatch.setattr(scanner.database, "save_curated_playlist", lambda key, tracks, profile_id=1: saved_playlists.append((key, list(tracks))) or True, raising=False) monkeypatch.setattr(scanner.database, "save_curated_playlist", lambda key, tracks, profile_id=1: saved_playlists.append((key, list(tracks))) or True, raising=False)
monkeypatch.setattr(scanner.database, "get_top_artists", lambda *args, **kwargs: [], raising=False) monkeypatch.setattr(scanner.database, "get_top_artists", lambda *args, **kwargs: [], raising=False)

View file

@ -29083,6 +29083,96 @@ def get_discover_similar_artists():
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/listening-recommendations', methods=['GET'])
def get_discover_listening_recommendations():
"""#913: artists you'd love based on what you actually LISTEN to (play-weighted).
Distinct from /api/discover/similar-artists (which is driven by your whole library /
watchlist): this is seeded by your most-PLAYED artists, consensus-ranked across the
similar-artist graph, and recency-boosted. The heavy lifting + storage happen during the
watchlist scan (core.watchlist_scanner._build_listening_recommendations -> the
'listening_recs_artists' metadata key); this endpoint just reshapes the stored list to the
same card shape the recommended-artists row already renders. Read-only, fail-soft.
"""
try:
database = get_database()
active_source = _get_active_discovery_source()
raw = database.get_metadata('listening_recs_artists')
if not raw:
return jsonify({"success": True, "artists": [], "source": active_source, "count": 0})
try:
stored = json.loads(raw) or []
except (ValueError, TypeError):
stored = []
result_artists = []
for a in stored:
name = a.get('name')
if not name:
continue
if active_source == 'spotify':
artist_id = a.get('spotify_artist_id')
elif active_source == 'deezer':
artist_id = a.get('deezer_artist_id') or a.get('itunes_artist_id')
else:
artist_id = a.get('itunes_artist_id')
entry = {
"artist_id": artist_id,
"spotify_artist_id": a.get('spotify_artist_id'),
"itunes_artist_id": a.get('itunes_artist_id'),
"deezer_artist_id": a.get('deezer_artist_id'),
"artist_name": name,
"seed_count": a.get('seed_count'),
"source": active_source,
}
img = a.get('image_url')
if img:
entry["image_url"] = fix_artist_image_url(img)
if a.get('genres'):
entry["genres"] = a['genres'][:3]
# "because you listen to X, Y, Z" — the most-played artists that point here.
if a.get('seeds'):
entry["because"] = a['seeds']
result_artists.append(entry)
return jsonify({
"success": True,
"artists": result_artists,
"source": active_source,
"count": len(result_artists),
})
except Exception as e:
logger.error(f"Error getting listening recommendations: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/personalized/listening-mix', methods=['GET'])
def get_discover_listening_mix():
"""#913: the "Listening Mix" playlist row — a playable track mix from the artists you'd
love based on what you actually listen to.
The tracks are built during the watchlist scan (core.watchlist_scanner
._build_listening_recommendations -> the 'listening_recs_tracks_full' metadata key) as full
render-ready dicts, so this endpoint just hands them back no discovery-pool re-hydration,
which means it can't shrink when the pool rotates (the failure mode Fresh Tape/Archives hit).
Same {success, tracks} shape renderCompactPlaylist + the sync/download chains expect.
"""
try:
database = get_database()
active_source = _get_active_discovery_source()
raw = database.get_metadata('listening_recs_tracks_full')
tracks = []
if raw:
try:
tracks = json.loads(raw) or []
except (ValueError, TypeError):
tracks = []
return jsonify({"success": True, "tracks": tracks, "source": active_source})
except Exception as e:
logger.error(f"Error getting listening mix: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/similar-artists/enrich', methods=['POST']) @app.route('/api/discover/similar-artists/enrich', methods=['POST'])
def enrich_similar_artists(): def enrich_similar_artists():
"""Enrich a batch of artist IDs with images/genres from Spotify or iTunes. """Enrich a batch of artist IDs with images/genres from Spotify or iTunes.

View file

@ -3133,6 +3133,53 @@
</div> </div>
<!-- Recommended For You Section (similar-artists graph) --> <!-- Recommended For You Section (similar-artists graph) -->
<!-- #913: listening-driven recommendations (play-weighted, consensus-ranked) -->
<div class="discover-section" id="listening-recs-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Based On Your Listening</h2>
<p class="discover-section-subtitle">Artists you'd love — ranked from who you actually play the most</p>
</div>
</div>
<div class="discover-carousel" id="listening-recs-carousel">
<!-- Populated by JS -->
</div>
</div>
<!-- #913: Listening Mix — a playable track mix from those recommended artists -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">🎧 Your Listening Mix</h2>
<p class="discover-section-subtitle">A fresh playlist of tracks from artists matched to your listening</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary"
onclick="openDownloadModalForDiscoverPlaylist('listening_mix', 'Your Listening Mix')"
title="Download missing tracks">
<span class="button-icon"></span><span class="button-text">Download</span>
</button>
<button class="action-button primary" id="listening-mix-sync-btn"
onclick="startDiscoverPlaylistSync('listening_mix', 'Your Listening Mix')"
title="Sync to media server">
<span class="button-icon"></span><span class="button-text">Sync</span>
</button>
</div>
</div>
<div class="discover-sync-status" id="listening-mix-sync-status" style="display: none;">
<div class="sync-status-content">
<div class="sync-status-label"><span class="sync-icon"></span><span>Syncing to media server...</span></div>
<div class="sync-status-stats">
<span class="sync-stat"><span id="listening-mix-sync-completed">0</span></span>
<span class="sync-stat"><span id="listening-mix-sync-pending">0</span></span>
<span class="sync-stat"><span id="listening-mix-sync-failed">0</span></span>
<span class="sync-stat">(<span id="listening-mix-sync-percentage">0</span>%)</span>
</div>
</div>
</div>
<div class="discover-playlist-container compact" id="personalized-listening-mix"></div>
</div>
<div class="discover-section" id="recommended-artists-section" style="display: none;"> <div class="discover-section" id="recommended-artists-section" style="display: none;">
<div class="discover-section-header"> <div class="discover-section-header">
<div> <div>

View file

@ -19,6 +19,7 @@ let personalizedPopularPicks = [];
let personalizedHiddenGems = []; let personalizedHiddenGems = [];
let personalizedDailyMixes = []; let personalizedDailyMixes = [];
let personalizedDiscoveryShuffle = []; let personalizedDiscoveryShuffle = [];
let personalizedListeningMix = []; // #913: the "Your Listening Mix" track playlist
let buildPlaylistSelectedArtists = []; let buildPlaylistSelectedArtists = [];
async function loadDiscoverPage() { async function loadDiscoverPage() {
@ -27,6 +28,8 @@ async function loadDiscoverPage() {
// Load all sections // Load all sections
await Promise.all([ await Promise.all([
loadDiscoverHero(), loadDiscoverHero(),
loadListeningRecommendations(), // #913: play-weighted, consensus-ranked picks
loadPersonalizedListeningMix(), // #913: playable track mix from those picks
loadRecommendedArtistsSection(), loadRecommendedArtistsSection(),
loadYourArtists(), loadYourArtists(),
loadYourAlbums(), loadYourAlbums(),
@ -676,10 +679,28 @@ async function addAllRecommendedToWatchlist(btn) {
// machinery, so the inline carousel and the "View All" modal stay in sync. // machinery, so the inline carousel and the "View All" modal stay in sync.
let _recommendedSectionCtrl = null; let _recommendedSectionCtrl = null;
function _renderRecommendedMini(artist, source) { // "Because you listen to X, Y" — the listening-driven (#913) variant of the reason line.
function _listeningRecommendationReason(artist) {
const names = (artist && artist.because) || [];
if (names.length === 1) return `Because you listen to ${escapeHtml(names[0])}`;
if (names.length === 2) return `Because you listen to ${escapeHtml(names[0])} & ${escapeHtml(names[1])}`;
if (names.length >= 3) {
const shown = names.slice(0, 2).map(escapeHtml).join(', ');
return `Because you listen to ${shown} +${names.length - 2} more`;
}
return 'From artists you play often';
}
function _listeningRecommendationReasonTitle(artist) {
const names = (artist && artist.because) || [];
return names.length ? `You listen to: ${names.join(', ')}` : '';
}
function _renderRecommendedMini(artist, source, opts) {
const reasonFn = (opts && opts.reasonFn) || _recommendationReason;
const titleFn = (opts && opts.titleFn) || _recommendationReasonTitle;
const artistSource = artist.source || source || ''; const artistSource = artist.source || source || '';
const reason = _recommendationReason(artist); const reason = reasonFn(artist);
const reasonTitle = _recommendationReasonTitle(artist); const reasonTitle = titleFn(artist);
const genreTags = (artist.genres || []).slice(0, 2).map(g => const genreTags = (artist.genres || []).slice(0, 2).map(g =>
`<span class="recommended-card-genre">${escapeHtml(g)}</span>` `<span class="recommended-card-genre">${escapeHtml(g)}</span>`
).join(''); ).join('');
@ -712,7 +733,7 @@ function _renderRecommendedMini(artist, source) {
// Progressively fill in images for the cards we actually rendered (the API // Progressively fill in images for the cards we actually rendered (the API
// returns cached images only; the rest are fetched on demand — same endpoint // returns cached images only; the rest are fetched on demand — same endpoint
// the modal uses). // the modal uses).
async function _enrichRecommendedCarouselCards(items, source) { async function _enrichRecommendedCarouselCards(items, source, carouselId) {
const idKey = source === 'spotify' ? 'spotify_artist_id' const idKey = source === 'spotify' ? 'spotify_artist_id'
: source === 'deezer' ? 'deezer_artist_id' : source === 'deezer' ? 'deezer_artist_id'
: 'itunes_artist_id'; : 'itunes_artist_id';
@ -726,7 +747,7 @@ async function _enrichRecommendedCarouselCards(items, source) {
}); });
const data = await resp.json(); const data = await resp.json();
if (!data.success || !data.artists) return; if (!data.success || !data.artists) return;
const carousel = document.getElementById('recommended-artists-carousel'); const carousel = document.getElementById(carouselId || 'recommended-artists-carousel');
if (!carousel) return; if (!carousel) return;
for (const [aid, info] of Object.entries(data.artists)) { for (const [aid, info] of Object.entries(data.artists)) {
if (!info.image_url) continue; if (!info.image_url) continue;
@ -778,6 +799,48 @@ async function loadRecommendedArtistsSection() {
return _recommendedSectionCtrl.load(); return _recommendedSectionCtrl.load();
} }
// #913: "Based On Your Listening" — play-weighted, consensus-ranked recommendations.
// Mirrors loadRecommendedArtistsSection but reads the listening-driven endpoint and
// renders a "Because you listen to X" reason. Hides itself when empty (no scan yet).
let _listeningRecsCtrl = null;
async function loadListeningRecommendations() {
if (!_listeningRecsCtrl) {
_listeningRecsCtrl = createDiscoverSectionController({
id: 'listening-recs',
sectionEl: '#listening-recs-section',
contentEl: '#listening-recs-carousel',
fetchUrl: '/api/discover/listening-recommendations',
extractItems: (data) => data.artists || [],
isEmpty: (items) => items.length === 0,
hideWhenEmpty: true,
renderItems: (items, data) => {
const source = data.source || 'spotify';
const shown = items.slice(0, 18);
return shown.map(a => _renderRecommendedMini(a, source, {
reasonFn: _listeningRecommendationReason,
titleFn: _listeningRecommendationReasonTitle,
})).join('');
},
onRendered: ({ data }) => {
const carousel = document.getElementById('listening-recs-carousel');
if (carousel && !carousel._recoWired) {
carousel._recoWired = true;
carousel.addEventListener('click', function (e) {
const btn = e.target.closest('.recommended-card-watchlist-btn');
if (btn) { e.preventDefault(); e.stopPropagation(); toggleRecommendedWatchlist(btn); }
});
}
const source = (data && data.source) || 'spotify';
_enrichRecommendedCarouselCards((data && data.artists || []).slice(0, 18), source, 'listening-recs-carousel');
},
loadingMessage: 'Reading your listening...',
emptyMessage: 'Play more music and run a watchlist scan to see picks based on your listening',
errorMessage: 'Failed to load listening recommendations',
});
}
return _listeningRecsCtrl.load();
}
function closeRecommendedArtistsModal() { function closeRecommendedArtistsModal() {
const modal = document.getElementById('recommended-artists-modal'); const modal = document.getElementById('recommended-artists-modal');
if (modal) modal.style.display = 'none'; if (modal) modal.style.display = 'none';
@ -4246,6 +4309,32 @@ async function loadPersonalizedHiddenGems() {
} }
} }
// #913: "Your Listening Mix" — a playable track playlist from the artists matched to your
// listening. Mirrors loadPersonalizedHiddenGems; tracks come pre-shaped from the scan so the
// row renders + syncs like the others. Hides when empty (no scan / no listening data yet).
async function loadPersonalizedListeningMix() {
try {
const container = document.getElementById('personalized-listening-mix');
if (!container) return;
const response = await fetch('/api/discover/personalized/listening-mix');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedListeningMix = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading listening mix:', error);
}
}
async function loadPersonalizedDailyMixes() { async function loadPersonalizedDailyMixes() {
try { try {
const container = document.getElementById('daily-mixes-grid'); const container = document.getElementById('daily-mixes-grid');
@ -4300,7 +4389,6 @@ function renderCompactPlaylist(container, tracks) {
const durationMin = Math.floor(track.duration_ms / 60000); const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000); const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
const artistEsc = (track.artist_name || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
html += ` html += `
<div class="discover-playlist-track-compact" data-track-index="${index}"> <div class="discover-playlist-track-compact" data-track-index="${index}">
@ -4314,7 +4402,6 @@ function renderCompactPlaylist(container, tracks) {
</div> </div>
<div class="track-compact-album">${track.album_name}</div> <div class="track-compact-album">${track.album_name}</div>
<div class="track-compact-duration">${duration}</div> <div class="track-compact-duration">${duration}</div>
<button class="track-compact-block" onclick="event.stopPropagation(); blockDiscoveryArtist('${artistEsc}')" title="Block ${artistEsc} from discovery"></button>
</div> </div>
`; `;
}); });
@ -8646,6 +8733,8 @@ async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName)
tracks = personalizedHiddenGems; tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') { } else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle; tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'listening_mix') {
tracks = personalizedListeningMix;
} else if (playlistType === 'build_playlist') { } else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks; tracks = buildPlaylistTracks;
} }
@ -8765,6 +8854,8 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) {
tracks = personalizedHiddenGems; tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') { } else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle; tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'listening_mix') {
tracks = personalizedListeningMix;
} else if (playlistType === 'build_playlist') { } else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks; tracks = buildPlaylistTracks;
} }