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 .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
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
|
||||
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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 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
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 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue