Enrich the SoulSync API
This commit is contained in:
parent
114af496c7
commit
86a502f556
10 changed files with 2235 additions and 160 deletions
1252
Support/API.md
1252
Support/API.md
File diff suppressed because it is too large
Load diff
|
|
@ -37,6 +37,7 @@ def create_api_blueprint():
|
||||||
from .downloads import register_routes as reg_downloads
|
from .downloads import register_routes as reg_downloads
|
||||||
from .playlists import register_routes as reg_playlists
|
from .playlists import register_routes as reg_playlists
|
||||||
from .settings import register_routes as reg_settings
|
from .settings import register_routes as reg_settings
|
||||||
|
from .discover import register_routes as reg_discover
|
||||||
|
|
||||||
reg_library(bp)
|
reg_library(bp)
|
||||||
reg_system(bp)
|
reg_system(bp)
|
||||||
|
|
@ -46,6 +47,7 @@ def create_api_blueprint():
|
||||||
reg_downloads(bp)
|
reg_downloads(bp)
|
||||||
reg_playlists(bp)
|
reg_playlists(bp)
|
||||||
reg_settings(bp)
|
reg_settings(bp)
|
||||||
|
reg_discover(bp)
|
||||||
|
|
||||||
# ---- error handlers (scoped to this Blueprint) ----
|
# ---- error handlers (scoped to this Blueprint) ----
|
||||||
@bp.errorhandler(400)
|
@bp.errorhandler(400)
|
||||||
|
|
|
||||||
153
api/discover.py
Normal file
153
api/discover.py
Normal 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)
|
||||||
|
|
@ -7,6 +7,34 @@ from .auth import require_api_key
|
||||||
from .helpers import api_success, api_error
|
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):
|
def register_routes(bp):
|
||||||
|
|
||||||
@bp.route("/downloads", methods=["GET"])
|
@bp.route("/downloads", methods=["GET"])
|
||||||
|
|
@ -19,18 +47,7 @@ def register_routes(bp):
|
||||||
tasks = []
|
tasks = []
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
for task_id, task in download_tasks.items():
|
for task_id, task in download_tasks.items():
|
||||||
tasks.append({
|
tasks.append(_serialize_download(task_id, task))
|
||||||
"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"),
|
|
||||||
})
|
|
||||||
|
|
||||||
return api_success({"downloads": tasks})
|
return api_success({"downloads": tasks})
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
Shared response helpers for the SoulSync public API.
|
Shared response helpers for the SoulSync public API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Set
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,3 +50,25 @@ def parse_pagination(request, default_limit=50, max_limit=200):
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
limit = default_limit
|
limit = default_limit
|
||||||
return page, 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
|
||||||
|
|
|
||||||
264
api/library.py
264
api/library.py
|
|
@ -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 flask import request, current_app
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
from .auth import require_api_key
|
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):
|
def register_routes(bp):
|
||||||
|
|
@ -18,6 +19,8 @@ def register_routes(bp):
|
||||||
search = request.args.get("search", "")
|
search = request.args.get("search", "")
|
||||||
letter = request.args.get("letter", "all")
|
letter = request.args.get("letter", "all")
|
||||||
watchlist = request.args.get("watchlist", "all")
|
watchlist = request.args.get("watchlist", "all")
|
||||||
|
fields = parse_fields(request)
|
||||||
|
profile_id = parse_profile_id(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db = get_database()
|
db = get_database()
|
||||||
|
|
@ -27,30 +30,34 @@ def register_routes(bp):
|
||||||
page=page,
|
page=page,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
watchlist_filter=watchlist,
|
watchlist_filter=watchlist,
|
||||||
|
profile_id=profile_id,
|
||||||
)
|
)
|
||||||
artists = result.get("artists", [])
|
artists = result.get("artists", [])
|
||||||
pag = result.get("pagination", {})
|
pag = result.get("pagination", {})
|
||||||
pagination = build_pagination(
|
pagination = build_pagination(
|
||||||
page, limit, pag.get("total_count", len(artists))
|
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:
|
except Exception as e:
|
||||||
return api_error("LIBRARY_ERROR", str(e), 500)
|
return api_error("LIBRARY_ERROR", str(e), 500)
|
||||||
|
|
||||||
@bp.route("/library/artists/<artist_id>", methods=["GET"])
|
@bp.route("/library/artists/<artist_id>", methods=["GET"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def get_artist(artist_id):
|
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:
|
try:
|
||||||
db = get_database()
|
db = get_database()
|
||||||
artist = db.get_artist(int(artist_id))
|
artist = db.api_get_artist(int(artist_id))
|
||||||
if not artist:
|
if not artist:
|
||||||
return api_error("NOT_FOUND", f"Artist {artist_id} not found.", 404)
|
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({
|
return api_success({
|
||||||
"artist": _serialize_artist(artist),
|
"artist": serialize_artist(artist, fields),
|
||||||
"albums": [_serialize_album(a) for a in albums],
|
"albums": [serialize_album(a, fields) for a in albums],
|
||||||
})
|
})
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return api_error("BAD_REQUEST", "artist_id must be an integer.", 400)
|
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"])
|
@bp.route("/library/artists/<artist_id>/albums", methods=["GET"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def get_artist_albums(artist_id):
|
def get_artist_albums(artist_id):
|
||||||
"""List albums for an artist."""
|
"""List albums for an artist with full metadata."""
|
||||||
|
fields = parse_fields(request)
|
||||||
try:
|
try:
|
||||||
db = get_database()
|
db = get_database()
|
||||||
albums = db.get_albums_by_artist(int(artist_id))
|
albums = db.api_get_albums_by_artist(int(artist_id))
|
||||||
return api_success({"albums": [_serialize_album(a) for a in albums]})
|
return api_success({"albums": [serialize_album(a, fields) for a in albums]})
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return api_error("BAD_REQUEST", "artist_id must be an integer.", 400)
|
return api_error("BAD_REQUEST", "artist_id must be an integer.", 400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error("LIBRARY_ERROR", str(e), 500)
|
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"])
|
@bp.route("/library/albums/<album_id>/tracks", methods=["GET"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def get_album_tracks(album_id):
|
def get_album_tracks(album_id):
|
||||||
"""List tracks in an album."""
|
"""List tracks in an album with full metadata."""
|
||||||
|
fields = parse_fields(request)
|
||||||
try:
|
try:
|
||||||
db = get_database()
|
db = get_database()
|
||||||
tracks = db.get_tracks_by_album(int(album_id))
|
tracks = db.api_get_tracks_by_album(int(album_id))
|
||||||
return api_success({"tracks": [_serialize_track(t) for t in tracks]})
|
return api_success({"tracks": [serialize_track(t, fields) for t in tracks]})
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return api_error("BAD_REQUEST", "album_id must be an integer.", 400)
|
return api_error("BAD_REQUEST", "album_id must be an integer.", 400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error("LIBRARY_ERROR", str(e), 500)
|
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"])
|
@bp.route("/library/tracks", methods=["GET"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def library_search_tracks():
|
def library_search_tracks():
|
||||||
"""Search tracks by title and/or artist."""
|
"""Search tracks by title and/or artist."""
|
||||||
title = request.args.get("title", "")
|
title = request.args.get("title", "")
|
||||||
artist = request.args.get("artist", "")
|
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:
|
if not title and not artist:
|
||||||
return api_error("BAD_REQUEST", "Provide at least 'title' or 'artist' query param.", 400)
|
return api_error("BAD_REQUEST", "Provide at least 'title' or 'artist' query param.", 400)
|
||||||
|
|
@ -97,7 +187,111 @@ def register_routes(bp):
|
||||||
try:
|
try:
|
||||||
db = get_database()
|
db = get_database()
|
||||||
tracks = db.search_tracks(title=title, artist=artist, limit=limit)
|
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:
|
except Exception as e:
|
||||||
return api_error("LIBRARY_ERROR", str(e), 500)
|
return api_error("LIBRARY_ERROR", str(e), 500)
|
||||||
|
|
||||||
|
|
@ -118,41 +312,3 @@ def register_routes(bp):
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error("LIBRARY_ERROR", str(e), 500)
|
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
329
api/serializers.py
Normal 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)
|
||||||
|
|
@ -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 flask import request, current_app
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
from .auth import require_api_key
|
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):
|
def register_routes(bp):
|
||||||
|
|
@ -13,12 +14,14 @@ def register_routes(bp):
|
||||||
@bp.route("/watchlist", methods=["GET"])
|
@bp.route("/watchlist", methods=["GET"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def list_watchlist():
|
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:
|
try:
|
||||||
db = get_database()
|
db = get_database()
|
||||||
artists = db.get_watchlist_artists()
|
artists = db.get_watchlist_artists(profile_id=profile_id)
|
||||||
return api_success({
|
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:
|
except Exception as e:
|
||||||
return api_error("WATCHLIST_ERROR", str(e), 500)
|
return api_error("WATCHLIST_ERROR", str(e), 500)
|
||||||
|
|
@ -33,13 +36,14 @@ def register_routes(bp):
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
artist_id = body.get("artist_id")
|
artist_id = body.get("artist_id")
|
||||||
artist_name = body.get("artist_name")
|
artist_name = body.get("artist_name")
|
||||||
|
profile_id = parse_profile_id(request)
|
||||||
|
|
||||||
if not artist_id or not artist_name:
|
if not artist_id or not artist_name:
|
||||||
return api_error("BAD_REQUEST", "Missing 'artist_id' or 'artist_name'.", 400)
|
return api_error("BAD_REQUEST", "Missing 'artist_id' or 'artist_name'.", 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db = get_database()
|
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:
|
if ok:
|
||||||
return api_success({"message": f"Added {artist_name} to watchlist."}, status=201)
|
return api_success({"message": f"Added {artist_name} to watchlist."}, status=201)
|
||||||
return api_error("INTERNAL_ERROR", "Failed to add artist to watchlist.", 500)
|
return api_error("INTERNAL_ERROR", "Failed to add artist to watchlist.", 500)
|
||||||
|
|
@ -50,15 +54,59 @@ def register_routes(bp):
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def remove_from_watchlist(artist_id):
|
def remove_from_watchlist(artist_id):
|
||||||
"""Remove an artist from the watchlist."""
|
"""Remove an artist from the watchlist."""
|
||||||
|
profile_id = parse_profile_id(request)
|
||||||
try:
|
try:
|
||||||
db = get_database()
|
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:
|
if ok:
|
||||||
return api_success({"message": "Artist removed from watchlist."})
|
return api_success({"message": "Artist removed from watchlist."})
|
||||||
return api_error("NOT_FOUND", "Artist not found in watchlist.", 404)
|
return api_error("NOT_FOUND", "Artist not found in watchlist.", 404)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error("WATCHLIST_ERROR", str(e), 500)
|
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"])
|
@bp.route("/watchlist/scan", methods=["POST"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def trigger_scan():
|
def trigger_scan():
|
||||||
|
|
@ -75,18 +123,3 @@ def register_routes(bp):
|
||||||
return api_error("NOT_AVAILABLE", "Watchlist scan function not available.", 501)
|
return api_error("NOT_AVAILABLE", "Watchlist scan function not available.", 501)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error("WATCHLIST_ERROR", str(e), 500)
|
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,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ Wishlist endpoints — view, add, remove, and trigger processing.
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from .auth import require_api_key
|
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):
|
def register_routes(bp):
|
||||||
|
|
@ -12,14 +13,16 @@ def register_routes(bp):
|
||||||
@bp.route("/wishlist", methods=["GET"])
|
@bp.route("/wishlist", methods=["GET"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def list_wishlist():
|
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"
|
category = request.args.get("category") # "singles" or "albums"
|
||||||
page, limit = parse_pagination(request)
|
page, limit = parse_pagination(request)
|
||||||
|
fields = parse_fields(request)
|
||||||
|
profile_id = parse_profile_id(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from core.wishlist_service import get_wishlist_service
|
from database.music_database import get_database
|
||||||
service = get_wishlist_service()
|
db = get_database()
|
||||||
raw_tracks = service.get_wishlist_tracks_for_download()
|
raw_tracks = db.get_wishlist_tracks(profile_id=profile_id)
|
||||||
|
|
||||||
# Category filter
|
# Category filter
|
||||||
if category in ("singles", "albums"):
|
if category in ("singles", "albums"):
|
||||||
|
|
@ -33,7 +36,7 @@ def register_routes(bp):
|
||||||
tracks = raw_tracks[start:start + limit]
|
tracks = raw_tracks[start:start + limit]
|
||||||
|
|
||||||
return api_success(
|
return api_success(
|
||||||
{"tracks": tracks},
|
{"tracks": [serialize_wishlist_track(t, fields) for t in tracks]},
|
||||||
pagination=build_pagination(page, limit, total),
|
pagination=build_pagination(page, limit, total),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -50,6 +53,7 @@ def register_routes(bp):
|
||||||
track_data = body.get("spotify_track_data")
|
track_data = body.get("spotify_track_data")
|
||||||
reason = body.get("failure_reason", "Added via API")
|
reason = body.get("failure_reason", "Added via API")
|
||||||
source_type = body.get("source_type", "api")
|
source_type = body.get("source_type", "api")
|
||||||
|
profile_id = parse_profile_id(request)
|
||||||
|
|
||||||
if not track_data:
|
if not track_data:
|
||||||
return api_error("BAD_REQUEST", "Missing 'spotify_track_data' in body.", 400)
|
return api_error("BAD_REQUEST", "Missing 'spotify_track_data' in body.", 400)
|
||||||
|
|
@ -57,7 +61,12 @@ def register_routes(bp):
|
||||||
try:
|
try:
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
db = 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:
|
if ok:
|
||||||
return api_success({"message": "Track added to wishlist."}, status=201)
|
return api_success({"message": "Track added to wishlist."}, status=201)
|
||||||
return api_error("CONFLICT", "Track may already be in wishlist.", 409)
|
return api_error("CONFLICT", "Track may already be in wishlist.", 409)
|
||||||
|
|
@ -68,10 +77,11 @@ def register_routes(bp):
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def remove_from_wishlist(track_id):
|
def remove_from_wishlist(track_id):
|
||||||
"""Remove a track from the wishlist by its Spotify track ID."""
|
"""Remove a track from the wishlist by its Spotify track ID."""
|
||||||
|
profile_id = parse_profile_id(request)
|
||||||
try:
|
try:
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
db = 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:
|
if ok:
|
||||||
return api_success({"message": "Track removed from wishlist."})
|
return api_success({"message": "Track removed from wishlist."})
|
||||||
return api_error("NOT_FOUND", "Track not found in wishlist.", 404)
|
return api_error("NOT_FOUND", "Track not found in wishlist.", 404)
|
||||||
|
|
|
||||||
|
|
@ -5364,6 +5364,8 @@ class MusicDatabase:
|
||||||
id=row['id'],
|
id=row['id'],
|
||||||
watchlist_artist_id=row['watchlist_artist_id'],
|
watchlist_artist_id=row['watchlist_artist_id'],
|
||||||
album_spotify_id=row['album_spotify_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'],
|
album_name=row['album_name'],
|
||||||
release_date=row['release_date'],
|
release_date=row['release_date'],
|
||||||
album_cover_url=row['album_cover_url'],
|
album_cover_url=row['album_cover_url'],
|
||||||
|
|
@ -6111,6 +6113,232 @@ class MusicDatabase:
|
||||||
logger.error(f"Error clearing all retag groups: {e}")
|
logger.error(f"Error clearing all retag groups: {e}")
|
||||||
return 0
|
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
|
# Thread-safe singleton pattern for database access
|
||||||
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
|
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
|
||||||
_database_lock = threading.Lock()
|
_database_lock = threading.Lock()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue