Add interactive REST API docs with full endpoint tester and complete metadata serialization

This commit is contained in:
Broque Thomas 2026-03-13 17:45:01 -07:00
parent d4d1b8c7a0
commit d4eadef374
5 changed files with 1152 additions and 329 deletions

View file

@ -260,7 +260,7 @@ def register_routes(bp):
Query params:
type: 'artist', 'album', or 'track' (required)
provider: 'spotify', 'musicbrainz', 'itunes', 'deezer', 'audiodb' (required)
provider: 'spotify', 'musicbrainz', 'itunes', 'deezer', 'audiodb', 'tidal', 'qobuz', 'genius' (required)
id: the external ID value (required)
"""
entity_type = request.args.get("type")
@ -276,8 +276,12 @@ def register_routes(bp):
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)
# genius only exists on artists and tracks, not albums
valid_providers = ("spotify", "musicbrainz", "itunes", "deezer", "audiodb", "tidal", "qobuz", "genius")
if provider not in valid_providers:
return api_error("BAD_REQUEST", f"provider must be one of: {', '.join(valid_providers)}.", 400)
if provider == "genius" and entity_type == "album":
return api_error("BAD_REQUEST", "Genius IDs are not available for albums. Use artist or track.", 400)
try:
db = get_database()

View file

@ -86,18 +86,40 @@ def serialize_artist(obj, fields: Optional[Set[str]] = None) -> dict:
"itunes_artist_id": d.get("itunes_artist_id"),
"audiodb_id": d.get("audiodb_id"),
"deezer_id": d.get("deezer_id"),
"tidal_id": d.get("tidal_id"),
"qobuz_id": d.get("qobuz_id"),
"genius_id": d.get("genius_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"),
"lastfm_match_status": d.get("lastfm_match_status"),
"genius_match_status": d.get("genius_match_status"),
"tidal_match_status": d.get("tidal_match_status"),
"qobuz_match_status": d.get("qobuz_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")),
"lastfm_last_attempted": _isoformat(d.get("lastfm_last_attempted")),
"genius_last_attempted": _isoformat(d.get("genius_last_attempted")),
"tidal_last_attempted": _isoformat(d.get("tidal_last_attempted")),
"qobuz_last_attempted": _isoformat(d.get("qobuz_last_attempted")),
# Last.fm metadata
"lastfm_listeners": d.get("lastfm_listeners"),
"lastfm_playcount": d.get("lastfm_playcount"),
"lastfm_tags": d.get("lastfm_tags"),
"lastfm_similar": d.get("lastfm_similar"),
"lastfm_bio": d.get("lastfm_bio"),
"lastfm_url": d.get("lastfm_url"),
# Genius metadata
"genius_description": d.get("genius_description"),
"genius_alt_names": d.get("genius_alt_names"),
"genius_url": d.get("genius_url"),
}
# Preserve extra keys from enriched queries (album_count, track_count, is_watched)
for extra_key in ("album_count", "track_count", "is_watched", "image_url"):
@ -126,24 +148,40 @@ def serialize_album(obj, fields: Optional[Set[str]] = None) -> dict:
"server_source": d.get("server_source"),
"created_at": _isoformat(d.get("created_at")),
"updated_at": _isoformat(d.get("updated_at")),
"upc": d.get("upc"),
"copyright": d.get("copyright"),
# 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"),
"tidal_id": d.get("tidal_id"),
"qobuz_id": d.get("qobuz_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"),
"lastfm_match_status": d.get("lastfm_match_status"),
"tidal_match_status": d.get("tidal_match_status"),
"qobuz_match_status": d.get("qobuz_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")),
"lastfm_last_attempted": _isoformat(d.get("lastfm_last_attempted")),
"tidal_last_attempted": _isoformat(d.get("tidal_last_attempted")),
"qobuz_last_attempted": _isoformat(d.get("qobuz_last_attempted")),
# Last.fm metadata
"lastfm_listeners": d.get("lastfm_listeners"),
"lastfm_playcount": d.get("lastfm_playcount"),
"lastfm_tags": d.get("lastfm_tags"),
"lastfm_wiki": d.get("lastfm_wiki"),
"lastfm_url": d.get("lastfm_url"),
}
return filter_fields(result, fields)
@ -169,24 +207,46 @@ def serialize_track(obj, fields: Optional[Set[str]] = None) -> dict:
"server_source": d.get("server_source"),
"created_at": _isoformat(d.get("created_at")),
"updated_at": _isoformat(d.get("updated_at")),
"isrc": d.get("isrc"),
"copyright": d.get("copyright"),
# 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"),
"tidal_id": d.get("tidal_id"),
"qobuz_id": d.get("qobuz_id"),
"genius_id": d.get("genius_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"),
"lastfm_match_status": d.get("lastfm_match_status"),
"genius_match_status": d.get("genius_match_status"),
"tidal_match_status": d.get("tidal_match_status"),
"qobuz_match_status": d.get("qobuz_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")),
"lastfm_last_attempted": _isoformat(d.get("lastfm_last_attempted")),
"genius_last_attempted": _isoformat(d.get("genius_last_attempted")),
"tidal_last_attempted": _isoformat(d.get("tidal_last_attempted")),
"qobuz_last_attempted": _isoformat(d.get("qobuz_last_attempted")),
# Last.fm metadata
"lastfm_listeners": d.get("lastfm_listeners"),
"lastfm_playcount": d.get("lastfm_playcount"),
"lastfm_tags": d.get("lastfm_tags"),
"lastfm_url": d.get("lastfm_url"),
# Genius metadata
"genius_lyrics": d.get("genius_lyrics"),
"genius_description": d.get("genius_description"),
"genius_url": d.get("genius_url"),
}
# Preserve extra keys from joined queries (artist_name, album_title)
for extra_key in ("artist_name", "album_title"):

View file

@ -7141,7 +7141,8 @@ class MusicDatabase:
Args:
table: 'artists', 'albums', or 'tracks'
provider: 'spotify', 'musicbrainz', 'itunes', 'deezer', 'audiodb'
provider: 'spotify', 'musicbrainz', 'itunes', 'deezer', 'audiodb',
'tidal', 'qobuz', 'genius' (genius: artists/tracks only)
"""
column_map = {
"artists": {
@ -7150,6 +7151,9 @@ class MusicDatabase:
"itunes": "itunes_artist_id",
"deezer": "deezer_id",
"audiodb": "audiodb_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
"genius": "genius_id",
},
"albums": {
"spotify": "spotify_album_id",
@ -7157,6 +7161,8 @@ class MusicDatabase:
"itunes": "itunes_album_id",
"deezer": "deezer_id",
"audiodb": "audiodb_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
},
"tracks": {
"spotify": "spotify_track_id",
@ -7164,6 +7170,9 @@ class MusicDatabase:
"itunes": "itunes_track_id",
"deezer": "deezer_id",
"audiodb": "audiodb_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
"genius": "genius_id",
},
}
if table not in column_map or provider not in column_map[table]:

