Enrichment matched artists by NAME ONLY (0.85 gate), so for a common name
('Rone' has ~5 artists) it stored whichever the source ranked first — often the
wrong one, which then drove a wrong/sparse library 'Standard' discography while
'Enhanced' (the real owned albums) showed the full set.
Fix — use the decisive signal the library already has (the albums you OWN):
- worker_utils: pick_artist_by_catalog() + catalog_overlap_score() +
owned_album_titles()/release_titles(). When 2+ candidates clear the name gate,
fetch each one's catalog and choose the one overlapping the owned albums; falls
back to the current best-by-name pick when there's nothing to disambiguate or
no overlap (so the common single-candidate path makes no extra API calls).
- Wired into Spotify (covers Spotify-Free, same client), iTunes, Deezer (now
multi-candidate search_artists + get_artist_info store), and MusicBrainz
(match_artist gains owned_titles; release-groups as the catalog).
Re-match path (#868):
- build_reset_query now also clears the stored source-ID column for artist/album
item resets — previously a 're-match' only nulled match_status, so the worker's
existing-id short-circuit re-confirmed the WRONG id and never re-resolved. Tracks
excluded (ids live in tags, not a column).
- MusicBrainz also self-corrects its 90-day name->mbid cache: match_artist bypasses
a cached mbid whose catalog has ZERO overlap with the owned albums, so a re-match
isn't blocked by a stale wrong cache entry.
Tests: shared selector (9), per-worker disambiguation for all 4 sources + MB
backward-compat + MB cache-revalidation (8), reset-clears-id (2). 99 worker/
enrichment tests green.
284 lines
10 KiB
Python
284 lines
10 KiB
Python
"""Read-side helpers for browsing the items an enrichment source hasn't matched.
|
|
|
|
The dashboard "Manage Enrichment Workers" modal lists, per source, the
|
|
artists / albums / tracks whose ``<service>_match_status`` is ``'not_found'``
|
|
(or still pending = ``NULL``) so the user can manually match them. Every
|
|
enrichment source writes a uniform ``<service>_match_status`` column, so one
|
|
parametric query serves all 11 workers.
|
|
|
|
This module owns the column mapping and SQL construction. ``service`` and
|
|
``entity_type`` are whitelisted against :data:`SERVICE_ENTITY_SUPPORT` and the
|
|
entity table map before any column name is interpolated — user-supplied values
|
|
(the search term, pagination) are always bound parameters, never interpolated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import List, Optional, Tuple
|
|
|
|
# Which entity types each enrichment source covers. Mirrors the authoritative
|
|
# ``_SERVICE_ID_COLUMNS`` map in web_server.py (used by manual-match), kept here
|
|
# so the unmatched browser is self-contained and unit-testable. Singular keys
|
|
# ('artist'/'album'/'track') match the manual-match entity_type vocabulary.
|
|
SERVICE_ENTITY_SUPPORT = {
|
|
'spotify': ('artist', 'album', 'track'),
|
|
'musicbrainz': ('artist', 'album', 'track'),
|
|
'deezer': ('artist', 'album', 'track'),
|
|
'audiodb': ('artist', 'album', 'track'),
|
|
'discogs': ('artist', 'album'), # no track-level id column
|
|
'itunes': ('artist', 'album', 'track'),
|
|
'lastfm': ('artist', 'album', 'track'),
|
|
'genius': ('artist', 'track'), # no album-level id column
|
|
'tidal': ('artist', 'album', 'track'),
|
|
'qobuz': ('artist', 'album', 'track'),
|
|
'amazon': ('artist', 'album', 'track'),
|
|
# Relationship enrichment (not a metadata source): the Similar Artists worker
|
|
# only operates at the artist level, and its <service>_match_status tracks
|
|
# whether MusicMap similars were fetched (not a source-id match). So the
|
|
# breakdown / unmatched list here means "artists we have / don't have
|
|
# similars for" — informative, even though there's no manual-match action.
|
|
'similar_artists': ('artist',),
|
|
}
|
|
|
|
# entity_type -> table / display-name column / image expression / optional join
|
|
# / parent-context expression (the artist an album belongs to; the album a
|
|
# track belongs to) so the UI can disambiguate same-named items.
|
|
# tracks carry no artwork column of their own, so we borrow the parent album's.
|
|
_ENTITY_TABLE = {
|
|
'artist': {
|
|
'table': 'artists', 'name': 'name',
|
|
'image': 'artists.thumb_url', 'join': '', 'parent': None,
|
|
},
|
|
'album': {
|
|
'table': 'albums', 'name': 'title',
|
|
'image': 'albums.thumb_url',
|
|
'join': 'LEFT JOIN artists par ON albums.artist_id = par.id',
|
|
'parent': 'par.name',
|
|
},
|
|
'track': {
|
|
'table': 'tracks', 'name': 'title',
|
|
'image': 'al.thumb_url',
|
|
'join': 'LEFT JOIN albums al ON tracks.album_id = al.id',
|
|
'parent': 'al.title',
|
|
},
|
|
}
|
|
|
|
# 'unmatched' = not yet matched at all (pending OR explicitly not_found).
|
|
VALID_STATUSES = ('not_found', 'pending', 'unmatched')
|
|
|
|
# Hard cap so a malicious/buggy caller can't ask for the whole library at once.
|
|
MAX_LIMIT = 200
|
|
|
|
|
|
class UnmatchedQueryError(ValueError):
|
|
"""Raised for an unknown service / unsupported entity type / bad status."""
|
|
|
|
|
|
def supported_entity_types(service: str) -> Tuple[str, ...]:
|
|
"""Return the entity types a source enriches, or () for an unknown source."""
|
|
return SERVICE_ENTITY_SUPPORT.get(service, ())
|
|
|
|
|
|
def match_status_column(service: str) -> str:
|
|
return f"{service}_match_status"
|
|
|
|
|
|
def last_attempted_column(service: str) -> str:
|
|
return f"{service}_last_attempted"
|
|
|
|
|
|
def _validate(service: str, entity_type: str) -> None:
|
|
support = SERVICE_ENTITY_SUPPORT.get(service)
|
|
if support is None:
|
|
raise UnmatchedQueryError(f"Unknown enrichment service: {service!r}")
|
|
if entity_type not in support:
|
|
raise UnmatchedQueryError(
|
|
f"{service} does not enrich {entity_type!r} entities"
|
|
)
|
|
if entity_type not in _ENTITY_TABLE: # defensive — support map drift
|
|
raise UnmatchedQueryError(f"No table mapping for entity type {entity_type!r}")
|
|
|
|
|
|
def _status_predicate(service: str, status: str, qualifier: str) -> str:
|
|
"""SQL predicate selecting rows in the requested match state.
|
|
|
|
``qualifier`` (the table name/alias) is always prefixed so the predicate is
|
|
unambiguous even when the query joins a second table that also carries a
|
|
``<service>_match_status`` column (tracks LEFT JOIN albums).
|
|
"""
|
|
col = f"{qualifier}.{match_status_column(service)}"
|
|
if status == 'not_found':
|
|
return f"{col} = 'not_found'"
|
|
if status == 'pending':
|
|
return f"{col} IS NULL"
|
|
# 'unmatched'
|
|
return f"({col} IS NULL OR {col} = 'not_found')"
|
|
|
|
|
|
def build_unmatched_query(
|
|
service: str,
|
|
entity_type: str,
|
|
status: str = 'not_found',
|
|
query: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> Tuple[str, List]:
|
|
"""Build the paginated SELECT for one (service, entity_type, status) view.
|
|
|
|
Returns ``(sql, params)``. Selected columns: id, name, image_url, status,
|
|
last_attempted.
|
|
"""
|
|
_validate(service, entity_type)
|
|
if status not in VALID_STATUSES:
|
|
raise UnmatchedQueryError(f"Invalid status: {status!r}")
|
|
|
|
meta = _ENTITY_TABLE[entity_type]
|
|
table, name_col, image_expr, join = (
|
|
meta['table'], meta['name'], meta['image'], meta['join'],
|
|
)
|
|
ms = match_status_column(service)
|
|
la = last_attempted_column(service)
|
|
|
|
where = [_status_predicate(service, status, table)]
|
|
params: List = []
|
|
if query:
|
|
where.append(f"{table}.{name_col} LIKE ?")
|
|
params.append(f"%{query}%")
|
|
|
|
parent_expr = meta.get('parent')
|
|
parent_select = f"{parent_expr} AS parent" if parent_expr else "NULL AS parent"
|
|
sql = (
|
|
f"SELECT {table}.id AS id, {table}.{name_col} AS name, "
|
|
f"{image_expr} AS image_url, {parent_select}, {table}.{ms} AS status, "
|
|
f"{table}.{la} AS last_attempted "
|
|
f"FROM {table} {join} "
|
|
f"WHERE {' AND '.join(where)} "
|
|
f"ORDER BY {table}.{name_col} COLLATE NOCASE "
|
|
f"LIMIT ? OFFSET ?"
|
|
).replace(' ', ' ')
|
|
|
|
params.append(_clamp_limit(limit))
|
|
params.append(max(int(offset or 0), 0))
|
|
return sql, params
|
|
|
|
|
|
def build_count_query(
|
|
service: str,
|
|
entity_type: str,
|
|
status: str = 'not_found',
|
|
query: Optional[str] = None,
|
|
) -> Tuple[str, List]:
|
|
"""Build the COUNT(*) matching :func:`build_unmatched_query`'s filters."""
|
|
_validate(service, entity_type)
|
|
if status not in VALID_STATUSES:
|
|
raise UnmatchedQueryError(f"Invalid status: {status!r}")
|
|
|
|
meta = _ENTITY_TABLE[entity_type]
|
|
table, name_col = meta['table'], meta['name']
|
|
|
|
where = [_status_predicate(service, status, table)]
|
|
params: List = []
|
|
if query:
|
|
where.append(f"{table}.{name_col} LIKE ?")
|
|
params.append(f"%{query}%")
|
|
|
|
sql = f"SELECT COUNT(*) FROM {table} WHERE {' AND '.join(where)}"
|
|
return sql, params
|
|
|
|
|
|
# Reset scopes for re-queuing items so the worker re-attempts them.
|
|
RESET_SCOPES = ('item', 'failed')
|
|
|
|
|
|
def build_reset_query(
|
|
service: str,
|
|
entity_type: str,
|
|
scope: str = 'item',
|
|
entity_id=None,
|
|
) -> Tuple[str, List]:
|
|
"""Build the UPDATE that re-queues item(s) for enrichment.
|
|
|
|
Re-queuing means clearing ``<service>_match_status`` back to NULL (and
|
|
``<service>_last_attempted`` to NULL): every worker's pending query selects
|
|
``match_status IS NULL`` first, so the item is retried on the next pass.
|
|
Nulling last_attempted alone is NOT enough — the not_found retry path uses
|
|
``last_attempted < cutoff`` and ``NULL < cutoff`` is false, so the item
|
|
would never be picked up.
|
|
|
|
* scope='item' -> a single row (requires entity_id)
|
|
* scope='failed' -> every 'not_found' row for this entity type
|
|
"""
|
|
_validate(service, entity_type)
|
|
if scope not in RESET_SCOPES:
|
|
raise UnmatchedQueryError(f"Invalid reset scope: {scope!r}")
|
|
|
|
meta = _ENTITY_TABLE[entity_type]
|
|
table = meta['table']
|
|
ms = match_status_column(service)
|
|
la = last_attempted_column(service)
|
|
set_parts = [f"{ms} = NULL", f"{la} = NULL"]
|
|
|
|
# Also forget the stored source ID so re-matching actually RE-RESOLVES the
|
|
# entity. Without this, the worker hits its existing-id short-circuit, sees
|
|
# the old (possibly WRONG) id and just re-confirms it — which is why "click
|
|
# to rematch" never fixed a mis-matched same-name artist (#868). Tracks keep
|
|
# their ids in file tags rather than a column, so only artist/album clear one.
|
|
if entity_type in ('artist', 'album'):
|
|
try:
|
|
from core.source_ids import id_column
|
|
id_col = id_column(service, entity_type)
|
|
except Exception:
|
|
id_col = None
|
|
if id_col:
|
|
set_parts.append(f"{id_col} = NULL")
|
|
set_clause = "SET " + ", ".join(set_parts)
|
|
|
|
if scope == 'item':
|
|
if not entity_id:
|
|
raise UnmatchedQueryError("entity_id is required for an item reset")
|
|
return f"UPDATE {table} {set_clause} WHERE id = ?", [entity_id]
|
|
# 'failed' — re-queue everything this source explicitly gave up on.
|
|
return f"UPDATE {table} {set_clause} WHERE {ms} = 'not_found'", []
|
|
|
|
|
|
def build_breakdown_query(service: str, entity_type: str) -> Tuple[str, List]:
|
|
"""Build the matched / not_found / pending / total tally for one entity type."""
|
|
_validate(service, entity_type)
|
|
meta = _ENTITY_TABLE[entity_type]
|
|
table = meta['table']
|
|
ms = f"{table}.{match_status_column(service)}"
|
|
sql = (
|
|
"SELECT "
|
|
f"SUM(CASE WHEN {ms} = 'matched' THEN 1 ELSE 0 END) AS matched, "
|
|
f"SUM(CASE WHEN {ms} = 'not_found' THEN 1 ELSE 0 END) AS not_found, "
|
|
f"SUM(CASE WHEN {ms} IS NULL THEN 1 ELSE 0 END) AS pending, "
|
|
f"COUNT(*) AS total "
|
|
f"FROM {table}"
|
|
)
|
|
return sql, []
|
|
|
|
|
|
def _clamp_limit(limit) -> int:
|
|
try:
|
|
n = int(limit)
|
|
except (TypeError, ValueError):
|
|
return 50
|
|
if n <= 0:
|
|
return 50
|
|
return min(n, MAX_LIMIT)
|
|
|
|
|
|
__all__ = [
|
|
'SERVICE_ENTITY_SUPPORT',
|
|
'VALID_STATUSES',
|
|
'MAX_LIMIT',
|
|
'UnmatchedQueryError',
|
|
'supported_entity_types',
|
|
'match_status_column',
|
|
'last_attempted_column',
|
|
'build_unmatched_query',
|
|
'build_count_query',
|
|
'build_breakdown_query',
|
|
'build_reset_query',
|
|
'RESET_SCOPES',
|
|
]
|