diff --git a/api/library.py b/api/library.py index 2562d1bf..b5f363c5 100644 --- a/api/library.py +++ b/api/library.py @@ -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() diff --git a/api/serializers.py b/api/serializers.py index de410c4b..7ec8c002 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -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"): diff --git a/database/music_database.py b/database/music_database.py index 31b752df..ec0e6e28 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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]: diff --git a/webui/static/docs.js b/webui/static/docs.js index 0d95e9b3..61b2f361 100644 --- a/webui/static/docs.js +++ b/webui/static/docs.js @@ -1356,343 +1356,716 @@ const DOCS_SECTIONS = [ icon: '/static/settings.jpg', children: [ { id: 'api-auth', title: 'Authentication' }, - { id: 'api-system', title: 'System & Status' }, + { id: 'api-system', title: 'System' }, + { id: 'api-library', title: 'Library' }, { id: 'api-search', title: 'Search' }, { id: 'api-downloads', title: 'Downloads' }, - { id: 'api-library', title: 'Library' }, - { id: 'api-library-edit', title: 'Library Editing' }, { id: 'api-playlists', title: 'Playlists' }, - { id: 'api-watchlist', title: 'Watchlist & Wishlist' }, - { id: 'api-automations', title: 'Automations' }, - { id: 'api-import', title: 'Import' }, - { id: 'api-settings', title: 'Settings' }, - { id: 'api-enrichment', title: 'Enrichment Workers' }, + { id: 'api-watchlist', title: 'Watchlist' }, + { id: 'api-wishlist', title: 'Wishlist' }, + { id: 'api-discover', title: 'Discover' }, { id: 'api-profiles', title: 'Profiles' }, + { id: 'api-settings', title: 'Settings & Keys' }, + { id: 'api-retag', title: 'Retag' }, + { id: 'api-cache', title: 'Cache' }, + { id: 'api-listenbrainz', title: 'ListenBrainz' }, { id: 'api-websocket', title: 'WebSocket Events' } ], - content: () => ` -
-

Authentication

-

Generate API keys in Settings → API Keys. Use them via header or query parameter:

- -

Keys use a sk_ prefix. The raw key is shown once at creation; only a SHA-256 hash is stored.

-
Example: cURL with API key
-
curl -H "Authorization: Bearer sk_abc123..." http://localhost:5000/api/system/status
-
-
-

System & Status

-

Endpoints for checking system health, service connectivity, and library statistics.

- - - - - - - - - -
MethodEndpointDescription
GET/api/system/statusUptime, version, and service connectivity
GET/api/system/statsLibrary counts (artists, albums, tracks) and total size
GET/api/system/activityRecent activity feed entries
GET/api/debug-infoFull debug snapshot: services, paths, workers, recent logs
GET/api/versionCurrent version and update availability
-
Response: GET /api/system/stats
-
{ - "artists": 342, - "albums": 1205, - "tracks": 14832, - "total_size": "128.4 GB", - "database_size": "45.2 MB" -}
-
- -
-

Downloads

-

Start, monitor, and manage music downloads.

