From 88ff47e1151e981b4df26d7346aad696a194b752 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 10:58:26 -0700 Subject: [PATCH] SoulSync Discovery sync tab: list all kinds + Listening Mix generator The tab reads the v2 personalized framework (personalized_playlists), but the Discover page generates through the legacy path and nothing seeded those v2 rows -> the tab was empty. Fixes: - New 'listening_mix' v2 generator: hands the scan's stored 'listening_recs_tracks_full' tracks to the personalized manager so the Listening Mix can mirror + Auto-Sync like every other kind (no pool hydration; can't shrink on rotation). Registered + tested. - Sync tab now lists every registered SINGLETON kind (Listening Mix, Fresh Tape, Archives, Hidden Gems, Discovery Shuffle, Popular Picks) as a card, not just already-generated rows. Clicking 'Refresh & Mirror' runs the generator + mirrors. Variant kinds (decade/genre/daily) need a picker, so they're not auto-listed; existing variant rows still show. Additive: new generator + frontend merge, no backend endpoint changes. End-to-end verified (refresh -> generate -> persist -> syncable tracks). --- core/personalized/generators/__init__.py | 1 + core/personalized/generators/listening_mix.py | 68 ++++++++++++++++ .../discovery/test_listening_mix_generator.py | 79 +++++++++++++++++++ webui/static/sync-soulsync-discovery.js | 30 +++++-- 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 core/personalized/generators/listening_mix.py create mode 100644 tests/discovery/test_listening_mix_generator.py diff --git a/core/personalized/generators/__init__.py b/core/personalized/generators/__init__.py index 8bd6309a..2e3c1aca 100644 --- a/core/personalized/generators/__init__.py +++ b/core/personalized/generators/__init__.py @@ -25,3 +25,4 @@ from core.personalized.generators import daily_mix # noqa: F401 from core.personalized.generators import fresh_tape # noqa: F401 from core.personalized.generators import archives # noqa: F401 from core.personalized.generators import seasonal_mix # noqa: F401 +from core.personalized.generators import listening_mix # noqa: F401 diff --git a/core/personalized/generators/listening_mix.py b/core/personalized/generators/listening_mix.py new file mode 100644 index 00000000..1aaaa2e7 --- /dev/null +++ b/core/personalized/generators/listening_mix.py @@ -0,0 +1,68 @@ +"""Listening Mix (#913) generator. + +Reads the full, render-ready track dicts the watchlist scan stored under the +``listening_recs_tracks_full`` metadata key — built from the recommended artists' +top tracks (see ``core.watchlist_scanner._build_listening_recommendations``) — and +coerces them into ``Track`` records. + +Unlike Fresh Tape / Archives this needs NO discovery-pool hydration: the stored +dicts are already complete, so the snapshot can't shrink when the pool rotates. It +also means the generator is a pure read — generation/network all happen during the +scan; this just hands the stored tracks to the personalized manager so the mix can +participate in the Sync-page mirror + Auto-Sync pipeline like every other kind.""" + +from __future__ import annotations + +import json +from typing import Any, List + +from core.personalized.specs import PlaylistKindSpec, get_registry +from core.personalized.types import PlaylistConfig, Track + + +KIND = 'listening_mix' +METADATA_KEY = 'listening_recs_tracks_full' + + +def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]: + """Return the stored Listening Mix tracks, trimmed to ``config.limit``. + + Empty (not an error) when the scan hasn't produced a mix yet — the manager + preserves any prior snapshot rather than dropping it. Tolerates a missing/garbage + metadata blob the same way. + """ + db = getattr(deps, 'database', None) or (deps.get('database') if isinstance(deps, dict) else None) + if db is None: + raise RuntimeError("Listening Mix generator deps missing `database`") + + raw = db.get_metadata(METADATA_KEY) + if not raw: + return [] + try: + rows = json.loads(raw) or [] + except (ValueError, TypeError): + return [] + + tracks: List[Track] = [] + for d in rows: + if not isinstance(d, dict): + continue + tracks.append(Track.from_dict(d)) + if len(tracks) >= config.limit: + break + return tracks + + +SPEC = PlaylistKindSpec( + kind=KIND, + name_template='Your Listening Mix', + description='Tracks from artists matched to what you actually listen to.', + default_config=PlaylistConfig(limit=50, max_per_album=5, max_per_artist=3), + generator=generate, + requires_variant=False, + tags=['curated', 'listening'], +) + + +if get_registry().get(KIND) is None: + get_registry().register(SPEC) diff --git a/tests/discovery/test_listening_mix_generator.py b/tests/discovery/test_listening_mix_generator.py new file mode 100644 index 00000000..cfa2b2d2 --- /dev/null +++ b/tests/discovery/test_listening_mix_generator.py @@ -0,0 +1,79 @@ +"""Listening Mix v2 generator (#913) — reads the scan's stored mix into Track records. + +The generator is what lets the Listening Mix appear on the Sync page's SoulSync +Discovery tab + flow through the mirror/Auto-Sync pipeline like the other kinds. It +must hand back exactly what the scan stored under 'listening_recs_tracks_full', with +NO pool hydration, and degrade to empty (never raise) when there's no mix yet. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +from core.personalized.generators.listening_mix import KIND, generate +from core.personalized.specs import get_registry +from core.personalized.types import PlaylistConfig + + +class _FakeDB: + def __init__(self, meta): + self._meta = meta + + def get_metadata(self, key): + return self._meta.get(key) + + +def _full_track(tid, name, artist, source='spotify'): + d = {'track_id': tid, 'name': name, 'track_name': name, 'artist_name': artist, + 'album_name': f'{name} - Album', 'album_cover_url': f'http://cdn/{tid}.jpg', + 'duration_ms': 200000, 'track_data_json': {'id': tid}, 'source': source, + f'{source}_track_id': tid, '_seed_artist': artist} + return d + + +def _deps(meta): + return SimpleNamespace(database=_FakeDB(meta)) + + +def test_generate_reads_stored_full_tracks(): + rows = [_full_track('s1', 'One', 'Arcangel'), _full_track('s2', 'Two', 'Maluma')] + out = generate(_deps({'listening_recs_tracks_full': json.dumps(rows)}), '', PlaylistConfig(limit=50)) + assert [t.track_name for t in out] == ['One', 'Two'] + assert [t.artist_name for t in out] == ['Arcangel', 'Maluma'] + assert out[0].spotify_track_id == 's1' # source id carried -> mirror can sync + assert out[0].album_cover_url == 'http://cdn/s1.jpg' + assert out[0].track_data_json == {'id': 's1'} # full payload preserved for download + + +def test_generate_respects_limit(): + rows = [_full_track(f's{i}', f'T{i}', f'A{i}') for i in range(10)] + out = generate(_deps({'listening_recs_tracks_full': json.dumps(rows)}), '', PlaylistConfig(limit=3)) + assert len(out) == 3 + + +def test_generate_empty_when_no_mix_yet(): + assert generate(_deps({}), '', PlaylistConfig(limit=50)) == [] + assert generate(_deps({'listening_recs_tracks_full': ''}), '', PlaylistConfig(limit=50)) == [] + + +def test_generate_tolerates_bad_json_and_non_dict_rows(): + assert generate(_deps({'listening_recs_tracks_full': 'not json'}), '', PlaylistConfig(limit=50)) == [] + rows = json.dumps([_full_track('s1', 'Good', 'A'), 'garbage', None]) + out = generate(_deps({'listening_recs_tracks_full': rows}), '', PlaylistConfig(limit=50)) + assert [t.track_name for t in out] == ['Good'] # junk rows skipped, valid kept + + +def test_generator_is_registered(): + # importing the package must register the kind so the manager + Sync tab discover it. + import core.personalized.generators # noqa: F401 + spec = get_registry().get(KIND) + assert spec is not None and spec.requires_variant is False + assert spec.display_name('') == 'Your Listening Mix' + + +def test_generate_supports_deezer_id_tracks(): + # iTunes/MusicBrainz users get Deezer-sourced tracks -> deezer_track_id must carry. + rows = [_full_track('d9', 'Song', 'Artist', source='deezer')] + out = generate(_deps({'listening_recs_tracks_full': json.dumps(rows)}), '', PlaylistConfig(limit=50)) + assert out[0].deezer_track_id == 'd9' and out[0].spotify_track_id is None diff --git a/webui/static/sync-soulsync-discovery.js b/webui/static/sync-soulsync-discovery.js index 6a707daa..64b0cb46 100644 --- a/webui/static/sync-soulsync-discovery.js +++ b/webui/static/sync-soulsync-discovery.js @@ -29,15 +29,34 @@ async function loadSoulsyncDiscoverySyncPlaylists() { } try { - const resp = await fetch('/api/personalized/playlists'); - const data = await resp.json(); + // Existing generated rows + ALL registered singleton kinds, so every SoulSync + // playlist (Listening Mix, Fresh Tape, Archives, Hidden Gems, Discovery Shuffle, + // …) is clickable here — not just ones generated before. The Discover page fills + // the legacy system, which never seeded these v2 rows, so without this the tab was + // empty. Variant kinds (decade / genre / daily mix) need a picker, so we only + // auto-list singletons; any existing variant rows still show. + // Playlists is REQUIRED; kinds is a best-effort enhancement and must never be able + // to break the core list, so it's fetched + parsed inside its own guard (not in a + // shared Promise.all, where a kinds-fetch rejection would sink the whole tab). + const data = await (await fetch('/api/personalized/playlists')).json(); if (!data.success) { container.innerHTML = `
❌ ${escapeHtml(data.error || 'Failed to load')}
`; return; } - _soulsyncDiscoverySyncRecords = data.playlists || []; + const existing = data.playlists || []; + const seen = new Set(existing.map(p => `${p.kind}|${p.variant || ''}`)); + let kinds = []; + try { + const kindsData = await (await fetch('/api/personalized/kinds')).json(); + if (kindsData.success && Array.isArray(kindsData.kinds)) kinds = kindsData.kinds; + } catch (e) { /* kinds endpoint optional — fall back to existing rows only */ } + const synthetic = kinds + .filter(k => !k.requires_variant && !seen.has(`${k.kind}|`)) + .map(k => ({ kind: k.kind, variant: '', name: k.name_template || k.kind, + track_count: 0, is_stale: true, _never_generated: true })); + _soulsyncDiscoverySyncRecords = [...existing, ...synthetic]; renderSoulsyncDiscoverySyncPlaylists(); - console.log(`✨ SoulSync Discovery Sync tab loaded: ${_soulsyncDiscoverySyncRecords.length} playlists`); + console.log(`✨ SoulSync Discovery Sync tab loaded: ${existing.length} generated + ${synthetic.length} available`); } catch (err) { container.innerHTML = `
❌ Error: ${err.message}
`; if (typeof showToast === 'function') { @@ -66,7 +85,8 @@ function renderSoulsyncDiscoverySyncPlaylists() { const subtitle = p.variant ? `${p.kind} · ${p.variant}` : p.kind; const count = p.track_count || 0; const stale = !!p.is_stale; - const stalenessText = stale ? 'Stale — refresh to regenerate' : 'Ready'; + const stalenessText = p._never_generated ? 'Tap “Refresh & Mirror” to generate' + : (stale ? 'Stale — refresh to regenerate' : 'Ready'); const stalenessColor = stale ? '#facc15' : '#14b8a6'; return `