Enrich the SoulSync API

This commit is contained in:
Broque Thomas 2026-03-04 13:19:39 -08:00
parent 114af496c7
commit 86a502f556
10 changed files with 2235 additions and 160 deletions

File diff suppressed because it is too large Load diff

View file

@ -37,6 +37,7 @@ def create_api_blueprint():
from .downloads import register_routes as reg_downloads
from .playlists import register_routes as reg_playlists
from .settings import register_routes as reg_settings
from .discover import register_routes as reg_discover
reg_library(bp)
reg_system(bp)
@ -46,6 +47,7 @@ def create_api_blueprint():
reg_downloads(bp)
reg_playlists(bp)
reg_settings(bp)
reg_discover(bp)
# ---- error handlers (scoped to this Blueprint) ----
@bp.errorhandler(400)

153
api/discover.py Normal file
View file

@ -0,0 +1,153 @@
"""
Discovery endpoints browse discovery pool, similar artists, and recent releases.
"""
from flask import request
from database.music_database import get_database
from .auth import require_api_key
from .helpers import api_success, api_error, parse_pagination, build_pagination, parse_fields, parse_profile_id
from .serializers import serialize_discovery_track, serialize_similar_artist, serialize_recent_release
def register_routes(bp):
@bp.route("/discover/pool", methods=["GET"])
@require_api_key
def list_discovery_pool():
"""List discovery pool tracks with optional filters.
Query params:
new_releases_only: 'true' to filter to new releases (default: false)
source: 'spotify' or 'itunes' (default: all)
limit: max tracks (default: 100, max: 500)
page: page number for pagination
"""
page, limit = parse_pagination(request, default_limit=100, max_limit=500)
new_releases_only = request.args.get("new_releases_only", "").lower() == "true"
source = request.args.get("source")
fields = parse_fields(request)
profile_id = parse_profile_id(request)
if source and source not in ("spotify", "itunes"):
return api_error("BAD_REQUEST", "source must be 'spotify' or 'itunes'.", 400)
try:
db = get_database()
# Get total count for accurate pagination
conn = db._get_connection()
cursor = conn.cursor()
count_wheres = ["profile_id = ?"]
count_params = [profile_id]
if new_releases_only:
count_wheres.append("is_new_release = 1")
if source:
count_wheres.append("source = ?")
count_params.append(source)
cursor.execute(
f"SELECT COUNT(*) as cnt FROM discovery_pool WHERE {' AND '.join(count_wheres)}",
count_params,
)
total = cursor.fetchone()["cnt"]
# Fetch page using offset/limit
offset = (page - 1) * limit
where_clauses = list(count_wheres)
params = list(count_params)
params.extend([limit, offset])
cursor.execute(f"""
SELECT * FROM discovery_pool
WHERE {' AND '.join(where_clauses)}
ORDER BY added_date DESC
LIMIT ? OFFSET ?
""", params)
rows = cursor.fetchall()
page_tracks = [dict(row) for row in rows]
return api_success(
{"tracks": [serialize_discovery_track(t, fields) for t in page_tracks]},
pagination=build_pagination(page, limit, total),
)
except Exception as e:
return api_error("DISCOVER_ERROR", str(e), 500)
@bp.route("/discover/similar-artists", methods=["GET"])
@require_api_key
def list_similar_artists():
"""List top similar artists discovered from the watchlist.
Query params:
limit: max artists (default: 50, max: 200)
"""
try:
limit = min(200, max(1, int(request.args.get("limit", 50))))
except (ValueError, TypeError):
limit = 50
fields = parse_fields(request)
profile_id = parse_profile_id(request)
try:
db = get_database()
artists = db.get_top_similar_artists(limit=limit, profile_id=profile_id)
return api_success({
"artists": [serialize_similar_artist(a, fields) for a in artists]
})
except Exception as e:
return api_error("DISCOVER_ERROR", str(e), 500)
@bp.route("/discover/recent-releases", methods=["GET"])
@require_api_key
def list_recent_releases():
"""List recent releases from watched artists.
Query params:
limit: max releases (default: 50, max: 200)
"""
try:
limit = min(200, max(1, int(request.args.get("limit", 50))))
except (ValueError, TypeError):
limit = 50
fields = parse_fields(request)
profile_id = parse_profile_id(request)
try:
db = get_database()
releases = db.get_recent_releases(limit=limit, profile_id=profile_id)
return api_success({
"releases": [serialize_recent_release(r, fields) for r in releases]
})
except Exception as e:
return api_error("DISCOVER_ERROR", str(e), 500)
@bp.route("/discover/pool/metadata", methods=["GET"])
@require_api_key
def discovery_pool_metadata():
"""Get discovery pool metadata (last populated timestamp, track count)."""
profile_id = parse_profile_id(request)
try:
db = get_database()
conn = db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT last_populated_timestamp, track_count, updated_at
FROM discovery_pool_metadata
WHERE profile_id = ?
""", (profile_id,))
row = cursor.fetchone()
if not row:
return api_success({
"last_populated": None,
"track_count": 0,
"updated_at": None,
})
return api_success({
"last_populated": row["last_populated_timestamp"],
"track_count": row["track_count"],
"updated_at": row["updated_at"],
})
except Exception as e:
return api_error("DISCOVER_ERROR", str(e), 500)

View file

@ -7,6 +7,34 @@ from .auth import require_api_key
from .helpers import api_success, api_error
def _serialize_download(task_id, task):
"""Serialize a download task with all available fields."""
track_info = task.get("track_info") or {}
# Track names can be top-level or inside track_info
track_name = task.get("track_name") or track_info.get("title") or track_info.get("track_name")
artist_name = task.get("artist_name") or track_info.get("artist") or track_info.get("artist_name")
album_name = task.get("album_name") or track_info.get("album") or track_info.get("album_name")
return {
"id": task_id,
"status": task.get("status"),
"track_name": track_name,
"artist_name": artist_name,
"album_name": album_name,
"username": task.get("username"),
"filename": task.get("filename"),
"progress": task.get("progress", 0),
"size": task.get("size"),
"error": task.get("error") or task.get("error_message"),
"batch_id": task.get("batch_id"),
"track_index": task.get("track_index"),
"retry_count": task.get("retry_count", 0),
"metadata_enhanced": task.get("metadata_enhanced", False),
"status_change_time": task.get("status_change_time"),
}
def register_routes(bp):
@bp.route("/downloads", methods=["GET"])
@ -19,18 +47,7 @@ def register_routes(bp):
tasks = []
with tasks_lock:
for task_id, task in download_tasks.items():
tasks.append({
"id": task_id,
"status": task.get("status"),
"track_name": task.get("track_name"),
"artist_name": task.get("artist_name"),
"album_name": task.get("album_name"),
"username": task.get("username"),
"filename": task.get("filename"),
"progress": task.get("progress", 0),
"size": task.get("size"),
"error": task.get("error"),
})
tasks.append(_serialize_download(task_id, task))
return api_success({"downloads": tasks})
except ImportError:

View file

@ -2,6 +2,7 @@
Shared response helpers for the SoulSync public API.
"""
from typing import Optional, Set
from flask import jsonify
@ -49,3 +50,25 @@ def parse_pagination(request, default_limit=50, max_limit=200):
except (ValueError, TypeError):
limit = default_limit
return page, limit
def parse_fields(request) -> Optional[Set[str]]:
"""Parse ?fields=id,name,thumb_url into a set. Returns None if not specified."""
raw = request.args.get("fields", "").strip()
if not raw:
return None
return {f.strip() for f in raw.split(",") if f.strip()}
def parse_profile_id(request, default: int = 1) -> int:
"""Extract profile_id from X-Profile-Id header or ?profile_id query param."""
try:
header = request.headers.get("X-Profile-Id")
if header:
return max(1, int(header))
param = request.args.get("profile_id")
if param:
return max(1, int(param))
except (ValueError, TypeError):
pass
return default

View file

@ -1,11 +1,12 @@
"""
Library endpoints browse artists, albums, tracks, and stats.
Library endpoints browse artists, albums, tracks, genres, and stats.
"""
from flask import request, current_app
from database.music_database import get_database
from .auth import require_api_key
from .helpers import api_success, api_error, build_pagination, parse_pagination
from .helpers import api_success, api_error, build_pagination, parse_pagination, parse_fields, parse_profile_id
from .serializers import serialize_artist, serialize_album, serialize_track
def register_routes(bp):
@ -18,6 +19,8 @@ def register_routes(bp):
search = request.args.get("search", "")
letter = request.args.get("letter", "all")
watchlist = request.args.get("watchlist", "all")
fields = parse_fields(request)
profile_id = parse_profile_id(request)
try:
db = get_database()
@ -27,30 +30,34 @@ def register_routes(bp):
page=page,
limit=limit,
watchlist_filter=watchlist,
profile_id=profile_id,
)
artists = result.get("artists", [])
pag = result.get("pagination", {})
pagination = build_pagination(
page, limit, pag.get("total_count", len(artists))
)
return api_success({"artists": artists}, pagination=pagination)
# Artists from get_library_artists are already dicts with external IDs
serialized = [serialize_artist(a, fields) for a in artists]
return api_success({"artists": serialized}, pagination=pagination)
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/artists/<artist_id>", methods=["GET"])
@require_api_key
def get_artist(artist_id):
"""Get a single artist by ID with album list."""
"""Get a single artist by ID with all metadata and album list."""
fields = parse_fields(request)
try:
db = get_database()
artist = db.get_artist(int(artist_id))
artist = db.api_get_artist(int(artist_id))
if not artist:
return api_error("NOT_FOUND", f"Artist {artist_id} not found.", 404)
albums = db.get_albums_by_artist(int(artist_id))
albums = db.api_get_albums_by_artist(int(artist_id))
return api_success({
"artist": _serialize_artist(artist),
"albums": [_serialize_album(a) for a in albums],
"artist": serialize_artist(artist, fields),
"albums": [serialize_album(a, fields) for a in albums],
})
except ValueError:
return api_error("BAD_REQUEST", "artist_id must be an integer.", 400)
@ -60,36 +67,119 @@ def register_routes(bp):
@bp.route("/library/artists/<artist_id>/albums", methods=["GET"])
@require_api_key
def get_artist_albums(artist_id):
"""List albums for an artist."""
"""List albums for an artist with full metadata."""
fields = parse_fields(request)
try:
db = get_database()
albums = db.get_albums_by_artist(int(artist_id))
return api_success({"albums": [_serialize_album(a) for a in albums]})
albums = db.api_get_albums_by_artist(int(artist_id))
return api_success({"albums": [serialize_album(a, fields) for a in albums]})
except ValueError:
return api_error("BAD_REQUEST", "artist_id must be an integer.", 400)
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/albums", methods=["GET"])
@require_api_key
def list_albums():
"""List/search albums with pagination and optional filters."""
page, limit = parse_pagination(request)
search = request.args.get("search", "")
fields = parse_fields(request)
artist_id = request.args.get("artist_id")
year = request.args.get("year")
try:
artist_id_int = int(artist_id) if artist_id else None
except ValueError:
return api_error("BAD_REQUEST", "artist_id must be an integer.", 400)
try:
year_int = int(year) if year else None
except ValueError:
return api_error("BAD_REQUEST", "year must be an integer.", 400)
try:
db = get_database()
result = db.api_list_albums(
search=search,
artist_id=artist_id_int,
year=year_int,
page=page,
limit=limit,
)
albums = result.get("albums", [])
total = result.get("total", 0)
pagination = build_pagination(page, limit, total)
return api_success(
{"albums": [serialize_album(a, fields) for a in albums]},
pagination=pagination,
)
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/albums/<album_id>", methods=["GET"])
@require_api_key
def get_album(album_id):
"""Get a single album by ID with all metadata and embedded tracks."""
fields = parse_fields(request)
try:
db = get_database()
album = db.api_get_album(int(album_id))
if not album:
return api_error("NOT_FOUND", f"Album {album_id} not found.", 404)
tracks = db.api_get_tracks_by_album(int(album_id))
return api_success({
"album": serialize_album(album, fields),
"tracks": [serialize_track(t, fields) for t in tracks],
})
except ValueError:
return api_error("BAD_REQUEST", "album_id must be an integer.", 400)
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/albums/<album_id>/tracks", methods=["GET"])
@require_api_key
def get_album_tracks(album_id):
"""List tracks in an album."""
"""List tracks in an album with full metadata."""
fields = parse_fields(request)
try:
db = get_database()
tracks = db.get_tracks_by_album(int(album_id))
return api_success({"tracks": [_serialize_track(t) for t in tracks]})
tracks = db.api_get_tracks_by_album(int(album_id))
return api_success({"tracks": [serialize_track(t, fields) for t in tracks]})
except ValueError:
return api_error("BAD_REQUEST", "album_id must be an integer.", 400)
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/tracks/<track_id>", methods=["GET"])
@require_api_key
def get_track(track_id):
"""Get a single track by ID with all metadata."""
fields = parse_fields(request)
try:
db = get_database()
track = db.api_get_track(int(track_id))
if not track:
return api_error("NOT_FOUND", f"Track {track_id} not found.", 404)
return api_success({"track": serialize_track(track, fields)})
except ValueError:
return api_error("BAD_REQUEST", "track_id must be an integer.", 400)
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/tracks", methods=["GET"])
@require_api_key
def library_search_tracks():
"""Search tracks by title and/or artist."""
title = request.args.get("title", "")
artist = request.args.get("artist", "")
limit = min(200, max(1, int(request.args.get("limit", 50))))
try:
limit = min(200, max(1, int(request.args.get("limit", 50))))
except (ValueError, TypeError):
limit = 50
fields = parse_fields(request)
if not title and not artist:
return api_error("BAD_REQUEST", "Provide at least 'title' or 'artist' query param.", 400)
@ -97,7 +187,111 @@ def register_routes(bp):
try:
db = get_database()
tracks = db.search_tracks(title=title, artist=artist, limit=limit)
return api_success({"tracks": [_serialize_track(t) for t in tracks]})
if not tracks:
return api_success({"tracks": []})
# Re-query by IDs to get full row data
track_ids = [t.id for t in tracks]
full_tracks = db.api_get_tracks_by_ids(track_ids)
return api_success({"tracks": [serialize_track(t, fields) for t in full_tracks]})
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/genres", methods=["GET"])
@require_api_key
def list_genres():
"""List all genres with occurrence counts.
Query params:
source: 'artists' or 'albums' (default: 'artists')
"""
source = request.args.get("source", "artists")
if source not in ("artists", "albums"):
return api_error("BAD_REQUEST", "source must be 'artists' or 'albums'.", 400)
try:
db = get_database()
genres = db.api_get_genres(table=source)
return api_success({"genres": genres, "source": source})
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/recently-added", methods=["GET"])
@require_api_key
def recently_added():
"""Get recently added content ordered by created_at.
Query params:
type: 'albums', 'artists', or 'tracks' (default: 'albums')
limit: max items to return (default: 50, max: 200)
"""
entity_type = request.args.get("type", "albums")
if entity_type not in ("albums", "artists", "tracks"):
return api_error("BAD_REQUEST", "type must be 'albums', 'artists', or 'tracks'.", 400)
try:
limit = min(200, max(1, int(request.args.get("limit", 50))))
except (ValueError, TypeError):
limit = 50
fields = parse_fields(request)
try:
db = get_database()
items = db.api_get_recently_added(entity_type=entity_type, limit=limit)
serializer = {
"artists": serialize_artist,
"albums": serialize_album,
"tracks": serialize_track,
}[entity_type]
return api_success({
"items": [serializer(item, fields) for item in items],
"type": entity_type,
})
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@bp.route("/library/lookup", methods=["GET"])
@require_api_key
def lookup_by_external_id():
"""Look up a library entity by external provider ID.
Query params:
type: 'artist', 'album', or 'track' (required)
provider: 'spotify', 'musicbrainz', 'itunes', 'deezer', 'audiodb' (required)
id: the external ID value (required)
"""
entity_type = request.args.get("type")
provider = request.args.get("provider")
external_id = request.args.get("id")
fields = parse_fields(request)
if not entity_type or not provider or not external_id:
return api_error("BAD_REQUEST", "Required params: type, provider, id.", 400)
table_map = {"artist": "artists", "album": "albums", "track": "tracks"}
table = table_map.get(entity_type)
if not table:
return api_error("BAD_REQUEST", "type must be 'artist', 'album', or 'track'.", 400)
if provider not in ("spotify", "musicbrainz", "itunes", "deezer", "audiodb"):
return api_error("BAD_REQUEST", "provider must be spotify, musicbrainz, itunes, deezer, or audiodb.", 400)
try:
db = get_database()
result = db.api_lookup_by_external_id(table, provider, external_id)
if not result:
return api_error("NOT_FOUND", f"No {entity_type} found for {provider} ID: {external_id}", 404)
serializer = {
"artists": serialize_artist,
"albums": serialize_album,
"tracks": serialize_track,
}[table]
return api_success({entity_type: serializer(result, fields)})
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
@ -118,41 +312,3 @@ def register_routes(bp):
})
except Exception as e:
return api_error("LIBRARY_ERROR", str(e), 500)
# ---- serialization helpers ----
def _serialize_artist(a):
return {
"id": a.id,
"name": a.name,
"thumb_url": a.thumb_url,
"genres": a.genres or [],
"summary": a.summary,
}
def _serialize_album(a):
return {
"id": a.id,
"artist_id": a.artist_id,
"title": a.title,
"year": a.year,
"thumb_url": a.thumb_url,
"genres": a.genres or [],
"track_count": a.track_count,
"duration": a.duration,
}
def _serialize_track(t):
return {
"id": t.id,
"album_id": t.album_id,
"artist_id": t.artist_id,
"title": t.title,
"track_number": t.track_number,
"duration": t.duration,
"file_path": t.file_path,
"bitrate": t.bitrate,
}

329
api/serializers.py Normal file
View file

@ -0,0 +1,329 @@
"""
Centralized serializers for the SoulSync API v1.
All serializers accept a sqlite3.Row, a dict, or a dataclass instance
and normalize the output to a plain dict. This allows the same serializer
to be used whether the data comes from raw queries or existing methods.
"""
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Set
def _to_dict(obj) -> dict:
"""Convert a sqlite3.Row, dataclass, or dict to a plain dict."""
if isinstance(obj, dict):
return obj
if hasattr(obj, "keys"): # sqlite3.Row
return {k: obj[k] for k in obj.keys()}
if hasattr(obj, "__dataclass_fields__"):
from dataclasses import asdict
return asdict(obj)
raise TypeError(f"Cannot serialize {type(obj)}")
def _parse_genres(raw) -> list:
"""Parse genres from JSON string, list, or comma-separated string."""
if isinstance(raw, list):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, list) else []
except (json.JSONDecodeError, TypeError):
return [g.strip() for g in raw.split(",") if g.strip()]
return []
def _isoformat(val) -> Optional[str]:
"""Safely convert datetime or string to ISO format string."""
if val is None:
return None
if isinstance(val, datetime):
return val.isoformat()
if isinstance(val, str):
return val
return str(val)
def _bool_or_none(val):
"""Convert to bool, returning None if val is None."""
if val is None:
return None
return bool(val)
def filter_fields(data: dict, fields: Optional[Set[str]]) -> dict:
"""If fields set is provided, return only those keys."""
if not fields:
return data
return {k: v for k, v in data.items() if k in fields}
# ── Library Entity Serializers ────────────────────────────────
def serialize_artist(obj, fields: Optional[Set[str]] = None) -> dict:
"""Full artist serialization — all columns."""
d = _to_dict(obj)
result = {
"id": d.get("id"),
"name": d.get("name"),
"thumb_url": d.get("thumb_url"),
"banner_url": d.get("banner_url"),
"genres": _parse_genres(d.get("genres")),
"summary": d.get("summary"),
"style": d.get("style"),
"mood": d.get("mood"),
"label": d.get("label"),
"server_source": d.get("server_source"),
"created_at": _isoformat(d.get("created_at")),
"updated_at": _isoformat(d.get("updated_at")),
# External IDs
"musicbrainz_id": d.get("musicbrainz_id"),
"spotify_artist_id": d.get("spotify_artist_id"),
"itunes_artist_id": d.get("itunes_artist_id"),
"audiodb_id": d.get("audiodb_id"),
"deezer_id": d.get("deezer_id"),
# Match statuses
"musicbrainz_match_status": d.get("musicbrainz_match_status"),
"spotify_match_status": d.get("spotify_match_status"),
"itunes_match_status": d.get("itunes_match_status"),
"audiodb_match_status": d.get("audiodb_match_status"),
"deezer_match_status": d.get("deezer_match_status"),
# Last attempted timestamps
"musicbrainz_last_attempted": _isoformat(d.get("musicbrainz_last_attempted")),
"spotify_last_attempted": _isoformat(d.get("spotify_last_attempted")),
"itunes_last_attempted": _isoformat(d.get("itunes_last_attempted")),
"audiodb_last_attempted": _isoformat(d.get("audiodb_last_attempted")),
"deezer_last_attempted": _isoformat(d.get("deezer_last_attempted")),
}
# Preserve extra keys from enriched queries (album_count, track_count, is_watched)
for extra_key in ("album_count", "track_count", "is_watched", "image_url"):
if extra_key in d:
result[extra_key] = d[extra_key]
return filter_fields(result, fields)
def serialize_album(obj, fields: Optional[Set[str]] = None) -> dict:
"""Full album serialization — all columns."""
d = _to_dict(obj)
result = {
"id": d.get("id"),
"artist_id": d.get("artist_id"),
"title": d.get("title"),
"year": d.get("year"),
"thumb_url": d.get("thumb_url"),
"genres": _parse_genres(d.get("genres")),
"track_count": d.get("track_count"),
"duration": d.get("duration"),
"style": d.get("style"),
"mood": d.get("mood"),
"label": d.get("label"),
"explicit": _bool_or_none(d.get("explicit")),
"record_type": d.get("record_type"),
"server_source": d.get("server_source"),
"created_at": _isoformat(d.get("created_at")),
"updated_at": _isoformat(d.get("updated_at")),
# External IDs
"musicbrainz_release_id": d.get("musicbrainz_release_id"),
"spotify_album_id": d.get("spotify_album_id"),
"itunes_album_id": d.get("itunes_album_id"),
"audiodb_id": d.get("audiodb_id"),
"deezer_id": d.get("deezer_id"),
# Match statuses
"musicbrainz_match_status": d.get("musicbrainz_match_status"),
"spotify_match_status": d.get("spotify_match_status"),
"itunes_match_status": d.get("itunes_match_status"),
"audiodb_match_status": d.get("audiodb_match_status"),
"deezer_match_status": d.get("deezer_match_status"),
# Last attempted timestamps
"musicbrainz_last_attempted": _isoformat(d.get("musicbrainz_last_attempted")),
"spotify_last_attempted": _isoformat(d.get("spotify_last_attempted")),
"itunes_last_attempted": _isoformat(d.get("itunes_last_attempted")),
"audiodb_last_attempted": _isoformat(d.get("audiodb_last_attempted")),
"deezer_last_attempted": _isoformat(d.get("deezer_last_attempted")),
}
return filter_fields(result, fields)
def serialize_track(obj, fields: Optional[Set[str]] = None) -> dict:
"""Full track serialization — all columns."""
d = _to_dict(obj)
result = {
"id": d.get("id"),
"album_id": d.get("album_id"),
"artist_id": d.get("artist_id"),
"title": d.get("title"),
"track_number": d.get("track_number"),
"duration": d.get("duration"),
"file_path": d.get("file_path"),
"bitrate": d.get("bitrate"),
"bpm": d.get("bpm"),
"explicit": _bool_or_none(d.get("explicit")),
"style": d.get("style"),
"mood": d.get("mood"),
"repair_status": d.get("repair_status"),
"repair_last_checked": _isoformat(d.get("repair_last_checked")),
"server_source": d.get("server_source"),
"created_at": _isoformat(d.get("created_at")),
"updated_at": _isoformat(d.get("updated_at")),
# External IDs
"musicbrainz_recording_id": d.get("musicbrainz_recording_id"),
"spotify_track_id": d.get("spotify_track_id"),
"itunes_track_id": d.get("itunes_track_id"),
"audiodb_id": d.get("audiodb_id"),
"deezer_id": d.get("deezer_id"),
# Match statuses
"musicbrainz_match_status": d.get("musicbrainz_match_status"),
"spotify_match_status": d.get("spotify_match_status"),
"itunes_match_status": d.get("itunes_match_status"),
"audiodb_match_status": d.get("audiodb_match_status"),
"deezer_match_status": d.get("deezer_match_status"),
# Last attempted timestamps
"musicbrainz_last_attempted": _isoformat(d.get("musicbrainz_last_attempted")),
"spotify_last_attempted": _isoformat(d.get("spotify_last_attempted")),
"itunes_last_attempted": _isoformat(d.get("itunes_last_attempted")),
"audiodb_last_attempted": _isoformat(d.get("audiodb_last_attempted")),
"deezer_last_attempted": _isoformat(d.get("deezer_last_attempted")),
}
# Preserve extra keys from joined queries (artist_name, album_title)
for extra_key in ("artist_name", "album_title"):
if extra_key in d:
result[extra_key] = d[extra_key]
return filter_fields(result, fields)
# ── Watchlist / Wishlist Serializers ──────────────────────────
def serialize_watchlist_artist(obj, fields: Optional[Set[str]] = None) -> dict:
"""Full watchlist artist serialization — all columns including all content filters."""
d = _to_dict(obj)
result = {
"id": d.get("id"),
"spotify_artist_id": d.get("spotify_artist_id"),
"itunes_artist_id": d.get("itunes_artist_id"),
"artist_name": d.get("artist_name"),
"image_url": d.get("image_url"),
"date_added": _isoformat(d.get("date_added")),
"last_scan_timestamp": _isoformat(d.get("last_scan_timestamp")),
"created_at": _isoformat(d.get("created_at")),
"updated_at": _isoformat(d.get("updated_at")),
"profile_id": d.get("profile_id"),
# Content type filters — ALL of them
"include_albums": bool(d.get("include_albums", True)),
"include_eps": bool(d.get("include_eps", True)),
"include_singles": bool(d.get("include_singles", True)),
"include_live": bool(d.get("include_live", False)),
"include_remixes": bool(d.get("include_remixes", False)),
"include_acoustic": bool(d.get("include_acoustic", False)),
"include_compilations": bool(d.get("include_compilations", False)),
}
return filter_fields(result, fields)
def serialize_wishlist_track(obj, fields: Optional[Set[str]] = None) -> dict:
"""Standardized wishlist track serialization."""
d = _to_dict(obj)
spotify_data = d.get("spotify_data", {})
if isinstance(spotify_data, str):
try:
spotify_data = json.loads(spotify_data)
except (json.JSONDecodeError, TypeError):
spotify_data = {}
source_info = d.get("source_info")
if isinstance(source_info, str):
try:
source_info = json.loads(source_info)
except (json.JSONDecodeError, TypeError):
source_info = None
result = {
"id": d.get("id"),
"spotify_track_id": d.get("spotify_track_id"),
"track_name": spotify_data.get("name", "Unknown") if isinstance(spotify_data, dict) else "Unknown",
"artist_name": ", ".join(
a.get("name", "") for a in spotify_data.get("artists", [])
) if isinstance(spotify_data, dict) and isinstance(spotify_data.get("artists"), list) else "",
"album_name": (
spotify_data.get("album", {}).get("name")
if isinstance(spotify_data, dict) and isinstance(spotify_data.get("album"), dict)
else None
),
"spotify_data": spotify_data,
"failure_reason": d.get("failure_reason"),
"retry_count": d.get("retry_count", 0),
"last_attempted": _isoformat(d.get("last_attempted")),
"date_added": _isoformat(d.get("date_added")),
"source_type": d.get("source_type"),
"source_info": source_info,
"profile_id": d.get("profile_id"),
}
return filter_fields(result, fields)
# ── Discovery Serializers ─────────────────────────────────────
def serialize_discovery_track(obj, fields: Optional[Set[str]] = None) -> dict:
"""Discovery pool track serialization."""
d = _to_dict(obj)
result = {
"id": d.get("id"),
"spotify_track_id": d.get("spotify_track_id"),
"spotify_album_id": d.get("spotify_album_id"),
"spotify_artist_id": d.get("spotify_artist_id"),
"itunes_track_id": d.get("itunes_track_id"),
"itunes_album_id": d.get("itunes_album_id"),
"itunes_artist_id": d.get("itunes_artist_id"),
"source": d.get("source"),
"track_name": d.get("track_name"),
"artist_name": d.get("artist_name"),
"album_name": d.get("album_name"),
"album_cover_url": d.get("album_cover_url"),
"duration_ms": d.get("duration_ms"),
"popularity": d.get("popularity"),
"release_date": d.get("release_date"),
"is_new_release": bool(d.get("is_new_release", False)),
"artist_genres": _parse_genres(d.get("artist_genres")),
"added_date": _isoformat(d.get("added_date")),
}
return filter_fields(result, fields)
def serialize_similar_artist(obj, fields: Optional[Set[str]] = None) -> dict:
"""Similar artist serialization."""
d = _to_dict(obj)
result = {
"id": d.get("id"),
"source_artist_id": d.get("source_artist_id"),
"similar_artist_spotify_id": d.get("similar_artist_spotify_id"),
"similar_artist_itunes_id": d.get("similar_artist_itunes_id"),
"similar_artist_name": d.get("similar_artist_name"),
"similarity_rank": d.get("similarity_rank"),
"occurrence_count": d.get("occurrence_count"),
"last_updated": _isoformat(d.get("last_updated")),
"last_featured": _isoformat(d.get("last_featured")),
}
return filter_fields(result, fields)
def serialize_recent_release(obj, fields: Optional[Set[str]] = None) -> dict:
"""Recent release serialization."""
d = _to_dict(obj)
result = {
"id": d.get("id"),
"watchlist_artist_id": d.get("watchlist_artist_id"),
"album_spotify_id": d.get("album_spotify_id"),
"album_itunes_id": d.get("album_itunes_id"),
"source": d.get("source"),
"album_name": d.get("album_name"),
"release_date": d.get("release_date"),
"album_cover_url": d.get("album_cover_url"),
"track_count": d.get("track_count"),
"added_date": _isoformat(d.get("added_date")),
}
return filter_fields(result, fields)

View file

@ -1,11 +1,12 @@
"""
Watchlist endpoints view, add, remove watched artists, trigger scans.
Watchlist endpoints view, add, remove, update watched artists, trigger scans.
"""
from flask import request, current_app
from database.music_database import get_database
from .auth import require_api_key
from .helpers import api_success, api_error
from .helpers import api_success, api_error, parse_fields, parse_profile_id
from .serializers import serialize_watchlist_artist
def register_routes(bp):
@ -13,12 +14,14 @@ def register_routes(bp):
@bp.route("/watchlist", methods=["GET"])
@require_api_key
def list_watchlist():
"""List all watchlist artists."""
"""List all watchlist artists for the current profile."""
fields = parse_fields(request)
profile_id = parse_profile_id(request)
try:
db = get_database()
artists = db.get_watchlist_artists()
artists = db.get_watchlist_artists(profile_id=profile_id)
return api_success({
"artists": [_serialize_watchlist_artist(a) for a in artists]
"artists": [serialize_watchlist_artist(a, fields) for a in artists]
})
except Exception as e:
return api_error("WATCHLIST_ERROR", str(e), 500)
@ -33,13 +36,14 @@ def register_routes(bp):
body = request.get_json(silent=True) or {}
artist_id = body.get("artist_id")
artist_name = body.get("artist_name")
profile_id = parse_profile_id(request)
if not artist_id or not artist_name:
return api_error("BAD_REQUEST", "Missing 'artist_id' or 'artist_name'.", 400)
try:
db = get_database()
ok = db.add_artist_to_watchlist(artist_id, artist_name)
ok = db.add_artist_to_watchlist(artist_id, artist_name, profile_id=profile_id)
if ok:
return api_success({"message": f"Added {artist_name} to watchlist."}, status=201)
return api_error("INTERNAL_ERROR", "Failed to add artist to watchlist.", 500)
@ -50,15 +54,59 @@ def register_routes(bp):
@require_api_key
def remove_from_watchlist(artist_id):
"""Remove an artist from the watchlist."""
profile_id = parse_profile_id(request)
try:
db = get_database()
ok = db.remove_artist_from_watchlist(artist_id)
ok = db.remove_artist_from_watchlist(artist_id, profile_id=profile_id)
if ok:
return api_success({"message": "Artist removed from watchlist."})
return api_error("NOT_FOUND", "Artist not found in watchlist.", 404)
except Exception as e:
return api_error("WATCHLIST_ERROR", str(e), 500)
@bp.route("/watchlist/<artist_id>", methods=["PATCH"])
@require_api_key
def update_watchlist_filters(artist_id):
"""Update content type filters for a watchlist artist.
Body: {"include_albums": true, "include_live": false, ...}
Accepts any combination of: include_albums, include_eps, include_singles,
include_live, include_remixes, include_acoustic, include_compilations
"""
body = request.get_json(silent=True) or {}
profile_id = parse_profile_id(request)
allowed_fields = {
"include_albums", "include_eps", "include_singles",
"include_live", "include_remixes", "include_acoustic", "include_compilations",
}
updates = {k: v for k, v in body.items() if k in allowed_fields}
if not updates:
return api_error("BAD_REQUEST", f"No valid filter fields provided. Allowed: {', '.join(sorted(allowed_fields))}", 400)
try:
db = get_database()
conn = db._get_connection()
cursor = conn.cursor()
# Build SET clause
set_parts = [f"{k} = ?" for k in updates]
values = [int(bool(v)) for v in updates.values()]
cursor.execute(f"""
UPDATE watchlist_artists
SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP
WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ?
""", values + [artist_id, artist_id, profile_id])
if cursor.rowcount > 0:
conn.commit()
return api_success({"message": "Watchlist filters updated.", "updated": updates})
return api_error("NOT_FOUND", "Artist not found in watchlist.", 404)
except Exception as e:
return api_error("WATCHLIST_ERROR", str(e), 500)
@bp.route("/watchlist/scan", methods=["POST"])
@require_api_key
def trigger_scan():
@ -75,18 +123,3 @@ def register_routes(bp):
return api_error("NOT_AVAILABLE", "Watchlist scan function not available.", 501)
except Exception as e:
return api_error("WATCHLIST_ERROR", str(e), 500)
def _serialize_watchlist_artist(a):
return {
"id": a.id,
"spotify_artist_id": a.spotify_artist_id,
"itunes_artist_id": a.itunes_artist_id,
"artist_name": a.artist_name,
"image_url": a.image_url,
"date_added": a.date_added.isoformat() if a.date_added else None,
"last_scan_timestamp": a.last_scan_timestamp.isoformat() if a.last_scan_timestamp else None,
"include_albums": a.include_albums,
"include_eps": a.include_eps,
"include_singles": a.include_singles,
}