- - - - - - - - - -
MethodEndpointDescription
POST/api/downloadStart downloading a track
GET/api/downloads/statusCurrent download queue and progress
POST/api/download/cancelCancel an active download
POST/api/download/albumStart downloading an entire album
GET/api/downloads/historyCompleted and failed download history
-
Request: POST /api/download
-
Content-Type: application/json + const apiGroups = [ + { + id: 'api-system', title: 'System', desc: 'Server status, activity feed, and combined statistics.', + endpoints: [ + E('GET', '/system/status', 'Server uptime and service connectivity', [], null, { + response: '{\n "success": true,\n "data": {\n "uptime": "4h 32m 10s",\n "uptime_seconds": 16330,\n "services": {\n "spotify": true,\n "soulseek": true,\n "hydrabase": false\n }\n }\n}' + }), + E('GET', '/system/stats', 'Combined library and download statistics', [], null, { + response: '{\n "success": true,\n "data": {\n "library": { "artists": 342, "albums": 1205, "tracks": 14832 },\n "database": { "size_mb": 45.2, "last_update": "2026-03-13T08:00:00Z" },\n "downloads": { "active": 3 }\n }\n}' + }), + E('GET', '/system/activity', 'Recent activity feed', [], null, { + response: '{\n "success": true,\n "data": {\n "activities": [\n { "timestamp": "2026-03-13T10:30:00Z", "type": "download", "message": "Downloaded: Radiohead - Karma Police" }\n ]\n }\n}' + }) + ] + }, + { + id: 'api-library', title: 'Library', desc: 'Browse artists, albums, tracks, genres, and library statistics. Most endpoints support ?fields= for field selection and pagination via ?page= and ?limit=.', + endpoints: [ + E('GET', '/library/artists', 'List library artists with search, letter filter, and pagination', [ + P('search', 'string', false, 'Substring filter on artist name', '""'), + P('letter', 'string', false, 'Filter by first letter, or "all"', '"all"'), + P('watchlist', 'string', false, 'Filter by watchlist status', '"all"'), + P('page', 'int', false, 'Page number', '1'), + P('limit', 'int', false, 'Results per page (max 200)', '50'), + P('fields', 'string', false, 'Comma-separated field names to include', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "artists": [\n {\n "id": 1,\n "name": "Radiohead",\n "thumb_url": "https://...",\n "banner_url": "https://...",\n "genres": ["alternative rock", "art rock"],\n "summary": "English rock band...",\n "style": "Alternative/Indie", "mood": "Melancholy",\n "label": "XL Recordings",\n "musicbrainz_id": "a74b1b7f-...",\n "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb",\n "itunes_artist_id": "657515",\n "deezer_id": "399", "tidal_id": "3746724",\n "qobuz_id": "61592", "genius_id": "604",\n "lastfm_listeners": 5832451,\n "lastfm_playcount": 328456789,\n "genius_url": "https://genius.com/artists/Radiohead",\n "album_count": 9, "track_count": 101,\n "...": "all 50+ fields included"\n }\n ]\n },\n "pagination": {\n "page": 1, "limit": 50, "total": 342, "total_pages": 7,\n "has_next": true, "has_prev": false\n }\n}' + }), + E('GET', '/library/artists/{artist_id}', 'Get a single artist with all metadata and album list', [ + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "artist": {\n "id": 1, "name": "Radiohead",\n "thumb_url": "https://...", "banner_url": "https://...",\n "genres": ["alternative rock", "art rock"],\n "summary": "English rock band formed in 1985...",\n "style": "Alternative/Indie", "mood": "Melancholy",\n "label": "XL Recordings",\n "server_source": "plex",\n "created_at": "2026-01-15T10:00:00Z",\n "updated_at": "2026-03-13T08:00:00Z",\n "musicbrainz_id": "a74b1b7f-71a3-4b73-8c51-5c1f3a71c9e8",\n "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb",\n "itunes_artist_id": "657515",\n "audiodb_id": "111239",\n "deezer_id": "399",\n "tidal_id": "3746724",\n "qobuz_id": "61592",\n "genius_id": "604",\n "musicbrainz_match_status": "matched",\n "spotify_match_status": "matched",\n "itunes_match_status": "matched",\n "audiodb_match_status": "matched",\n "deezer_match_status": "matched",\n "lastfm_match_status": "matched",\n "genius_match_status": "matched",\n "tidal_match_status": "matched",\n "qobuz_match_status": "matched",\n "musicbrainz_last_attempted": "2026-03-10T08:00:00Z",\n "spotify_last_attempted": "2026-03-10T08:00:00Z",\n "itunes_last_attempted": "2026-03-10T08:00:00Z",\n "audiodb_last_attempted": "2026-03-10T08:00:00Z",\n "deezer_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_last_attempted": "2026-03-10T08:00:00Z",\n "genius_last_attempted": "2026-03-10T08:00:00Z",\n "tidal_last_attempted": "2026-03-10T08:00:00Z",\n "qobuz_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_listeners": 5832451,\n "lastfm_playcount": 328456789,\n "lastfm_tags": "alternative, rock, experimental",\n "lastfm_similar": "Thom Yorke, Atoms for Peace, Portishead",\n "lastfm_bio": "Radiohead are an English rock band...",\n "lastfm_url": "https://www.last.fm/music/Radiohead",\n "genius_description": "Radiohead is an English rock band...",\n "genius_alt_names": "On a Friday",\n "genius_url": "https://genius.com/artists/Radiohead",\n "album_count": 9, "track_count": 101\n },\n "albums": [\n { "id": 10, "title": "OK Computer", "year": 1997, "track_count": 12, "record_type": "album" }\n ]\n }\n}' + }), + E('GET', '/library/artists/{artist_id}/albums', 'List albums for an artist', [ + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "albums": [\n {\n "id": 10, "artist_id": 1, "title": "OK Computer", "year": 1997,\n "thumb_url": "https://...", "track_count": 12, "duration": 3214000,\n "genres": ["alternative rock"],\n "style": "Art Rock", "mood": "Atmospheric",\n "label": "Parlophone", "record_type": "album", "explicit": false,\n "upc": "0724385522529", "copyright": "1997 Parlophone Records",\n "spotify_album_id": "6dVIqQ8qmQ5GBnJ9shOYGE",\n "tidal_id": "17914997", "qobuz_id": "0724385522529",\n "lastfm_listeners": 1543000, "lastfm_playcount": 89234567,\n "...": "all 45+ fields included"\n }\n ]\n }\n}' + }), + E('GET', '/library/albums', 'List or search albums with pagination', [ + P('search', 'string', false, 'Substring filter on album title', '""'), + P('artist_id', 'int', false, 'Filter by artist ID'), + P('year', 'int', false, 'Filter by release year'), + P('page', 'int', false, 'Page number', '1'), + P('limit', 'int', false, 'Results per page (max 200)', '50'), + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": { "albums": [ { "id": 10, "title": "OK Computer", "year": 1997, "artist_id": 1 } ] },\n "pagination": { "page": 1, "limit": 50, "total": 1205, "total_pages": 25, "has_next": true, "has_prev": false }\n}' + }), + E('GET', '/library/albums/{album_id}', 'Get a single album with metadata and embedded tracks', [ + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "album": {\n "id": 10, "artist_id": 1, "title": "OK Computer", "year": 1997,\n "thumb_url": "https://...",\n "genres": ["alternative rock"],\n "track_count": 12, "duration": 3214000,\n "style": "Art Rock", "mood": "Atmospheric",\n "label": "Parlophone", "explicit": false, "record_type": "album",\n "server_source": "plex",\n "created_at": "2026-01-15T10:00:00Z",\n "updated_at": "2026-03-13T08:00:00Z",\n "upc": "0724385522529", "copyright": "1997 Parlophone Records",\n "musicbrainz_release_id": "b1a9c0e7-...",\n "spotify_album_id": "6dVIqQ8qmQ5GBnJ9shOYGE",\n "itunes_album_id": "1097861387",\n "audiodb_id": "2115888",\n "deezer_id": "6575789",\n "tidal_id": "17914997",\n "qobuz_id": "0724385522529",\n "musicbrainz_match_status": "matched",\n "spotify_match_status": "matched",\n "itunes_match_status": "matched",\n "audiodb_match_status": "matched",\n "deezer_match_status": "matched",\n "lastfm_match_status": "matched",\n "tidal_match_status": "matched",\n "qobuz_match_status": "matched",\n "musicbrainz_last_attempted": "2026-03-10T08:00:00Z",\n "spotify_last_attempted": "2026-03-10T08:00:00Z",\n "itunes_last_attempted": "2026-03-10T08:00:00Z",\n "audiodb_last_attempted": "2026-03-10T08:00:00Z",\n "deezer_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_last_attempted": "2026-03-10T08:00:00Z",\n "tidal_last_attempted": "2026-03-10T08:00:00Z",\n "qobuz_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_listeners": 1543000,\n "lastfm_playcount": 89234567,\n "lastfm_tags": "alternative, 90s, rock",\n "lastfm_wiki": "OK Computer is the third studio album...",\n "lastfm_url": "https://www.last.fm/music/Radiohead/OK+Computer"\n },\n "tracks": [\n { "id": 100, "title": "Airbag", "track_number": 1, "duration": 284000, "bitrate": 1411 }\n ]\n }\n}' + }), + E('GET', '/library/albums/{album_id}/tracks', 'List tracks in an album', [ + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "tracks": [\n {\n "id": 100, "album_id": 10, "artist_id": 1, "title": "Airbag",\n "track_number": 1, "duration": 284000,\n "file_path": "/music/Radiohead/OK Computer/01 Airbag.flac",\n "bitrate": 1411, "bpm": 120.5, "explicit": false,\n "isrc": "GBAYE9700106",\n "spotify_track_id": "6anwyDGQmsg45JKiVKpKGA",\n "tidal_id": "17914998", "genius_id": "1342",\n "lastfm_listeners": 892000, "lastfm_playcount": 4567890,\n "genius_url": "https://genius.com/Radiohead-airbag-lyrics",\n "...": "all 55+ fields included"\n }\n ]\n }\n}' + }), + E('GET', '/library/tracks/{track_id}', 'Get a single track with all metadata', [ + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "track": {\n "id": 100, "album_id": 10, "artist_id": 1, "title": "Airbag",\n "track_number": 1, "duration": 284000,\n "file_path": "/music/Radiohead/OK Computer/01 Airbag.flac",\n "bitrate": 1411, "bpm": 120.5, "explicit": false,\n "style": "Art Rock", "mood": "Atmospheric",\n "repair_status": null, "repair_last_checked": null,\n "server_source": "plex",\n "created_at": "2026-01-15T10:00:00Z",\n "updated_at": "2026-03-13T08:00:00Z",\n "isrc": "GBAYE9700106", "copyright": "1997 Parlophone Records",\n "musicbrainz_recording_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",\n "spotify_track_id": "6anwyDGQmsg45JKiVKpKGA",\n "itunes_track_id": "1097861700",\n "audiodb_id": null,\n "deezer_id": "72420132",\n "tidal_id": "17914998",\n "qobuz_id": "24517824",\n "genius_id": "1342",\n "musicbrainz_match_status": "matched",\n "spotify_match_status": "matched",\n "itunes_match_status": "matched",\n "audiodb_match_status": "not_found",\n "deezer_match_status": "matched",\n "lastfm_match_status": "matched",\n "genius_match_status": "matched",\n "tidal_match_status": "matched",\n "qobuz_match_status": "matched",\n "musicbrainz_last_attempted": "2026-03-10T08:00:00Z",\n "spotify_last_attempted": "2026-03-10T08:00:00Z",\n "itunes_last_attempted": "2026-03-10T08:00:00Z",\n "audiodb_last_attempted": "2026-03-10T08:00:00Z",\n "deezer_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_last_attempted": "2026-03-10T08:00:00Z",\n "genius_last_attempted": "2026-03-10T08:00:00Z",\n "tidal_last_attempted": "2026-03-10T08:00:00Z",\n "qobuz_last_attempted": "2026-03-10T08:00:00Z",\n "lastfm_listeners": 892000,\n "lastfm_playcount": 4567890,\n "lastfm_tags": "alternative rock, radiohead",\n "lastfm_url": "https://www.last.fm/music/Radiohead/_/Airbag",\n "genius_lyrics": "In the next world war, in a jackknifed juggernaut...",\n "genius_description": "The opening track of OK Computer...",\n "genius_url": "https://genius.com/Radiohead-airbag-lyrics"\n }\n }\n}' + }), + E('GET', '/library/tracks', 'Search tracks by title and/or artist', [ + P('title', 'string', false, 'Track title to search (at least one of title/artist required)', '""'), + P('artist', 'string', false, 'Artist name to search', '""'), + P('limit', 'int', false, 'Max results (max 200)', '50'), + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "tracks": [\n { "id": 100, "title": "Airbag", "artist_name": "Radiohead", "album_title": "OK Computer" }\n ]\n }\n}' + }), + E('GET', '/library/genres', 'List all genres with occurrence counts', [ + P('source', 'string', false, '"artists" or "albums"', '"artists"') + ], null, { + response: '{\n "success": true,\n "data": {\n "genres": [ { "name": "alternative rock", "count": 45 }, { "name": "electronic", "count": 38 } ],\n "source": "artists"\n }\n}' + }), + E('GET', '/library/recently-added', 'Get recently added content', [ + P('type', 'string', false, '"albums", "artists", or "tracks"', '"albums"'), + P('limit', 'int', false, 'Max items (max 200)', '50'), + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "items": [ { "id": 10, "title": "OK Computer", "year": 1997, "created_at": "2026-03-12T10:00:00Z" } ],\n "type": "albums"\n }\n}' + }), + E('GET', '/library/lookup', 'Look up a library entity by external provider ID', [ + P('type', 'string', true, '"artist", "album", or "track"'), + P('provider', 'string', true, '"spotify", "musicbrainz", "itunes", "deezer", "audiodb", "tidal", "qobuz", or "genius"'), + P('id', 'string', true, 'The external ID value'), + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "artist": { "id": 1, "name": "Radiohead", "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb" }\n }\n}' + }), + E('GET', '/library/stats', 'Get library statistics', [], null, { + response: '{\n "success": true,\n "data": {\n "artists": 342,\n "albums": 1205,\n "tracks": 14832,\n "database_size_mb": 45.2,\n "last_update": "2026-03-13T08:00:00Z"\n }\n}' + }) + ] + }, + { + id: 'api-search', title: 'Search', desc: 'Search external music sources (Spotify, iTunes, Hydrabase). All search endpoints use POST with a JSON body.', + endpoints: [ + E('POST', '/search/tracks', 'Search for tracks across music sources', [], [ + P('query', 'string', true, 'Search query'), + P('source', 'string', false, '"spotify", "itunes", or "auto"', '"auto"'), + P('limit', 'int', false, 'Max results (1-50)', '20') + ], { + request: '{\n "query": "Karma Police",\n "source": "auto",\n "limit": 10\n}', + response: '{\n "success": true,\n "data": {\n "tracks": [\n {\n "id": "3SVAN3BRByDmHOhKyIDxfC",\n "name": "Karma Police",\n "artists": ["Radiohead"],\n "album": "OK Computer",\n "duration_ms": 264066,\n "popularity": 78,\n "image_url": "https://...",\n "release_date": "1997-05-28"\n }\n ],\n "source": "spotify"\n }\n}' + }), + E('POST', '/search/albums', 'Search for albums', [], [ + P('query', 'string', true, 'Search query'), + P('limit', 'int', false, 'Max results (1-50)', '20') + ], { + request: '{\n "query": "OK Computer",\n "limit": 5\n}', + response: '{\n "success": true,\n "data": {\n "albums": [\n {\n "id": "6dVIqQ8qmQ5GBnJ9shOYGE",\n "name": "OK Computer",\n "artists": ["Radiohead"],\n "release_date": "1997-05-28",\n "total_tracks": 12,\n "album_type": "album",\n "image_url": "https://..."\n }\n ],\n "source": "spotify"\n }\n}' + }), + E('POST', '/search/artists', 'Search for artists', [], [ + P('query', 'string', true, 'Search query'), + P('limit', 'int', false, 'Max results (1-50)', '20') + ], { + request: '{\n "query": "Radiohead",\n "limit": 5\n}', + response: '{\n "success": true,\n "data": {\n "artists": [\n {\n "id": "4Z8W4fKeB5YxbusRsdQVPb",\n "name": "Radiohead",\n "popularity": 79,\n "genres": ["alternative rock", "art rock"],\n "followers": 8500000,\n "image_url": "https://..."\n }\n ],\n "source": "spotify"\n }\n}' + }) + ] + }, + { + id: 'api-downloads', title: 'Downloads', desc: 'List active downloads, cancel individual or all downloads.', + endpoints: [ + E('GET', '/downloads', 'List active and recent download tasks', [], null, { + response: '{\n "success": true,\n "data": {\n "downloads": [\n {\n "id": "abc123",\n "status": "downloading",\n "track_name": "Karma Police",\n "artist_name": "Radiohead",\n "album_name": "OK Computer",\n "username": "slsk_user42",\n "progress": 67,\n "size": 34500000,\n "batch_id": null,\n "error": null\n }\n ]\n }\n}' + }), + E('POST', '/downloads/{download_id}/cancel', 'Cancel a specific download', [], [ + P('username', 'string', true, 'Soulseek username for the transfer') + ], { + request: '{\n "username": "slsk_user42"\n}', + response: '{\n "success": true,\n "data": { "message": "Download cancelled." }\n}' + }), + E('POST', '/downloads/cancel-all', 'Cancel all active downloads and clear completed', [], null, { + response: '{\n "success": true,\n "data": { "message": "All downloads cancelled and cleared." }\n}' + }) + ] + }, + { + id: 'api-playlists', title: 'Playlists', desc: 'List and inspect playlists from Spotify or Tidal, and trigger playlist sync.', + endpoints: [ + E('GET', '/playlists', 'List user playlists from Spotify or Tidal', [ + P('source', 'string', false, '"spotify" or "tidal"', '"spotify"') + ], null, { + response: '{\n "success": true,\n "data": {\n "playlists": [\n {\n "id": "37i9dQZF1DXcBWIGoYBM5M",\n "name": "Today\'s Top Hits",\n "owner": "spotify",\n "track_count": 50,\n "image_url": "https://..."\n }\n ],\n "source": "spotify"\n }\n}' + }), + E('GET', '/playlists/{playlist_id}', 'Get playlist details with tracks', [ + P('source', 'string', false, 'Only "spotify" is supported', '"spotify"') + ], null, { + response: '{\n "success": true,\n "data": {\n "playlist": {\n "id": "37i9dQZF1DXcBWIGoYBM5M",\n "name": "Today\'s Top Hits",\n "owner": "spotify",\n "total_tracks": 50,\n "tracks": [\n {\n "id": "3SVAN3BRByDmHOhKyIDxfC",\n "name": "Karma Police",\n "artists": ["Radiohead"],\n "album": "OK Computer",\n "duration_ms": 264066,\n "image_url": "https://..."\n }\n ]\n },\n "source": "spotify"\n }\n}' + }), + E('POST', '/playlists/{playlist_id}/sync', 'Trigger playlist sync and download', [], [ + P('playlist_name', 'string', true, 'Name of the playlist'), + P('tracks', 'array', true, 'Array of track objects to sync') + ], { + request: '{\n "playlist_name": "My Playlist",\n "tracks": [\n { "id": "3SVAN3...", "name": "Karma Police", "artists": [{ "name": "Radiohead" }] }\n ]\n}', + response: '{\n "success": true,\n "data": { "message": "Playlist sync started.", "playlist_id": "37i9dQZF1DXcBWIGoYBM5M" }\n}' + }) + ] + }, + { + id: 'api-watchlist', title: 'Watchlist', desc: 'View, add, remove watched artists and trigger new release scans. Profile-scoped via X-Profile-Id header.', + endpoints: [ + E('GET', '/watchlist', 'List all watchlist artists for the current profile', [ + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "artists": [\n {\n "id": 1,\n "artist_name": "Radiohead",\n "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb",\n "image_url": "https://...",\n "date_added": "2026-01-15T10:00:00Z",\n "include_albums": true,\n "include_eps": true,\n "include_singles": true,\n "include_live": false,\n "include_remixes": false,\n "profile_id": 1\n }\n ]\n }\n}' + }), + E('POST', '/watchlist', 'Add an artist to the watchlist', [], [ + P('artist_id', 'string', true, 'Spotify or iTunes artist ID'), + P('artist_name', 'string', true, 'Artist display name') + ], { + request: '{\n "artist_id": "4Z8W4fKeB5YxbusRsdQVPb",\n "artist_name": "Radiohead"\n}', + response: '{\n "success": true,\n "data": { "message": "Added Radiohead to watchlist." }\n}' + }), + E('DELETE', '/watchlist/{artist_id}', 'Remove an artist from the watchlist', [], null, { + response: '{\n "success": true,\n "data": { "message": "Artist removed from watchlist." }\n}' + }), + E('PATCH', '/watchlist/{artist_id}', 'Update content type filters for a watchlist artist', [], [ + P('include_albums', 'bool', false, 'Include albums'), + P('include_eps', 'bool', false, 'Include EPs'), + P('include_singles', 'bool', false, 'Include singles'), + P('include_live', 'bool', false, 'Include live recordings'), + P('include_remixes', 'bool', false, 'Include remixes'), + P('include_acoustic', 'bool', false, 'Include acoustic versions'), + P('include_compilations', 'bool', false, 'Include compilations') + ], { + request: '{\n "include_live": true,\n "include_remixes": false\n}', + response: '{\n "success": true,\n "data": {\n "message": "Watchlist filters updated.",\n "updated": { "include_live": true, "include_remixes": false }\n }\n}' + }), + E('POST', '/watchlist/scan', 'Trigger a watchlist scan for new releases', [], null, { + response: '{\n "success": true,\n "data": { "message": "Watchlist scan started." }\n}' + }) + ] + }, + { + id: 'api-wishlist', title: 'Wishlist', desc: 'View, add, remove wishlist tracks and trigger download processing. Profile-scoped.', + endpoints: [ + E('GET', '/wishlist', 'List wishlist tracks with optional category filter', [ + P('category', 'string', false, '"singles" or "albums"', 'all'), + P('page', 'int', false, 'Page number', '1'), + P('limit', 'int', false, 'Results per page (max 200)', '50'), + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "tracks": [\n {\n "id": 1,\n "spotify_track_id": "3SVAN3BRByDmHOhKyIDxfC",\n "track_name": "Karma Police",\n "artist_name": "Radiohead",\n "album_name": "OK Computer",\n "failure_reason": "No suitable source found",\n "retry_count": 2,\n "last_attempted": "2026-03-12T10:00:00Z",\n "date_added": "2026-03-10T08:00:00Z",\n "source_type": "playlist_sync"\n }\n ]\n },\n "pagination": { "page": 1, "limit": 50, "total": 12, "total_pages": 1, "has_next": false, "has_prev": false }\n}' + }), + E('POST', '/wishlist', 'Add a track to the wishlist', [], [ + P('spotify_track_data', 'object', true, 'Full Spotify track data object'), + P('failure_reason', 'string', false, 'Reason for adding', '"Added via API"'), + P('source_type', 'string', false, 'Source identifier', '"api"') + ], { + request: '{\n "spotify_track_data": {\n "id": "3SVAN3BRByDmHOhKyIDxfC",\n "name": "Karma Police",\n "artists": [{ "name": "Radiohead" }],\n "album": { "name": "OK Computer", "album_type": "album" }\n },\n "source_type": "api"\n}', + response: '{\n "success": true,\n "data": { "message": "Track added to wishlist." }\n}' + }), + E('DELETE', '/wishlist/{track_id}', 'Remove a track by Spotify track ID', [], null, { + response: '{\n "success": true,\n "data": { "message": "Track removed from wishlist." }\n}' + }), + E('POST', '/wishlist/process', 'Trigger wishlist download processing', [], null, { + response: '{\n "success": true,\n "data": { "message": "Wishlist processing started." }\n}' + }) + ] + }, + { + id: 'api-discover', title: 'Discover', desc: 'Browse the discovery pool, similar artists, recent releases, and bubble snapshots. Profile-scoped.', + endpoints: [ + E('GET', '/discover/pool', 'List discovery pool tracks with optional filters', [ + P('new_releases_only', 'string', false, '"true" to filter new releases only', 'false'), + P('source', 'string', false, '"spotify" or "itunes"', 'all'), + P('page', 'int', false, 'Page number', '1'), + P('limit', 'int', false, 'Max tracks (max 500)', '100'), + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "tracks": [\n {\n "id": 1,\n "spotify_track_id": "3SVAN3...",\n "track_name": "Karma Police",\n "artist_name": "Radiohead",\n "album_name": "OK Computer",\n "album_cover_url": "https://...",\n "duration_ms": 264066,\n "popularity": 78,\n "is_new_release": false,\n "source": "spotify"\n }\n ]\n },\n "pagination": { "page": 1, "limit": 100, "total": 850, "total_pages": 9, "has_next": true, "has_prev": false }\n}' + }), + E('GET', '/discover/similar-artists', 'List top similar artists from the watchlist', [ + P('limit', 'int', false, 'Max artists (max 200)', '50'), + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "artists": [\n {\n "id": 1,\n "similar_artist_name": "Thom Yorke",\n "similar_artist_spotify_id": "2x9SpqnPi8rlE9pjHBwN5z",\n "similarity_rank": 1,\n "occurrence_count": 5\n }\n ]\n }\n}' + }), + E('GET', '/discover/recent-releases', 'List recent releases from watched artists', [ + P('limit', 'int', false, 'Max releases (max 200)', '50'), + P('fields', 'string', false, 'Comma-separated fields', 'all') + ], null, { + response: '{\n "success": true,\n "data": {\n "releases": [\n {\n "id": 1,\n "album_name": "A Moon Shaped Pool",\n "album_spotify_id": "2ix8vWvvSp2Yo7rKMiWpkg",\n "release_date": "2016-05-08",\n "album_cover_url": "https://...",\n "track_count": 11,\n "source": "spotify"\n }\n ]\n }\n}' + }), + E('GET', '/discover/pool/metadata', 'Get discovery pool metadata', [], null, { + response: '{\n "success": true,\n "data": {\n "last_populated": "2026-03-12T10:00:00Z",\n "track_count": 850,\n "updated_at": "2026-03-12T10:00:00Z"\n }\n}' + }), + E('GET', '/discover/bubbles', 'List all bubble snapshots for the current profile', [], null, { + response: '{\n "success": true,\n "data": {\n "snapshots": {\n "artist_bubbles": { "snapshot_data": [...], "updated_at": "..." },\n "search_bubbles": null,\n "discover_downloads": null\n }\n }\n}' + }), + E('GET', '/discover/bubbles/{snapshot_type}', 'Get a specific bubble snapshot (artist_bubbles, search_bubbles, discover_downloads)', [], null, { + response: '{\n "success": true,\n "data": {\n "snapshot": { "snapshot_data": [...], "updated_at": "2026-03-12T10:00:00Z" }\n }\n}' + }) + ] + }, + { + id: 'api-profiles', title: 'Profiles', desc: 'Manage multi-profile support. Create, update, delete profiles with PIN protection and page access control.', + endpoints: [ + E('GET', '/profiles', 'List all profiles', [], null, { + response: '{\n "success": true,\n "data": {\n "profiles": [\n {\n "id": 1,\n "name": "Admin",\n "is_admin": 1,\n "avatar_color": "#6366f1",\n "avatar_url": null,\n "created_at": "2026-01-01T00:00:00Z"\n }\n ]\n }\n}' + }), + E('GET', '/profiles/{profile_id}', 'Get a single profile by ID', [], null, { + response: '{\n "success": true,\n "data": {\n "profile": {\n "id": 1, "name": "Admin", "is_admin": 1,\n "avatar_color": "#6366f1", "avatar_url": null\n }\n }\n}' + }), + E('POST', '/profiles', 'Create a new profile', [], [ + P('name', 'string', true, 'Profile display name'), + P('avatar_color', 'string', false, 'Hex color for avatar', '"#6366f1"'), + P('avatar_url', 'string', false, 'Custom avatar image URL'), + P('is_admin', 'bool', false, 'Admin privileges', 'false'), + P('pin', 'string', false, 'PIN for profile protection') + ], { + request: '{\n "name": "Family Room",\n "is_admin": false,\n "avatar_color": "#22c55e",\n "pin": "1234"\n}', + response: '{\n "success": true,\n "data": {\n "profile": {\n "id": 3, "name": "Family Room", "is_admin": 0,\n "avatar_color": "#22c55e"\n }\n }\n}' + }), + E('PUT', '/profiles/{profile_id}', 'Update a profile', [], [ + P('name', 'string', false, 'New display name'), + P('avatar_color', 'string', false, 'Hex color'), + P('avatar_url', 'string', false, 'Avatar image URL'), + P('is_admin', 'bool', false, 'Admin privileges'), + P('pin', 'string', false, 'New PIN (empty string clears PIN)') + ], { + request: '{\n "name": "Kids Room",\n "avatar_color": "#f59e0b"\n}', + response: '{\n "success": true,\n "data": {\n "profile": { "id": 3, "name": "Kids Room", "avatar_color": "#f59e0b" }\n }\n}' + }), + E('DELETE', '/profiles/{profile_id}', 'Delete a profile (cannot delete profile 1)', [], null, { + response: '{\n "success": true,\n "data": { "message": "Profile 3 deleted." }\n}' + }) + ] + }, + { + id: 'api-settings', title: 'Settings & API Keys', desc: 'Read and update application settings. Manage API keys. Sensitive values are always redacted in GET responses.', + endpoints: [ + E('GET', '/settings', 'Get current settings (sensitive values redacted)', [], null, { + response: '{\n "success": true,\n "data": {\n "settings": {\n "spotify": {\n "client_id": "***REDACTED***",\n "country": "US"\n },\n "download_path": "/music",\n "download_source": "hybrid",\n "ui_appearance": {\n "accent_preset": "green",\n "particles_enabled": true\n }\n }\n }\n}' + }), + E('PATCH', '/settings', 'Update settings (partial, dot-notation keys accepted)', [], [ + P('{key}', 'any', true, 'One or more key-value pairs. The "api_keys" key is blocked.') + ], { + request: '{\n "spotify.country": "GB",\n "download_path": "/new/music/path"\n}', + response: '{\n "success": true,\n "data": {\n "message": "Settings updated.",\n "updated_keys": ["spotify.country", "download_path"]\n }\n}' + }), + E('GET', '/api-keys', 'List all API keys (prefix and label only, never the full key)', [], null, { + response: '{\n "success": true,\n "data": {\n "keys": [\n {\n "id": "a1b2c3d4-...",\n "label": "My Bot",\n "key_prefix": "sk_AbCdEfGh",\n "created_at": "2026-03-01T10:00:00Z",\n "last_used_at": "2026-03-13T09:15:00Z"\n }\n ]\n }\n}' + }), + E('POST', '/api-keys', 'Generate a new API key (raw key returned once)', [], [ + P('label', 'string', false, 'Descriptive label for the key', '""') + ], { + request: '{\n "label": "Home Assistant"\n}', + response: '{\n "success": true,\n "data": {\n "key": "sk_AbCdEfGhIjKlMnOpQrStUvWxYz123456789...",\n "id": "a1b2c3d4-...",\n "label": "Home Assistant",\n "key_prefix": "sk_AbCdEfGh",\n "created_at": "2026-03-13T10:00:00Z"\n }\n}' + }), + E('DELETE', '/api-keys/{key_id}', 'Revoke an API key by its UUID', [], null, { + response: '{\n "success": true,\n "data": { "message": "API key revoked." }\n}' + }), + E('POST', '/api-keys/bootstrap', 'Generate the first API key when none exist (NO AUTH REQUIRED)', [], [ + P('label', 'string', false, 'Label for the key', '"Default"') + ], { + request: '{\n "label": "My First Key"\n}', + response: '{\n "success": true,\n "data": {\n "key": "sk_...",\n "id": "...",\n "label": "My First Key",\n "key_prefix": "sk_...",\n "created_at": "2026-03-13T10:00:00Z"\n }\n}' + }) + ] + }, + { + id: 'api-retag', title: 'Retag', desc: 'View and manage the pending metadata correction queue.', + endpoints: [ + E('GET', '/retag/groups', 'List all retag groups with track counts', [], null, { + response: '{\n "success": true,\n "data": {\n "groups": [\n {\n "id": 1,\n "original_artist": "Radiohed",\n "corrected_artist": "Radiohead",\n "track_count": 5,\n "created_at": "2026-03-12T10:00:00Z"\n }\n ]\n }\n}' + }), + E('GET', '/retag/groups/{group_id}', 'Get a retag group with its tracks', [], null, { + response: '{\n "success": true,\n "data": {\n "group": { "id": 1, "original_artist": "Radiohed", "corrected_artist": "Radiohead" },\n "tracks": [\n { "id": 100, "title": "Airbag", "file_path": "/music/..." }\n ]\n }\n}' + }), + E('DELETE', '/retag/groups/{group_id}', 'Delete a retag group and its tracks', [], null, { + response: '{\n "success": true,\n "data": { "message": "Retag group 1 deleted." }\n}' + }), + E('DELETE', '/retag/groups', 'Delete all retag groups and tracks', [], null, { + response: '{\n "success": true,\n "data": { "message": "Cleared 5 retag groups." }\n}' + }), + E('GET', '/retag/stats', 'Get retag queue statistics', [], null, { + response: '{\n "success": true,\n "data": {\n "total_groups": 5,\n "total_tracks": 23,\n "pending": 18,\n "completed": 5\n }\n}' + }) + ] + }, + { + id: 'api-cache', title: 'Cache', desc: 'Browse MusicBrainz and discovery match caches for debugging and inspection.', + endpoints: [ + E('GET', '/cache/musicbrainz', 'List cached MusicBrainz lookups', [ + P('entity_type', 'string', false, '"artist", "album", or "track"'), + P('search', 'string', false, 'Filter by entity name'), + P('page', 'int', false, 'Page number', '1'), + P('limit', 'int', false, 'Results per page (max 200)', '50') + ], null, { + response: '{\n "success": true,\n "data": {\n "entries": [\n {\n "entity_type": "artist",\n "entity_name": "Radiohead",\n "musicbrainz_id": "a74b1b7f-...",\n "last_updated": "2026-03-12T10:00:00Z",\n "metadata_json": { "type": "Group", "country": "GB" }\n }\n ]\n },\n "pagination": { "page": 1, "limit": 50, "total": 342, "total_pages": 7, "has_next": true, "has_prev": false }\n}' + }), + E('GET', '/cache/musicbrainz/stats', 'Get MusicBrainz cache statistics', [], null, { + response: '{\n "success": true,\n "data": {\n "total": 1024,\n "matched": 890,\n "unmatched": 134,\n "by_type": { "artist": 342, "album": 450, "track": 232 }\n }\n}' + }), + E('GET', '/cache/discovery-matches', 'List cached discovery provider matches', [ + P('provider', 'string', false, '"spotify", "itunes", etc.'), + P('search', 'string', false, 'Filter by title or artist'), + P('page', 'int', false, 'Page number', '1'), + P('limit', 'int', false, 'Results per page (max 200)', '50') + ], null, { + response: '{\n "success": true,\n "data": {\n "entries": [\n {\n "provider": "spotify",\n "original_title": "Karma Police",\n "original_artist": "Radiohead",\n "matched_data_json": { "id": "3SVAN3...", "confidence": 0.95 },\n "use_count": 3,\n "last_used_at": "2026-03-12T10:00:00Z"\n }\n ]\n },\n "pagination": { "page": 1, "limit": 50, "total": 5000, "total_pages": 100, "has_next": true, "has_prev": false }\n}' + }), + E('GET', '/cache/discovery-matches/stats', 'Get discovery match cache statistics', [], null, { + response: '{\n "success": true,\n "data": {\n "total": 5000,\n "total_uses": 18500,\n "avg_confidence": 0.872,\n "by_provider": { "spotify": 3200, "itunes": 1800 }\n }\n}' + }) + ] + }, + { + id: 'api-listenbrainz', title: 'ListenBrainz', desc: 'Browse cached ListenBrainz playlists and their tracks.', + endpoints: [ + E('GET', '/listenbrainz/playlists', 'List cached ListenBrainz playlists', [ + P('type', 'string', false, 'Filter by playlist_type (e.g. "weekly-jams")'), + P('page', 'int', false, 'Page number', '1'), + P('limit', 'int', false, 'Results per page (max 200)', '50') + ], null, { + response: '{\n "success": true,\n "data": {\n "playlists": [\n {\n "id": 1,\n "playlist_mbid": "a1b2c3d4-...",\n "title": "Weekly Jams for user",\n "playlist_type": "weekly-jams",\n "track_count": 50,\n "created_at": "2026-03-10T00:00:00Z"\n }\n ]\n },\n "pagination": { "page": 1, "limit": 50, "total": 12, "total_pages": 1, "has_next": false, "has_prev": false }\n}' + }), + E('GET', '/listenbrainz/playlists/{playlist_id}', 'Get a ListenBrainz playlist with tracks (ID or MBID)', [], null, { + response: '{\n "success": true,\n "data": {\n "playlist": {\n "id": 1,\n "playlist_mbid": "a1b2c3d4-...",\n "title": "Weekly Jams for user",\n "playlist_type": "weekly-jams"\n },\n "tracks": [\n {\n "id": 1,\n "position": 0,\n "recording_mbid": "e1f2g3h4-...",\n "title": "Karma Police",\n "artist": "Radiohead"\n }\n ]\n }\n}' + }) + ] + } + ]; -{ - "artist": "Radiohead", - "album": "OK Computer", - "title": "Karma Police", - "spotify_id": "3SVAN3BRByDmHOhKyIDxfC" -}
-
Response
-
{ - "success": true, - "task_id": "abc123", - "message": "Download started" -}
-
-
-

