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 fresh_tape # noqa: F401
|
||||||
from core.personalized.generators import archives # noqa: F401
|
from core.personalized.generators import archives # noqa: F401
|
||||||
from core.personalized.generators import seasonal_mix # 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 {
|
try {
|
||||||
const resp = await fetch('/api/personalized/playlists');
|
// Existing generated rows + ALL registered singleton kinds, so every SoulSync
|
||||||
const data = await resp.json();
|
// 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) {
|
if (!data.success) {
|
||||||
container.innerHTML = `<div class="playlist-placeholder">❌ ${escapeHtml(data.error || 'Failed to load')}</div>`;
|
container.innerHTML = `<div class="playlist-placeholder">❌ ${escapeHtml(data.error || 'Failed to load')}</div>`;
|
||||||
return;
|
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();
|
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) {
|
} catch (err) {
|
||||||
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${err.message}</div>`;
|
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${err.message}</div>`;
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
|
|
@ -66,7 +85,8 @@ function renderSoulsyncDiscoverySyncPlaylists() {
|
||||||
const subtitle = p.variant ? `${p.kind} · ${p.variant}` : p.kind;
|
const subtitle = p.variant ? `${p.kind} · ${p.variant}` : p.kind;
|
||||||
const count = p.track_count || 0;
|
const count = p.track_count || 0;
|
||||||
const stale = !!p.is_stale;
|
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';
|
const stalenessColor = stale ? '#facc15' : '#14b8a6';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue