diff --git a/core/exports/export_sources.py b/core/exports/export_sources.py index 48db313b..8b6fa36f 100644 --- a/core/exports/export_sources.py +++ b/core/exports/export_sources.py @@ -72,6 +72,55 @@ def db_recording_mbid(artist: str, title: str) -> Optional[str]: return _db_match(artist, title)[0] +# Service → the tracks-table column carrying that service's track ID (set by enrichment). +# Trusted constants — never user input — so safe to interpolate into the SELECT below. +_SERVICE_ID_COLUMNS = {"spotify": "spotify_track_id", "deezer": "deezer_id"} + + +def db_service_track_id(artist: str, title: str, service: str) -> Optional[str]: + """The service track ID (``spotify_track_id`` / ``deezer_id``) stored on a matched + library track — what lets a mirrored playlist be exported BACK to Spotify/Deezer + without re-searching, since enrichment already pinned it (#945). Text-matches by + (artist, title), same as the MBID resolver. Fail-safe: any miss/error returns None.""" + column = _SERVICE_ID_COLUMNS.get((service or "").lower()) + if not column or not title: + return None + try: + from database.music_database import get_database + db = get_database() + conn = db._get_connection() + try: + cur = conn.cursor() + cur.execute( + f"SELECT t.{column} FROM tracks t JOIN artists a ON t.artist_id = a.id " + "WHERE LOWER(t.title) = LOWER(?) AND LOWER(a.name) = LOWER(?) LIMIT 1", + (title, artist), + ) + row = cur.fetchone() + if not row: + return None + val = row[0] if not hasattr(row, "keys") else row[column] + return val or None + finally: + try: + conn.close() + except Exception: # noqa: S110 + pass + except Exception as exc: + logger.debug(f"export service-id lookup failed for '{artist} - {title}' ({service}): {exc}") + return None + + +def build_service_resolve_fn(service: str) -> Callable[[str, str], Tuple[Optional[str], Optional[str]]]: + """resolve_fn for service-playlist export: ``(artist, title) -> (service_track_id, 'library')``. + Plugs into ``resolve_playlist_tracks(..., id_key='service_track_id')`` exactly like the + MBID resolver plugs in for ListenBrainz.""" + def resolve_fn(artist: str, title: str) -> Tuple[Optional[str], Optional[str]]: + tid = db_service_track_id(artist, title, service) + return (tid, "library" if tid else None) + return resolve_fn + + 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) diff --git a/core/exports/playlist_export.py b/core/exports/playlist_export.py index 1105a09b..e2da0498 100644 --- a/core/exports/playlist_export.py +++ b/core/exports/playlist_export.py @@ -36,16 +36,22 @@ def resolve_playlist_tracks( resolve_fn: ResolveFn, *, on_progress: Optional[ProgressFn] = None, + id_key: str = "recording_mbid", ) -> Dict[str, Any]: - """Resolve every track to a recording MBID and build the export pseudo-playlist. + """Resolve every track to an ID and build the export pseudo-playlist. + + ``resolve_fn(artist, title) -> (id, source)`` returns whatever ID the target needs — + a MusicBrainz recording MBID for ListenBrainz/JSPF (the default), or a Spotify/Deezer + track ID for service export. ``id_key`` names the field that ID lands under in each + resolved entry (defaults to ``recording_mbid`` so existing LB/JSPF callers are + untouched). The dedup + stats + ordering logic is identical regardless of ID type. ``tracks`` items may use ``artist``/``artist_name`` and ``title``/``track_name`` and ``album``/``album_name`` (both the mirrored-playlist and LB-cache shapes are accepted). Returns ``{"resolved": [...], "stats": {...}}`` where each resolved entry is - ``{artist, title, album, recording_mbid}`` (recording_mbid is None when unmatched), - in original order, and stats carries ``total, resolved, unmatched, deduped, - by_source`` for the live display. + ``{artist, title, album, }`` (the ID is None when unmatched), in original + order, and stats carries ``total, resolved, unmatched, deduped, by_source``. """ total = len(tracks or []) memo: Dict[str, Tuple[Optional[str], Optional[str]]] = {} @@ -71,7 +77,7 @@ def resolve_playlist_tracks( memo[key] = (mbid, source) fresh = True - resolved.append({"artist": artist, "title": title, "album": album, "recording_mbid": mbid}) + resolved.append({"artist": artist, "title": title, "album": album, id_key: mbid}) if mbid: stats["resolved"] += 1 diff --git a/tests/exports/test_export_sources.py b/tests/exports/test_export_sources.py index bb019ea7..37361090 100644 --- a/tests/exports/test_export_sources.py +++ b/tests/exports/test_export_sources.py @@ -57,3 +57,34 @@ def test_all_miss_returns_none_and_no_write(): fn, recorded = _wire() assert fn("A", "T") == (None, None) assert recorded == {} + + +# ── service track-id resolver (#945 export to Spotify/Deezer) ── + +from core.exports.export_sources import ( + db_service_track_id, + build_service_resolve_fn, + _SERVICE_ID_COLUMNS, +) + + +def test_service_id_column_mapping(): + assert _SERVICE_ID_COLUMNS == {'spotify': 'spotify_track_id', 'deezer': 'deezer_id'} + + +def test_db_service_track_id_unknown_service_is_none(): + assert db_service_track_id('A', 'X', 'tidal') is None + assert db_service_track_id('A', 'X', '') is None + + +def test_db_service_track_id_no_title_is_none(): + assert db_service_track_id('A', '', 'spotify') is None + + +def test_build_service_resolve_fn_returns_id_and_source(monkeypatch): + import core.exports.export_sources as es + monkeypatch.setattr(es, 'db_service_track_id', + lambda a, t, s: 'spid-99' if t == 'Hit' else None) + fn = build_service_resolve_fn('spotify') + assert fn('Artist', 'Hit') == ('spid-99', 'library') + assert fn('Artist', 'Miss') == (None, None) diff --git a/tests/exports/test_playlist_export.py b/tests/exports/test_playlist_export.py index d0af07e5..7cfe3ae2 100644 --- a/tests/exports/test_playlist_export.py +++ b/tests/exports/test_playlist_export.py @@ -72,3 +72,31 @@ def test_empty_playlist(): out = resolve_playlist_tracks([], lambda a, t: (None, None)) assert out["resolved"] == [] assert out["stats"]["total"] == 0 + + +# ── id_key generalization (#945 service export reuses the LB resolver) ── + +from core.exports.playlist_export import resolve_playlist_tracks as _rpt + + +def _const_resolver(mapping): + return lambda artist, title: mapping.get((artist, title), (None, None)) + + +def test_default_id_key_is_recording_mbid_unchanged(): + # ListenBrainz/JSPF callers must be byte-for-byte unaffected by the generalization. + out = _rpt([{'artist': 'A', 'title': 'X'}], _const_resolver({('A', 'X'): ('mbid-1', 'db')})) + assert out['resolved'][0]['recording_mbid'] == 'mbid-1' + assert 'service_track_id' not in out['resolved'][0] + + +def test_custom_id_key_carries_service_id(): + out = _rpt( + [{'artist': 'A', 'title': 'X'}, {'artist': 'B', 'title': 'Y'}], + _const_resolver({('A', 'X'): ('spid-1', 'library')}), # B/Y unmatched + id_key='service_track_id', + ) + assert out['resolved'][0]['service_track_id'] == 'spid-1' + assert out['resolved'][1]['service_track_id'] is None + assert 'recording_mbid' not in out['resolved'][0] + assert out['stats']['resolved'] == 1 and out['stats']['unmatched'] == 1