View file

@ -4,7 +4,8 @@ Wishlist endpoints — view, add, remove, and trigger processing.
from flask import request
from .auth import require_api_key
from .helpers import api_success, api_error, parse_pagination, build_pagination
from .helpers import api_success, api_error, parse_pagination, build_pagination, parse_fields, parse_profile_id
from .serializers import serialize_wishlist_track
def register_routes(bp):
@ -12,14 +13,16 @@ def register_routes(bp):
@bp.route("/wishlist", methods=["GET"])
@require_api_key
def list_wishlist():
"""List wishlist tracks with optional category filter."""
"""List wishlist tracks with optional category filter and standardized format."""
category = request.args.get("category") # "singles" or "albums"
page, limit = parse_pagination(request)
fields = parse_fields(request)
profile_id = parse_profile_id(request)
try:
from core.wishlist_service import get_wishlist_service
service = get_wishlist_service()
raw_tracks = service.get_wishlist_tracks_for_download()
from database.music_database import get_database
db = get_database()
raw_tracks = db.get_wishlist_tracks(profile_id=profile_id)
# Category filter
if category in ("singles", "albums"):
@ -33,7 +36,7 @@ def register_routes(bp):
tracks = raw_tracks[start:start + limit]
return api_success(
{"tracks": tracks},
{"tracks": [serialize_wishlist_track(t, fields) for t in tracks]},
pagination=build_pagination(page, limit, total),
)
except Exception as e:
@ -50,6 +53,7 @@ def register_routes(bp):
track_data = body.get("spotify_track_data")
reason = body.get("failure_reason", "Added via API")
source_type = body.get("source_type", "api")
profile_id = parse_profile_id(request)
if not track_data:
return api_error("BAD_REQUEST", "Missing 'spotify_track_data' in body.", 400)
@ -57,7 +61,12 @@ def register_routes(bp):
try:
from database.music_database import get_database
db = get_database()
ok = db.add_to_wishlist(track_data, failure_reason=reason, source_type=source_type)
ok = db.add_to_wishlist(
track_data,
failure_reason=reason,
source_type=source_type,
profile_id=profile_id,
)
if ok:
return api_success({"message": "Track added to wishlist."}, status=201)
return api_error("CONFLICT", "Track may already be in wishlist.", 409)
@ -68,10 +77,11 @@ def register_routes(bp):
@require_api_key
def remove_from_wishlist(track_id):
"""Remove a track from the wishlist by its Spotify track ID."""
profile_id = parse_profile_id(request)
try:
from database.music_database import get_database
db = get_database()
ok = db.remove_from_wishlist(track_id)
ok = db.remove_from_wishlist(track_id, profile_id=profile_id)
if ok:
return api_success({"message": "Track removed from wishlist."})
return api_error("NOT_FOUND", "Track not found in wishlist.", 404)

View file

@ -5364,6 +5364,8 @@ class MusicDatabase:
id=row['id'],
watchlist_artist_id=row['watchlist_artist_id'],
album_spotify_id=row['album_spotify_id'],
album_itunes_id=row['album_itunes_id'] if 'album_itunes_id' in row.keys() else None,
source=row['source'] if 'source' in row.keys() else 'spotify',
album_name=row['album_name'],
release_date=row['release_date'],
album_cover_url=row['album_cover_url'],
@ -6111,6 +6113,232 @@ class MusicDatabase:
logger.error(f"Error clearing all retag groups: {e}")
return 0
# ── Full-row API query methods (return dicts, not dataclasses) ────────
def api_get_artist(self, artist_id: int) -> Optional[Dict[str, Any]]:
"""Get artist by ID with ALL columns as a dict."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM artists WHERE id = ?", (artist_id,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"API: Error getting artist {artist_id}: {e}")
return None
def api_get_album(self, album_id: int) -> Optional[Dict[str, Any]]:
"""Get album by ID with ALL columns as a dict."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM albums WHERE id = ?", (album_id,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"API: Error getting album {album_id}: {e}")
return None
def api_get_track(self, track_id: int) -> Optional[Dict[str, Any]]:
"""Get track by ID with ALL columns as a dict, plus artist_name and album_title."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.*, a.name as artist_name, al.title as album_title
FROM tracks t
LEFT JOIN artists a ON t.artist_id = a.id
LEFT JOIN albums al ON t.album_id = al.id
WHERE t.id = ?
""", (track_id,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"API: Error getting track {track_id}: {e}")
return None
def api_get_albums_by_artist(self, artist_id: int) -> List[Dict[str, Any]]:
"""Get all albums for an artist with ALL columns."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM albums WHERE artist_id = ? ORDER BY year, title",
(artist_id,),
)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"API: Error getting albums for artist {artist_id}: {e}")
return []
def api_get_tracks_by_album(self, album_id: int) -> List[Dict[str, Any]]:
"""Get all tracks for an album with ALL columns, plus artist_name."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.*, a.name as artist_name
FROM tracks t
LEFT JOIN artists a ON t.artist_id = a.id
WHERE t.album_id = ?
ORDER BY t.track_number, t.title
""", (album_id,))
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"API: Error getting tracks for album {album_id}: {e}")
return []
def api_get_tracks_by_ids(self, track_ids: List[int]) -> List[Dict[str, Any]]:
"""Get multiple tracks by ID with ALL columns, plus artist_name and album_title."""
if not track_ids:
return []
try:
conn = self._get_connection()
cursor = conn.cursor()
placeholders = ",".join("?" * len(track_ids))
cursor.execute(f"""
SELECT t.*, a.name as artist_name, al.title as album_title
FROM tracks t
LEFT JOIN artists a ON t.artist_id = a.id
LEFT JOIN albums al ON t.album_id = al.id
WHERE t.id IN ({placeholders})
""", track_ids)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"API: Error getting tracks by IDs: {e}")
return []
def api_lookup_by_external_id(self, table: str, provider: str, external_id: str) -> Optional[Dict[str, Any]]:
"""Look up an entity by external provider ID.
Args:
table: 'artists', 'albums', or 'tracks'
provider: 'spotify', 'musicbrainz', 'itunes', 'deezer', 'audiodb'
"""
column_map = {
"artists": {
"spotify": "spotify_artist_id",
"musicbrainz": "musicbrainz_id",
"itunes": "itunes_artist_id",
"deezer": "deezer_id",
"audiodb": "audiodb_id",
},
"albums": {
"spotify": "spotify_album_id",
"musicbrainz": "musicbrainz_release_id",
"itunes": "itunes_album_id",
"deezer": "deezer_id",
"audiodb": "audiodb_id",
},
"tracks": {
"spotify": "spotify_track_id",
"musicbrainz": "musicbrainz_recording_id",
"itunes": "itunes_track_id",
"deezer": "deezer_id",
"audiodb": "audiodb_id",
},
}
if table not in column_map or provider not in column_map[table]:
return None
column = column_map[table][provider]
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(f"SELECT * FROM {table} WHERE {column} = ?", (external_id,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"API: External lookup {table}.{column}={external_id}: {e}")
return None
def api_get_genres(self, table: str = "artists") -> List[Dict[str, Any]]:
"""Get all unique genres with counts from the given table."""
if table not in ("artists", "albums"):
return []
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(f"SELECT genres FROM {table}")
genre_counts: Dict[str, int] = {}
for row in cursor.fetchall():
raw = row["genres"]
if raw:
try:
genres = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(genres, list):
for g in genres:
g = g.strip() if isinstance(g, str) else str(g)
if g:
genre_counts[g] = genre_counts.get(g, 0) + 1
except (json.JSONDecodeError, TypeError):
pass
return sorted(
[{"name": k, "count": v} for k, v in genre_counts.items()],
key=lambda x: x["count"],
reverse=True,
)
except Exception as e:
logger.error(f"API: Error getting genres from {table}: {e}")
return []
def api_get_recently_added(self, entity_type: str = "albums", limit: int = 50) -> List[Dict[str, Any]]:
"""Get recently added entities, ordered by created_at DESC."""
table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type)
if not table:
return []
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(f"SELECT * FROM {table} ORDER BY created_at DESC LIMIT ?", (limit,))
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"API: Error getting recently added {entity_type}: {e}")
return []
def api_list_albums(self, search: str = "", artist_id: int = None,
year: int = None, page: int = 1, limit: int = 50) -> Dict[str, Any]:
"""List/search albums with pagination, returning full rows."""
try:
conn = self._get_connection()
cursor = conn.cursor()
where_parts = []
params: list = []
if search:
where_parts.append("LOWER(al.title) LIKE LOWER(?)")
params.append(f"%{search}%")
if artist_id is not None:
where_parts.append("al.artist_id = ?")
params.append(artist_id)
if year is not None:
where_parts.append("al.year = ?")
params.append(year)
where_clause = " AND ".join(where_parts) if where_parts else "1=1"
# Count
cursor.execute(f"SELECT COUNT(*) as cnt FROM albums al WHERE {where_clause}", params)
total = cursor.fetchone()["cnt"]
# Fetch page
offset = (page - 1) * limit
cursor.execute(
f"""SELECT al.*, a.name as artist_name
FROM albums al
LEFT JOIN artists a ON al.artist_id = a.id
WHERE {where_clause}
ORDER BY al.title COLLATE NOCASE
LIMIT ? OFFSET ?""",
params + [limit, offset],
)
albums = [dict(row) for row in cursor.fetchall()]
return {"albums": albums, "total": total}
except Exception as e:
logger.error(f"API: Error listing albums: {e}")
return {"albums": [], "total": 0}
# Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
_database_lock = threading.Lock()