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:
parent
9cb5c4d40d
commit
88ff47e115
4 changed files with 173 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
68
core/personalized/generators/listening_mix.py
Normal file
68
core/personalized/generators/listening_mix.py
Normal 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)
|
||||
79
tests/discovery/test_listening_mix_generator.py
Normal file
79
tests/discovery/test_listening_mix_generator.py
Normal 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
|
||||
|
|
@ -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 `
|
||||
|
|
|
|||
Loading…
Reference in a new issue