Library

-

Browse and query your music library.

- - - - - - - - - -
MethodEndpointDescription
GET/api/library/artistsPaginated artist list with search and filters
GET/api/artist-detail/{id}Full artist info, discography, and match status
GET/api/library/artist/{id}/enhancedEnhanced view: full tracks, tags, file paths
GET/api/library/album/{id}/tracksTrack list for an album
POST/api/database/updateTrigger library database refresh
-
Response: GET /api/library/artists?page=1&per_page=20
-
{ - "artists": [ - { - "id": 1, - "name": "Radiohead", - "album_count": 9, - "track_count": 101, - "image_url": "https://..." - } - ], - "total": 342, - "page": 1, - "per_page": 20 -}
-
-
-

Library Editing

-

Edit metadata and write tags to files via the Enhanced Library Manager.

- - - - - - - - - - - -
MethodEndpointDescription
PUT/api/library/artist/{id}Update artist fields (name, genres, label, etc.)
PUT/api/library/album/{id}Update album fields
PUT/api/library/track/{id}Update track fields (title, track_number, bpm)
POST/api/library/tracks/batchBatch update multiple tracks at once
GET/api/library/track/{id}/tag-previewPreview tag diff (current file tags vs database)
POST/api/library/track/{id}/write-tagsWrite database metadata to audio file tags
POST/api/library/tracks/write-tags-batchBatch write tags to multiple files
-
Request: PUT /api/library/track/42
-
Content-Type: application/json + // --- Build endpoint HTML --- + function methodClass(m) { return m.toLowerCase(); } -{ - "title": "Karma Police", - "track_number": 6, - "bpm": 75 -}
-
-
-