File diff suppressed because it is too large Load diff

View file

@ -35612,6 +35612,383 @@ tr.tag-diff-same {
margin: 16px 0 4px;
}
/* ── API Tester ── */
.api-key-bar {
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 20px;
}
.api-key-bar label {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.api-key-bar input {
flex: 1;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 7px 12px;
color: rgba(255, 255, 255, 0.8);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
}
.api-key-bar input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.api-key-bar .api-key-status {
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
white-space: nowrap;
}
.api-key-bar .api-key-status.connected {
color: rgba(var(--accent-rgb), 0.8);
}
.api-endpoint-group {
margin-bottom: 28px;
}
.api-endpoint-group-title {
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.45);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.api-endpoint {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
margin-bottom: 8px;
overflow: hidden;
transition: border-color 0.2s ease;
}
.api-endpoint:hover {
border-color: rgba(255, 255, 255, 0.12);
}
.api-endpoint.expanded {
border-color: rgba(var(--accent-rgb), 0.25);
}
.api-endpoint-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
transition: background 0.15s ease;
}
.api-endpoint-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.api-method {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 3px 8px;
border-radius: 4px;
min-width: 52px;
text-align: center;
flex-shrink: 0;
}
.api-method.get { background: rgba(72, 199, 142, 0.15); color: #48c78e; }
.api-method.post { background: rgba(62, 142, 208, 0.15); color: #3e8ed0; }
.api-method.put { background: rgba(255, 183, 77, 0.15); color: #ffb74d; }
.api-method.patch { background: rgba(171, 130, 255, 0.15); color: #ab82ff; }
.api-method.delete { background: rgba(241, 70, 104, 0.15); color: #f14668; }
.api-endpoint-path {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12.5px;
color: rgba(255, 255, 255, 0.85);
flex-shrink: 0;
}
.api-endpoint-desc {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
margin-left: auto;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.api-endpoint-arrow {
color: rgba(255, 255, 255, 0.2);
font-size: 10px;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.api-endpoint.expanded .api-endpoint-arrow {
transform: rotate(90deg);
}
.api-endpoint-body {
display: none;
padding: 0 14px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.api-endpoint.expanded .api-endpoint-body {
display: block;
}
.api-endpoint-detail {
margin-bottom: 14px;
}
.api-detail-label {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 14px 0 6px;
}
.api-params-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.api-params-table th {
text-align: left;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.35);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 6px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.api-params-table td {
padding: 6px 10px;
color: rgba(255, 255, 255, 0.7);
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
vertical-align: top;
}
.api-params-table td:first-child {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11.5px;
color: rgb(var(--accent-light-rgb));
white-space: nowrap;
}
.api-params-table td:nth-child(2) {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
}
.api-param-required {
color: #f14668 !important;
font-size: 10px;
font-weight: 600;
}
.api-param-optional {
color: rgba(255, 255, 255, 0.25) !important;
font-size: 10px;
}
.api-try-bar {
display: flex;
align-items: stretch;
gap: 8px;
margin-top: 12px;
}
.api-try-params {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.api-try-param {
display: flex;
align-items: center;
gap: 6px;
}
.api-try-param label {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
}
.api-try-param input,
.api-try-param select {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 5px 8px;
color: rgba(255, 255, 255, 0.8);
font-size: 11.5px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
min-width: 80px;
}
.api-try-body {
flex: 1;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 8px 10px;
color: rgba(255, 255, 255, 0.8);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11.5px;
resize: vertical;
min-height: 60px;
}
.api-try-btn {
background: rgba(var(--accent-rgb), 0.15);
border: 1px solid rgba(var(--accent-rgb), 0.3);
border-radius: 6px;
color: rgb(var(--accent-light-rgb));
font-size: 11.5px;
font-weight: 600;
padding: 0 16px;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 5px;
}
.api-try-btn:hover {
background: rgba(var(--accent-rgb), 0.25);
border-color: rgba(var(--accent-rgb), 0.5);
}
.api-try-btn:active {
transform: scale(0.97);
}
.api-try-btn.loading {
opacity: 0.6;
pointer-events: none;
}
.api-response-panel {
margin-top: 10px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.api-response-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
font-size: 11px;
font-weight: 600;
}
.api-response-status {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
}
.api-response-status.s2xx { background: rgba(72, 199, 142, 0.15); color: #48c78e; }
.api-response-status.s4xx { background: rgba(255, 183, 77, 0.15); color: #ffb74d; }
.api-response-status.s5xx { background: rgba(241, 70, 104, 0.15); color: #f14668; }
.api-response-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
}
.api-response-body {
background: rgba(0, 0, 0, 0.4);
padding: 12px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11.5px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
white-space: pre;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
.api-response-body .json-key { color: rgb(var(--accent-light-rgb)); }
.api-response-body .json-string { color: rgba(152, 195, 121, 0.9); }
.api-response-body .json-number { color: rgba(209, 154, 102, 0.9); }
.api-response-body .json-bool { color: rgba(86, 182, 194, 0.9); }
.api-response-body .json-null { color: rgba(255, 255, 255, 0.3); }
.api-example-json {
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 12px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11.5px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
white-space: pre;
overflow-x: auto;
margin: 6px 0 10px;
}
.api-base-url {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
background: rgba(0, 0, 0, 0.3);
padding: 4px 10px;
border-radius: 5px;
margin-left: 8px;
}
.api-note {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
padding: 8px 12px;
background: rgba(var(--accent-rgb), 0.05);
border-left: 3px solid rgba(var(--accent-rgb), 0.3);
border-radius: 0 6px 6px 0;
margin: 8px 0;
}
/* Screenshot responsive adjustments */
@media (max-width: 600px) {
.docs-screenshot {