soulsync/core/library/manual_library_match.py
Broque Thomas aaf312cd34 Honor manual library matches across source labels
Manual matches can be created from sync history as mirrored while wishlist and download flows later see the same track as wishlist or a provider source. Add a shared track-level lookup that falls back from exact source/id to source_track_id and title/artist, then use it for wishlist adds, cleanup, and download analysis so mapped tracks are not re-added or redownloaded.

Add coverage for mirrored-source matches being honored by wishlist cleanup and download batches, including the internal wishlist force-download path.
2026-05-18 16:32:38 -07:00

283 lines
10 KiB
Python

"""Manual library match service.
Lets users explicitly link a source track (wishlist/sync-history candidate) to
an existing library track so SoulSync stops trying to re-download it.
"""
from __future__ import annotations
import json
from typing import Any, Optional
from utils.logging_config import get_logger
logger = get_logger("library.manual_library_match")
def save_match(
db,
profile_id: int,
source: str,
source_track_id: str,
library_track_id: int,
**meta,
) -> bool:
"""Save (insert or replace) a manual match."""
return db.save_manual_library_match(
profile_id, source, source_track_id, library_track_id, **meta
)
def get_match(
db,
profile_id: int,
source: str,
source_track_id: str,
server_source: str = "",
) -> Optional[dict]:
"""Return match row dict or None if not found."""
getter = getattr(db, "get_manual_library_match", None)
if getter is None:
return None
return getter(profile_id, source, source_track_id, server_source)
def _first_artist_name(track: dict[str, Any]) -> str:
artists = track.get("artists") or []
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
return (first.get("name") or "").strip()
return str(first).strip()
return (track.get("artist") or track.get("artist_name") or "").strip()
def _track_source_candidates(track: dict[str, Any], default_source: str = "") -> list[str]:
candidates = [
track.get("provider"),
track.get("source"),
default_source,
"spotify",
]
out = []
for source in candidates:
source = (source or "").strip()
if source and source not in out:
out.append(source)
return out
def _track_id_candidates(track: dict[str, Any]) -> list[str]:
candidates = [
track.get("source_track_id"),
track.get("spotify_track_id"),
track.get("track_id"),
track.get("id"),
track.get("musicbrainz_recording_id"),
track.get("deezer_id") or track.get("deezer_track_id"),
track.get("itunes_track_id"),
track.get("tidal_id") or track.get("tidal_track_id"),
track.get("qobuz_id") or track.get("qobuz_track_id"),
track.get("amazon_id") or track.get("amazon_track_id"),
]
out = []
for value in candidates:
value = str(value).strip() if value is not None else ""
if value and value not in out:
out.append(value)
return out
def get_match_for_track(
db,
profile_id: int,
track: dict[str, Any],
*,
default_source: str = "",
server_source: str = "",
) -> Optional[dict]:
"""Return a manual match for a wishlist/sync track.
Exact source+ID matches are preferred, but source labels can legitimately
change between UI surfaces (for example ``mirrored`` in sync history versus
``wishlist`` in the wishlist batch). Fall back to track ID and finally
title/artist so saved manual matches are honored consistently.
"""
if not isinstance(track, dict):
return None
sources = _track_source_candidates(track, default_source)
track_ids = _track_id_candidates(track)
for track_id in track_ids:
for source in sources:
match = get_match(db, profile_id, source, track_id, server_source)
if match:
return match
id_getter = getattr(db, "find_manual_library_match_by_source_track_id", None)
if id_getter is not None:
for track_id in track_ids:
match = id_getter(profile_id, track_id, server_source)
if match:
return match
title = (track.get("name") or track.get("title") or track.get("track_name") or "").strip()
artist = _first_artist_name(track)
metadata_getter = getattr(db, "find_manual_library_match_by_metadata", None)
if metadata_getter is not None and title and artist:
return metadata_getter(profile_id, title, artist, server_source)
return None
def delete_match(db, match_id: int, profile_id: int) -> bool:
"""Delete match by PK id, scoped to profile."""
return db.delete_manual_library_match(match_id, profile_id)
def list_matches(db, profile_id: int, limit: int = 100) -> list[dict]:
"""Return all matches for profile, most-recently-updated first."""
rows = db.list_manual_library_matches(profile_id, limit)
return [_enrich_match(row, db) for row in rows]
def search_source_candidates(db, query: str, profile_id: int, limit: int = 15) -> list[dict]:
"""Search wishlist + sync history for source track candidates matching query."""
if not query or not query.strip():
return []
q = query.strip()
like = f"%{q}%"
results: dict[tuple, dict] = {}
# 1) Wishlist tracks
try:
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
json_extract(spotify_data, '$.id') AS track_id,
json_extract(spotify_data, '$.name') AS title,
json_extract(spotify_data, '$.artists[0].name') AS artist,
json_extract(spotify_data, '$.album.name') AS album,
date_added AS added_at
FROM wishlist_tracks
WHERE profile_id = ?
AND (
json_extract(spotify_data, '$.name') LIKE ?
OR json_extract(spotify_data, '$.artists[0].name') LIKE ?
)
ORDER BY date_added DESC
LIMIT ?
""", (profile_id, like, like, limit * 2))
for row in cursor.fetchall():
r = dict(row)
if not r.get("track_id"):
continue
key = ("spotify", r["track_id"])
if key not in results:
results[key] = {
"source": "spotify",
"source_track_id": r["track_id"],
"title": r["title"] or "",
"artist": r["artist"] or "",
"album": r["album"] or "",
"context": "Wishlist",
"added_at": r["added_at"] or "",
}
except Exception as exc:
logger.debug("source_candidates wishlist query failed: %s", exc)
# 2) Sync history — scan tracks_json blobs
try:
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT playlist_name, source, tracks_json, started_at
FROM sync_history
ORDER BY started_at DESC
LIMIT 50
""")
for row in cursor.fetchall():
sh = dict(row)
try:
tracks = json.loads(sh["tracks_json"] or "[]")
except Exception:
continue
for t in tracks:
title = t.get("name", "")
artist = ""
artists = t.get("artists", [])
if artists:
first = artists[0]
artist = first.get("name", "") if isinstance(first, dict) else str(first)
if q.lower() not in title.lower() and q.lower() not in artist.lower():
continue
src = sh["source"] or "spotify"
tid = t.get("id") or t.get("spotify_track_id") or ""
if not tid:
continue
key = (src, tid)
if key not in results:
album = ""
alb = t.get("album")
if isinstance(alb, dict):
album = alb.get("name", "")
elif isinstance(alb, str):
album = alb
results[key] = {
"source": src,
"source_track_id": tid,
"title": title,
"artist": artist,
"album": album,
"context": sh["playlist_name"] or "",
"added_at": sh["started_at"] or "",
}
if len(results) >= limit * 3:
break
except Exception as exc:
logger.debug("source_candidates sync_history query failed: %s", exc)
# Sort by recency and cap
sorted_results = sorted(results.values(), key=lambda r: r.get("added_at", ""), reverse=True)
return sorted_results[:limit]
def search_library_candidates(db, query: str, limit: int = 15) -> list[dict[str, Any]]:
"""Search library tracks using the existing api_search_tracks method."""
if not query or not query.strip():
return []
q = query.strip()
# Pass the full query as title (covers most single-field searches).
# Also try as artist in parallel and merge, deduped by track id.
title_rows = db.api_search_tracks(title=q, limit=limit)
artist_rows = db.api_search_tracks(artist=q, limit=limit)
seen: set[int] = set()
merged = []
for row in title_rows + artist_rows:
rid = row.get('id')
if rid not in seen:
seen.add(rid)
merged.append(row)
if len(merged) >= limit:
break
return merged
def _enrich_match(match_row: dict, db) -> dict:
"""Add library track details to a match row."""
out = dict(match_row)
lib_id = match_row.get("library_track_id")
if lib_id:
try:
tracks = db.api_get_tracks_by_ids([lib_id])
if tracks:
t = tracks[0]
out["library_title"] = t.get("title", "")
out["library_artist"] = t.get("artist_name", "")
out["library_album"] = t.get("album_title", "")
out["library_file_path"] = t.get("file_path", "")
out["library_bitrate"] = t.get("bitrate")
except Exception as exc:
logger.debug("enrich_match track lookup failed for id=%s: %s", lib_id, exc)
return out