playlist export: resolve IDs from the discovery cache first (#945, increment 6)

Boulder: "all 50 tracks are discovered to Deezer already — it's not using any of that." Right —
the export only checked tracks.deezer_id (library) and ignored the IDs discovery already resolved
and stored in each mirrored track's extra_data. So tracks that were discovered+downloaded but not
separately enriched showed as "not on Deezer" and got dropped.

Adds a per-track waterfall for service export:
- service_id_from_extra_data(track, service): the id discovery already matched, read from
  extra_data.matched_data.id — FREE (no API call) and reliable (it's the same id used to mirror
  the track). Trusted only when discovered ON the target service (provider == service); a
  wing_it_fallback (low-confidence guess) does NOT match here, so it falls through rather than
  risk a wrong track in the export.
- resolve_service_track_ids(tracks, service): cache (extra_data) → library stored id → unmatched.
  Reports from_cache / from_library / unmatched. _run_service_export now uses this instead of the
  artist/title MBID-style resolver.

For Boulder's playlist this means all 50 resolve straight from the cache — full coverage, zero API
calls. (A live confident-search backfill for the genuinely-missing remainder is the optional next
step, gated + thresholded.)

9 new tests: extra_data id only when provider matches + wing_it excluded + bad-json/not-discovered
guards, the cache→library→unmatched waterfall with stat tallies, and _run_service_export resolving
straight from the cache end-to-end. 49 export tests green, ruff clean.
This commit is contained in:
BoulderBadgeDad 2026-06-28 21:39:39 -07:00
parent 6e1a377f37
commit 37c8b06a27
4 changed files with 181 additions and 31 deletions

View file

@ -16,8 +16,9 @@ writes a fresh non-cache hit back to the cache so the next export of the same so
from __future__ import annotations
import json
import threading
from typing import Callable, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple
from utils.logging_config import get_logger
@ -121,6 +122,89 @@ def build_service_resolve_fn(service: str) -> Callable[[str, str], Tuple[Optiona
return resolve_fn
def service_id_from_extra_data(track: Any, service: str) -> Optional[str]:
"""The target-service track ID the DISCOVERY step already resolved for this mirrored
track, read from its ``extra_data`` blob (#945 — Boulder: "all 50 are discovered to
Deezer already, it's not using any of that"). This is free (no API call) and reliable
(it's the same id used to mirror the track).
Only trusted when the track was discovered ON the export's target service — a
Deezer-discovered track carries a Deezer id under ``matched_data['id']``, and its
``provider`` is the service name. A ``wing_it_fallback`` provider (the low-confidence
guess path) deliberately does NOT match here, so those fall through to the library/
none path rather than risk a wrong track in the exported playlist."""
raw = track.get("extra_data") if isinstance(track, dict) else None
if not raw:
return None
try:
data = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
return None
if not isinstance(data, dict) or not data.get("discovered"):
return None
if str(data.get("provider") or "").lower() != str(service or "").lower():
return None
matched = data.get("matched_data")
tid = matched.get("id") if isinstance(matched, dict) else None
return str(tid) if tid else None
def _track_field(track: Dict[str, Any], *names: str) -> str:
for n in names:
v = track.get(n)
if v:
return str(v)
return ""
def resolve_service_track_ids(
tracks: List[Dict[str, Any]],
service: str,
*,
db_fn: Optional[Callable[[str, str, str], Optional[str]]] = None,
on_progress: Optional[Callable[[int, int, Dict[str, Any]], None]] = None,
) -> Dict[str, Any]:
"""Resolve a mirrored playlist's tracks to target-service track IDs for export.
Waterfall per track: the discovery cache (``extra_data`` free + already confidently
matched) the library track's stored service id. A track with neither is reported
unmatched (caller skips it never a guessed/wrong id). Returns
``{"resolved": [{artist, title, album, service_track_id}], "stats": {...}}`` with
``from_cache`` / ``from_library`` / ``unmatched`` tallies for the status display.
"""
db_fn = db_fn or db_service_track_id
total = len(tracks or [])
resolved: List[Dict[str, Any]] = []
stats: Dict[str, Any] = {
"total": total, "resolved": 0, "unmatched": 0, "from_cache": 0, "from_library": 0,
}
for i, t in enumerate(tracks or []):
if not isinstance(t, dict):
t = {}
artist = _track_field(t, "artist", "artist_name", "creator")
title = _track_field(t, "title", "track_name", "name")
album = _track_field(t, "album", "album_name", "release_name")
tid = service_id_from_extra_data(t, service)
if tid:
stats["from_cache"] += 1
else:
tid = db_fn(artist, title, service)
if tid:
stats["from_library"] += 1
resolved.append({"artist": artist, "title": title, "album": album,
"service_track_id": tid or None})
stats["resolved" if tid else "unmatched"] += 1
if on_progress is not None:
try:
on_progress(i + 1, total, stats)
except Exception: # noqa: S110 — a progress error must never fail the export
pass
return {"resolved": resolved, "stats": stats}
def file_recording_mbid(artist: str, title: str) -> Optional[str]:
"""Recording MBID read from the matched track's file tag (set on import post-processing)."""
_mbid, fpath = _db_match(artist, title)

View file

@ -117,3 +117,55 @@ def test_db_service_track_id_real_sql_executes(tmp_path, monkeypatch):
assert es.db_service_track_id("Kendrick Lamar", "Not Like Us", "spotify") == "spid-NLU"
assert es.db_service_track_id("kendrick lamar", "not like us", "deezer") == "dz-NLU" # case-insensitive
assert es.db_service_track_id("Kendrick Lamar", "Unknown Song", "spotify") is None
# ── discovery-cache resolution (#945: use the already-discovered IDs, no API call) ──
import json as _json
from core.exports.export_sources import (
service_id_from_extra_data,
resolve_service_track_ids,
)
def _extra(service, tid, discovered=True, provider=None):
return {'extra_data': _json.dumps({'discovered': discovered,
'provider': provider or service,
'matched_data': {'id': tid}})}
def test_extra_data_id_when_discovered_to_that_service():
assert service_id_from_extra_data(_extra('deezer', 111), 'deezer') == '111'
# dict (not str) extra_data also works
raw = {'extra_data': {'discovered': True, 'provider': 'spotify', 'matched_data': {'id': 'spX'}}}
assert service_id_from_extra_data(raw, 'spotify') == 'spX'
def test_extra_data_provider_must_match_service():
# discovered to Spotify, exporting to Deezer → don't reuse the (wrong-service) id
assert service_id_from_extra_data(_extra('spotify', 111), 'deezer') is None
def test_extra_data_wing_it_fallback_is_not_trusted():
track = _extra('deezer', 111, provider='wing_it_fallback')
assert service_id_from_extra_data(track, 'deezer') is None
def test_extra_data_misc_none_cases():
assert service_id_from_extra_data({}, 'deezer') is None # no extra_data
assert service_id_from_extra_data({'extra_data': 'not json{'}, 'deezer') is None # bad json
assert service_id_from_extra_data(_extra('deezer', 111, discovered=False), 'deezer') is None
def test_resolve_waterfall_cache_then_library_then_unmatched():
tracks = [
_extra('deezer', 111) | {'artist_name': 'A', 'track_name': 'Cached'}, # cache hit
{'artist_name': 'A', 'track_name': 'InLib'}, # library hit (db_fn)
{'artist_name': 'A', 'track_name': 'Nowhere'}, # unmatched
]
db_fn = lambda a, t, s: 'lib-222' if t == 'InLib' else None
out = resolve_service_track_ids(tracks, 'deezer', db_fn=db_fn)
ids = [r['service_track_id'] for r in out['resolved']]
assert ids == ['111', 'lib-222', None]
s = out['stats']
assert s == {'total': 3, 'resolved': 2, 'unmatched': 1, 'from_cache': 1, 'from_library': 1}

View file

@ -1,7 +1,8 @@
"""_run_service_export orchestration (#945): resolve mirrored tracks → service IDs → push →
store the target for idempotent re-export. Deps injected (fake db/client/resolve_fn) so this
needs no DB or live Spotify/Deezer."""
"""_run_service_export orchestration (#945): resolve mirrored tracks → service IDs
(discovery cache library) push store the target for idempotent re-export. Deps
injected so this needs no real DB or live Spotify/Deezer."""
import json
import web_server as ws
@ -28,48 +29,62 @@ class _FakeClient:
return self.result
def _resolve(mapping):
return lambda artist, title: (mapping.get(title), 'library' if mapping.get(title) else None)
def _fake_resolver(ids):
"""resolve_ids_fn stub returning the given service ids as the resolved set."""
def fn(tracks, service, on_progress=None):
resolved = [{'artist': 'A', 'title': f't{i}', 'service_track_id': s}
for i, s in enumerate(ids)]
matched = sum(1 for s in ids if s)
return {'resolved': resolved,
'stats': {'total': len(ids), 'resolved': matched, 'unmatched': len(ids) - matched}}
return fn
def test_success_stores_target():
def _discovered(artist, title, service, tid):
return {'artist_name': artist, 'track_name': title,
'extra_data': json.dumps({'discovered': True, 'provider': service,
'matched_data': {'id': tid}})}
def test_success_resolves_from_discovery_cache_and_stores_target():
"""Real resolver: both tracks were discovered to Deezer, so their IDs come straight
from extra_data (the cache) with no DB/API the gap Boulder spotted."""
job = {}
db = _FakeDB([{'artist': 'A', 'title': 'X'}, {'artist': 'A', 'title': 'Y'}])
db = _FakeDB([_discovered('A', 'X', 'deezer', 111), _discovered('A', 'Y', 'deezer', 222)])
client = _FakeClient({'success': True, 'playlist_id': 'pl-1', 'added': 2})
ws._run_service_export(job, db, 5, 'My PL', 'spotify', client, _resolve({'X': 'sx', 'Y': 'sy'}))
ws._run_service_export(job, db, 5, 'My PL', 'deezer', client) # real resolve_service_track_ids
assert job['phase'] == 'done'
assert client.calls[0] == ('My PL', ['sx', 'sy'], None)
assert db.set_calls == [(5, 'spotify', 'pl-1')]
assert client.calls[0] == ('My PL', ['111', '222'], None)
assert db.set_calls == [(5, 'deezer', 'pl-1')]
assert job['stats']['from_cache'] == 2 and job['stats']['unmatched'] == 0
def test_no_matched_ids_errors_no_push():
def test_no_match_errors_no_push():
job = {}
client = _FakeClient({'success': True})
ws._run_service_export(job, _FakeDB([{'artist': 'A', 'title': 'X'}]), 5, 'PL', 'deezer',
client, _resolve({}))
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'deezer', client, _fake_resolver([None]))
assert job['phase'] == 'error' and 'nothing to export' in job['error']
assert client.calls == []
def test_client_none_errors():
job = {}
ws._run_service_export(job, _FakeDB([{'artist': 'A', 'title': 'X'}]), 5, 'PL', 'spotify',
None, _resolve({'X': 'sx'}))
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'spotify', None, _fake_resolver(['sx']))
assert job['phase'] == 'error' and 'not connected' in job['error']
def test_push_failure_surfaces_error_no_target_store():
job = {}
db = _FakeDB([{'artist': 'A', 'title': 'X'}])
db = _FakeDB([{}])
client = _FakeClient({'success': False, 'error': 'Reconnect Spotify'})
ws._run_service_export(job, db, 5, 'PL', 'spotify', client, _resolve({'X': 'sx'}))
ws._run_service_export(job, db, 5, 'PL', 'spotify', client, _fake_resolver(['sx']))
assert job['phase'] == 'error' and job['error'] == 'Reconnect Spotify'
assert db.set_calls == []
def test_reexport_passes_existing_target():
job = {}
db = _FakeDB([{'artist': 'A', 'title': 'X'}], existing='pl-old')
db = _FakeDB([{}], existing='pl-old')
client = _FakeClient({'success': True, 'playlist_id': 'pl-old', 'added': 1})
ws._run_service_export(job, db, 5, 'PL', 'deezer', client, _resolve({'X': 'dz'}))
ws._run_service_export(job, db, 5, 'PL', 'deezer', client, _fake_resolver(['dz']))
assert client.calls[0][2] == 'pl-old'

View file

@ -27741,15 +27741,17 @@ _playlist_export_jobs = {}
_playlist_export_jobs_lock = threading.Lock()
def _run_service_export(job, db, playlist_id, title, service, client, resolve_fn):
def _run_service_export(job, db, playlist_id, title, service, client, resolve_ids_fn=None):
"""Export a mirrored playlist back to a streaming service (Spotify/Deezer, #945).
Reuses the same resolvepushstore-target machinery the ListenBrainz export uses,
just with a service track-ID resolver and a service write client. Deps are injected
so the orchestration (resolve, unmatched-guard, push, idempotent target store, error
paths) is unit-testable without a DB or live service.
Resolves each track to a target-service ID via the discovery cache (the track's
extra_data free + already matched) then the library's stored id, pushes the matched
set via the service write client, and stores the returned playlist id so a re-export
updates in place (idempotent, like the LB #903 fix). Deps injected so the orchestration
is unit-testable without a DB or live service.
"""
from core.exports.playlist_export import resolve_playlist_tracks
from core.exports.export_sources import resolve_service_track_ids
resolve_ids_fn = resolve_ids_fn or resolve_service_track_ids
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
job['total'] = len(tracks)
@ -27759,8 +27761,7 @@ def _run_service_export(job, db, playlist_id, title, service, client, resolve_fn
job['done'] = done
job['stats'] = dict(stats)
out = resolve_playlist_tracks(tracks, resolve_fn, on_progress=on_progress,
id_key='service_track_id')
out = resolve_ids_fn(tracks, service, on_progress=on_progress)
job['stats'] = out['stats']
ids = [r['service_track_id'] for r in out['resolved'] if r.get('service_track_id')]
if not ids:
@ -27792,15 +27793,13 @@ def _run_playlist_export(job_id, playlist_id, title, mode):
try:
# Service export (#945) — resolve to Spotify/Deezer track IDs and push.
if mode in ('spotify', 'deezer'):
from core.exports.export_sources import build_service_resolve_fn
db = get_database()
if mode == 'spotify':
client = get_spotify_client()
else:
from core.deezer_download_client import DeezerDownloadClient
client = DeezerDownloadClient()
_run_service_export(job, db, playlist_id, title, mode, client,
build_service_resolve_fn(mode))
_run_service_export(job, db, playlist_id, title, mode, client)
return
from core.exports.export_sources import build_resolve_fn