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).
This commit is contained in:
BoulderBadgeDad 2026-06-25 10:58:26 -07:00
parent 9cb5c4d40d
commit 88ff47e115
4 changed files with 173 additions and 5 deletions

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 = `<div class="playlist-placeholder">❌ ${escapeHtml(data.error || 'Failed to load')}</div>`;
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 = `<div class="playlist-placeholder">❌ Error: ${err.message}</div>`;
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 `