Playlists

-

Import, sync, and manage mirrored playlists from external sources.

- - - - - - - - - -
MethodEndpointDescription
GET/api/playlists/spotifyList Spotify playlists
POST/api/playlists/parseParse a YouTube/Tidal playlist URL
GET/api/playlists/mirroredList all mirrored playlists
POST/api/playlists/{id}/syncSync playlist tracks to wishlist
POST/api/playlists/{id}/discoverDiscover official metadata for playlist tracks
-
Request: POST /api/playlists/parse
-
Content-Type: application/json + function buildParamsTable(params) { + if (!params || !params.length) return ''; + let html = '
Parameters
'; + html += ''; + params.forEach(p => { + const req = p.required ? 'required' : 'optional'; + const def = p.default !== undefined ? ' (default: ' + p.default + ')' : ''; + html += ''; + }); + html += '
NameTypeRequiredDescription
' + p.name + '' + p.type + '' + req + '' + p.desc + def + '
'; + return html; + } -{ - "url": "https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf" -}
-
-
-

Watchlist & Wishlist

-

Manage watched artists and the download wishlist queue.

- - - - - - - - - -
MethodEndpointDescription
GET/api/watchlistList all watched artists
POST/api/watchlist/addAdd an artist to the watchlist
DELETE/api/watchlist/{id}Remove artist from watchlist
GET/api/wishlistList all wishlist items with status
POST/api/wishlist/processTrigger wishlist processing
-
Request: POST /api/watchlist/add
-
Content-Type: application/json + function buildBodyTable(fields) { + if (!fields || !fields.length) return ''; + let html = '
Request Body (JSON)
'; + html += ''; + fields.forEach(p => { + const req = p.required ? 'required' : 'optional'; + const def = p.default !== undefined ? ' (default: ' + p.default + ')' : ''; + html += ''; + }); + html += '
FieldTypeRequiredDescription
' + p.name + '' + p.type + '' + req + '' + p.desc + def + '
'; + return html; + } -{ - "artist_name": "Radiohead", - "spotify_id": "4Z8W4fKeB5YxbusRsdQVPb" -}
-
-
-

Automations

-

Create, manage, and trigger automations.

- - - - - - - - - -
MethodEndpointDescription
GET/api/automationsList all automations with status
POST/api/automationsCreate a new automation
PUT/api/automations/{id}Update an automation
POST/api/automations/{id}/runRun an automation immediately
DELETE/api/automations/{id}Delete a custom automation
-
Response: GET /api/automations
-
[ - { - "id": 1, - "name": "Auto-Process Wishlist", - "trigger_type": "schedule", - "action_type": "process_wishlist", - "enabled": true, - "last_run": "2026-03-12T10:30:00Z", - "run_count": 142, - "is_system": true - } -]
-
-
-

Import

-

Manage the staging folder and import music files.

- - - - - - - - -
MethodEndpointDescription
GET/api/import/stagingList files and folders in the staging directory
POST/api/import/matchAuto-match staged files to album tracks
POST/api/import/confirmConfirm and execute an import
POST/api/import/search/albumsSearch for album matches for staged files
-
Response: GET /api/import/staging
-
{ - "folders": [ - { - "name": "Radiohead - OK Computer", - "files": 12, - "size": "485 MB" - } - ], - "singles": 3, - "total_size": "512 MB" -}
-
-
-

Settings

-

Read and update application settings. Admin-only endpoints.

- - - - - - - - - -
MethodEndpointDescription
GET/api/settingsGet all current settings
POST/api/settingsUpdate settings (partial update supported)
POST/api/database/backupCreate a database backup
GET/api/database/backupsList available backups
POST/api/database/restoreRestore from a backup
-
Response: POST /api/database/backup
-
{ - "success": true, - "filename": "backup_2026-03-12_103000.db", - "size": "45.2 MB" -}
-
-
-

Enrichment Workers

-

Monitor and control the background metadata enrichment workers.

- - - - - - - - -
MethodEndpointDescription
GET/api/enrichment/statusStatus of all enrichment workers
POST/api/enrichment/pausePause a specific worker
POST/api/enrichment/resumeResume a paused worker
POST/api/enrichment/resetReset worker progress and re-scan all items
-
Response: GET /api/enrichment/status
-
{ - "workers": { - "spotify": { "status": "running", "matched": 142, "total": 342, "current": "Radiohead" }, - "musicbrainz": { "status": "paused", "matched": 98, "total": 342 }, - "lastfm": { "status": "running", "matched": 320, "total": 342 } - } -}
-
-
-

Profiles

-

Manage multi-profile support. Admin-only for create/edit/delete.

- - - - - - - - - -
MethodEndpointDescription
GET/api/profilesList all profiles
POST/api/profilesCreate a new profile
PUT/api/profiles/{id}Update a profile (name, avatar, permissions)
DELETE/api/profiles/{id}Delete a profile (admin only, cannot delete profile 1)
POST/api/profiles/switchSwitch active profile (PIN required if set)
-
Request: POST /api/profiles
-
Content-Type: application/json + function buildExample(ex) { + if (!ex) return ''; + let html = ''; + if (ex.request) { + html += '
Example Request Body
'; + html += '
' + escHtml(ex.request) + '
'; + } + if (ex.response) { + html += '
Example Response
'; + html += '
' + escHtml(ex.response) + '
'; + } + return html; + } -{ - "name": "Family Room", - "is_admin": false, - "pin": "1234", - "allowed_pages": ["search", "discover", "library", "sync"], - "can_download": true -}
-
-
-

WebSocket Events

-

SoulSync uses Socket.IO for real-time communication. The frontend connects automatically and receives live updates without polling. Connect to the same host/port as the web UI.

- - - - - - - - - - - - - -
EventDescription
download_progressPer-track download progress (speed, ETA, percentage)
download_completeTrack finished downloading and post-processing
batch_progressAlbum/playlist batch download status
worker_statusEnrichment worker status (Spotify, MusicBrainz, Deezer, Tidal, Qobuz, etc.)
scan_progressLibrary scan, quality scan, or duplicate scan progress
system_statusService connectivity changes (Spotify rate limit, slskd disconnect)
activitySystem activity feed entries
wishlist_updateWishlist item added, status changed, or removed
automation_runAutomation started, completed, or failed
-

All UI elements that show live progress (download bars, worker icons, scan counters) are driven by these WebSocket events. External clients can connect using any Socket.IO-compatible library and listen for these same events.

-
Example: Socket.IO client (JavaScript)
-
const socket = io('http://localhost:5000'); -socket.on('download_progress', (data) => { - console.log(data.title, data.percent + '%'); -}); -socket.on('activity', (data) => { - console.log(data.timestamp, data.message); -});
-

The full API has 200+ endpoints covering library, downloads, playlists, automations, profiles, settings, and more. Use a reverse proxy (Nginx, Caddy, Traefik) for external access with HTTPS.

-
- ` + function escHtml(s) { + return s.replace(/&/g,'&').replace(//g,'>'); + } + + function buildTryIt(ep, idx) { + const hasPathParam = ep.path.includes('{'); + const pathParams = []; + const pathMatch = ep.path.match(/\{([^}]+)\}/g); + if (pathMatch) pathMatch.forEach(m => pathParams.push(m.replace(/[{}]/g, ''))); + + let html = '
Try It
'; + + // Path param inputs + if (pathParams.length) { + html += '
'; + pathParams.forEach(pp => { + html += '
'; + }); + html += '
'; + } + + // Query param inputs for GET endpoints + if (ep.params && ep.params.length && (ep.method === 'GET')) { + html += '
'; + ep.params.forEach(p => { + if (p.name === 'fields') return; // skip fields param in try-it + html += '
'; + }); + html += '
'; + } + + html += '
'; + // Body textarea for POST/PUT/PATCH/DELETE with body + if (ep.bodyFields && ep.bodyFields.length) { + const defaultBody = ep.example && ep.example.request ? ep.example.request : '{}'; + html += ''; + } + html += ''; + html += '
'; + + html += '
'; + return html; + } + + // Build all sections + let sectionsHTML = ''; + + // Auth section (not a group) + sectionsHTML += '
'; + sectionsHTML += '

Authentication

'; + sectionsHTML += '

All API v1 endpoints require an API key (except POST /api-keys/bootstrap). Generate keys in Settings → API Keys or via the bootstrap endpoint.

'; + sectionsHTML += '
Two authentication methods
'; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += '
MethodFormatExample
HeaderAuthorization: Bearer {key}Authorization: Bearer sk_AbCd...
Query?api_key={key}/api/v1/system/status?api_key=sk_AbCd...
'; + sectionsHTML += '
Keys use the sk_ prefix. The raw key is shown exactly once at creation time. Only a SHA-256 hash is stored server-side. Rate limit: 60 requests per minute per IP.
'; + sectionsHTML += '
Base URL
'; + sectionsHTML += '

All endpoints are prefixed with /api/v1

'; + sectionsHTML += '
Response Envelope
'; + sectionsHTML += '

Every response follows this structure:

'; + sectionsHTML += '
{\n "success": true | false,\n "data": { ... } | null,\n "error": { "code": "ERROR_CODE", "message": "..." } | null,\n "pagination": { "page": 1, "limit": 50, "total": 342, "total_pages": 7, "has_next": true, "has_prev": false } | null\n}
'; + sectionsHTML += '
Error Codes
'; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += '
StatusCodeMeaning
400BAD_REQUESTMissing or invalid parameters
401AUTH_REQUIREDNo API key provided
403INVALID_KEY / FORBIDDENInvalid key or insufficient permissions
404NOT_FOUNDResource not found
409CONFLICTResource already exists or action in progress
429RATE_LIMITEDToo many requests
500*_ERRORInternal server error
'; + sectionsHTML += '
cURL Example
'; + sectionsHTML += '
curl -H "Authorization: Bearer sk_abc123..." \\\n http://localhost:5000/api/v1/system/status
'; + sectionsHTML += '
'; + + // API key input bar + sectionsHTML += '
'; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += 'Enter key to test endpoints'; + sectionsHTML += '
'; + + // Endpoint groups + let globalIdx = 0; + const endpointRegistry = []; + + apiGroups.forEach(group => { + sectionsHTML += '
'; + sectionsHTML += '

' + group.title + '

'; + sectionsHTML += '

' + group.desc + '

'; + + group.endpoints.forEach(ep => { + const idx = globalIdx++; + endpointRegistry.push(ep); + + sectionsHTML += '
'; + sectionsHTML += '
'; + sectionsHTML += '' + ep.method + ''; + sectionsHTML += '' + ep.path + ''; + sectionsHTML += '' + ep.desc + ''; + sectionsHTML += ''; + sectionsHTML += '
'; + sectionsHTML += '
'; + sectionsHTML += '

' + ep.desc + '

'; + sectionsHTML += buildParamsTable(ep.params); + sectionsHTML += buildBodyTable(ep.bodyFields); + sectionsHTML += buildExample(ep.example); + sectionsHTML += buildTryIt(ep, idx); + sectionsHTML += '
'; + }); + + sectionsHTML += '
'; + }); + + // WebSocket section + sectionsHTML += '
'; + sectionsHTML += '

WebSocket Events

'; + sectionsHTML += '

SoulSync uses Socket.IO for real-time updates. Connect to the same host/port as the web UI. No API key required for WebSocket connections.

'; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += ''; + sectionsHTML += '
EventDescriptionKey Fields
download_progressPer-track download progresstitle, percent, speed, eta
download_completeTrack finished downloadingtitle, artist, album, file_path
batch_progressAlbum/playlist batch statusbatch_id, completed, total, current_track
worker_statusEnrichment worker updatesworker, status, matched, total, current
scan_progressLibrary/quality/duplicate scantype, progress, total, current
system_statusService connectivity changesservice, connected, rate_limited
activityActivity feed entriestimestamp, type, message
wishlist_updateWishlist item changesaction, track_id, track_name
automation_runAutomation execution eventsautomation_id, status, result
'; + sectionsHTML += '
JavaScript Example
'; + sectionsHTML += '
import { io } from "socket.io-client";\n\nconst socket = io("http://localhost:5000");\n\nsocket.on("download_progress", (data) => {\n console.log(`${data.title}: ${data.percent}%`);\n});\n\nsocket.on("worker_status", (data) => {\n console.log(`${data.worker}: ${data.status} (${data.matched}/${data.total})`);\n});\n\nsocket.on("activity", (data) => {\n console.log(`[${data.timestamp}] ${data.message}`);\n});
'; + sectionsHTML += '
'; + + // Wire up API key status indicator + setTimeout(() => { + const keyInput = document.getElementById('api-tester-key'); + const keyStatus = document.getElementById('api-key-status'); + if (keyInput && keyStatus) { + keyInput.addEventListener('input', () => { + const val = keyInput.value.trim(); + if (!val) { + keyStatus.textContent = 'Enter key to test endpoints'; + keyStatus.classList.remove('connected'); + } else if (val.startsWith('sk_')) { + keyStatus.textContent = 'Key set \u2713'; + keyStatus.classList.add('connected'); + } else { + keyStatus.textContent = 'Key should start with sk_'; + keyStatus.classList.remove('connected'); + } + }); + } + }, 0); + + // Register the try-it handler on window + window._apiEndpointRegistry = endpointRegistry; + window._apiTryIt = async function(idx) { + const ep = endpointRegistry[idx]; + const btn = document.getElementById('api-try-btn-' + idx); + const resultDiv = document.getElementById('api-try-result-' + idx); + const apiKey = document.getElementById('api-tester-key')?.value?.trim(); + + if (!apiKey) { + resultDiv.innerHTML = '
Enter your API key above first
'; + return; + } + + // Build path + let path = ep.path; + const pathMatch = path.match(/\{([^}]+)\}/g); + if (pathMatch) { + for (const m of pathMatch) { + const paramName = m.replace(/[{}]/g, ''); + const input = document.getElementById('api-try-path-' + idx + '-' + paramName); + const val = input?.value?.trim(); + if (!val) { + resultDiv.innerHTML = '
Fill in path parameter: ' + paramName + '
'; + return; + } + path = path.replace(m, encodeURIComponent(val)); + } + } + + // Build query string for GET + let qs = ''; + if (ep.method === 'GET' && ep.params) { + const parts = []; + ep.params.forEach(p => { + if (p.name === 'fields') return; + const input = document.getElementById('api-try-q-' + idx + '-' + p.name); + const val = input?.value?.trim(); + if (val) parts.push(encodeURIComponent(p.name) + '=' + encodeURIComponent(val)); + }); + if (parts.length) qs = '?' + parts.join('&'); + } + + const url = '/api/v1' + path + qs; + const fetchOpts = { + method: ep.method === 'PATCH' ? 'PATCH' : ep.method, + headers: { 'Authorization': 'Bearer ' + apiKey } + }; + + // Body + if (ep.bodyFields && ep.bodyFields.length) { + const bodyEl = document.getElementById('api-try-body-' + idx); + if (bodyEl) { + fetchOpts.headers['Content-Type'] = 'application/json'; + fetchOpts.body = bodyEl.value; + } + } + + btn.classList.add('loading'); + btn.innerHTML = '⏳ Sending...'; + resultDiv.innerHTML = ''; + + const startTime = performance.now(); + try { + const resp = await fetch(url, fetchOpts); + const elapsed = Math.round(performance.now() - startTime); + let bodyText; + try { bodyText = await resp.text(); } catch(e) { bodyText = '(empty response)'; } + + let formatted = bodyText; + try { + const parsed = JSON.parse(bodyText); + formatted = JSON.stringify(parsed, null, 2); + } catch(e) {} + + const statusClass = resp.status < 300 ? 's2xx' : resp.status < 500 ? 's4xx' : 's5xx'; + resultDiv.innerHTML = '
' + + '
' + + '' + resp.status + ' ' + resp.statusText + '' + + '' + elapsed + 'ms' + + '
' + + '
' + syntaxHighlight(escHtml2(formatted)) + '
' + + '
'; + } catch(err) { + resultDiv.innerHTML = '
Network Error
' + escHtml2(err.message) + '
'; + } + btn.classList.remove('loading'); + btn.innerHTML = '▶ Send'; + }; + + function escHtml2(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); + } + + function syntaxHighlight(json) { + return json.replace(/"([^"]+)":/g, '"$1":') + .replace(/: "((?:[^"\\]|\\.)*)"/g, ': "$1"') + .replace(/: (-?\d+\.?\d*)/g, ': $1') + .replace(/: (true|false)/g, ': $1') + .replace(/: (null)/g, ': $1'); + } + + return sectionsHTML; + } } ]; diff --git a/webui/static/style.css b/webui/static/style.css index 597638a6..bdadba67 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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 {