diff --git a/Support/API.md b/Support/API.md index f893dc78..33c299be 100644 --- a/Support/API.md +++ b/Support/API.md @@ -33,7 +33,7 @@ http://localhost:8008/api/v1/system/status?api_key=sk_your_key_here ### 3. Response Format -Every response follows this format: +Every response follows this envelope: ```json { @@ -44,7 +44,7 @@ Every response follows this format: } ``` -Errors: +Error responses: ```json { @@ -53,6 +53,22 @@ Errors: "error": { "code": "NOT_FOUND", "message": "Artist 999 not found." + }, + "pagination": null +} +``` + +Paginated responses include: + +```json +{ + "pagination": { + "page": 1, + "limit": 50, + "total": 347, + "total_pages": 7, + "has_next": true, + "has_prev": false } } ``` @@ -81,20 +97,35 @@ Keys are generated as `sk_` followed by a random token. Only the SHA-256 hash is ## Rate Limiting -Requests are rate-limited per IP address: - -| Endpoint Type | Limit | -|---------------|-------| -| Read (GET) | 60/min | -| Search (POST /search/*) | 20/min | -| Write (POST/DELETE/PATCH) | 30/min | -| Downloads (POST /downloads) | 10/min | -| System polling (GET /system/*) | 120/min | +Requests are rate-limited to **60 per minute** per IP address. Exceeding the limit returns `429 RATE_LIMITED`. --- +## Global Query Parameters + +These optional parameters work on all endpoints that return entity data: + +| Param | Type | Description | +|-------|------|-------------| +| `fields` | string | Comma-separated list of fields to return (e.g. `?fields=id,name,thumb_url`). Omit to return all fields. | + +--- + +## Multi-Profile Support + +SoulSync supports multiple user profiles. Profile-scoped endpoints (watchlist, wishlist, discovery) accept a profile identifier: + +| Method | Details | +|--------|---------| +| Header | `X-Profile-Id: 2` | +| Query | `?profile_id=2` | + +If omitted, defaults to profile 1 (admin). Profile scoping applies to: watchlist, wishlist, and discovery endpoints. + +--- + ## Endpoints ### System @@ -121,57 +152,528 @@ Server status, uptime, and service connectivity. Recent activity feed. +```json +{ + "data": { + "activities": [ + { "type": "download", "message": "Downloaded Track Name", "timestamp": "..." }, + ... + ] + } +} +``` + #### `GET /api/v1/system/stats` Combined library and download statistics. +```json +{ + "data": { + "library": { + "artists": 1250, + "albums": 4830, + "tracks": 52100 + }, + "database": { + "size_mb": 145.2, + "last_update": "2026-03-04T09:00:00" + }, + "downloads": { + "active": 3 + } + } +} +``` + --- -### Library +### Library — Artists #### `GET /api/v1/library/artists` -List library artists with search and pagination. +List library artists with search, letter filtering, and pagination. | Param | Type | Default | Description | |-------|------|---------|-------------| | `search` | string | | Filter by name | -| `letter` | string | `all` | Filter by first letter (a-z, #) | +| `letter` | string | `all` | Filter by first letter (a-z, `#` for non-alpha) | | `page` | int | 1 | Page number | | `limit` | int | 50 | Items per page (max 200) | | `watchlist` | string | `all` | `all`, `watched`, or `unwatched` | +| `fields` | string | | Comma-separated field list | + +**Response:** + +```json +{ + "data": { + "artists": [ + { + "id": 42, + "name": "Radiohead", + "thumb_url": null, + "banner_url": null, + "genres": ["alternative rock", "art rock"], + "summary": null, + "style": null, + "mood": null, + "label": null, + "server_source": null, + "created_at": null, + "updated_at": null, + "musicbrainz_id": "a74b1b7f-71a5-4011-9441-d0b5e4122711", + "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb", + "itunes_artist_id": "657515", + "audiodb_id": "111239", + "deezer_id": "399", + "musicbrainz_match_status": null, + "spotify_match_status": null, + "itunes_match_status": null, + "audiodb_match_status": null, + "deezer_match_status": null, + "musicbrainz_last_attempted": null, + "spotify_last_attempted": null, + "itunes_last_attempted": null, + "audiodb_last_attempted": null, + "deezer_last_attempted": null, + "album_count": 9, + "track_count": 101, + "is_watched": true, + "image_url": "https://..." + } + ] + }, + "pagination": { "page": 1, "limit": 50, "total": 1250, "total_pages": 25, "has_next": true, "has_prev": false } +} +``` + +> **Note:** The list endpoint returns a subset of metadata fields. Some fields like `summary`, `style`, `mood`, `label`, `banner_url`, and all `*_match_status` / `*_last_attempted` timestamps may be `null` in list view. Use the detail endpoint below for the complete record. #### `GET /api/v1/library/artists/` -Get artist details with album list. +Get a single artist by ID with **all metadata** and their album list. + +```json +{ + "data": { + "artist": { + "id": 42, + "name": "Radiohead", + "thumb_url": "https://i.scdn.co/image/abc123...", + "banner_url": "https://www.theaudiodb.com/images/media/artist/fanart/...", + "genres": ["alternative rock", "art rock", "experimental"], + "summary": "Radiohead are an English rock band formed in Abingdon...", + "style": "Alternative/Indie", + "mood": "Melancholy", + "label": "XL Recordings", + "server_source": "plex", + "created_at": "2025-12-01T14:30:00", + "updated_at": "2026-02-15T09:12:00", + "musicbrainz_id": "a74b1b7f-71a5-4011-9441-d0b5e4122711", + "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb", + "itunes_artist_id": "657515", + "audiodb_id": "111239", + "deezer_id": "399", + "musicbrainz_match_status": "matched", + "spotify_match_status": "matched", + "itunes_match_status": "matched", + "audiodb_match_status": "matched", + "deezer_match_status": "matched", + "musicbrainz_last_attempted": "2026-01-10T08:00:00", + "spotify_last_attempted": "2026-01-10T08:00:00", + "itunes_last_attempted": "2026-01-10T08:00:00", + "audiodb_last_attempted": "2026-01-10T08:00:00", + "deezer_last_attempted": "2026-01-10T08:00:00" + }, + "albums": [ + { + "id": 87, + "artist_id": 42, + "title": "OK Computer", + "year": 1997, + "...": "..." + } + ] + } +} +``` + +**Artist fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Internal database ID | +| `name` | string | Artist name | +| `thumb_url` | string? | Artist thumbnail/profile image URL | +| `banner_url` | string? | Artist banner/fanart image URL (from AudioDB) | +| `genres` | string[] | List of genre tags | +| `summary` | string? | Artist biography/description | +| `style` | string? | Musical style (from AudioDB) | +| `mood` | string? | Musical mood (from AudioDB) | +| `label` | string? | Record label (from AudioDB) | +| `server_source` | string? | Media server source (`plex`, `jellyfin`, `navidrome`) | +| `created_at` | string? | ISO 8601 timestamp when added to library | +| `updated_at` | string? | ISO 8601 timestamp of last update | +| `musicbrainz_id` | string? | MusicBrainz artist MBID | +| `spotify_artist_id` | string? | Spotify artist ID | +| `itunes_artist_id` | string? | Apple Music / iTunes artist ID | +| `audiodb_id` | string? | TheAudioDB artist ID | +| `deezer_id` | string? | Deezer artist ID | +| `musicbrainz_match_status` | string? | MusicBrainz enrichment status (`matched`, `not_found`, `error`) | +| `spotify_match_status` | string? | Spotify enrichment status | +| `itunes_match_status` | string? | iTunes enrichment status | +| `audiodb_match_status` | string? | AudioDB enrichment status | +| `deezer_match_status` | string? | Deezer enrichment status | +| `musicbrainz_last_attempted` | string? | ISO 8601 timestamp of last MusicBrainz lookup | +| `spotify_last_attempted` | string? | ISO 8601 timestamp of last Spotify lookup | +| `itunes_last_attempted` | string? | ISO 8601 timestamp of last iTunes lookup | +| `audiodb_last_attempted` | string? | ISO 8601 timestamp of last AudioDB lookup | +| `deezer_last_attempted` | string? | ISO 8601 timestamp of last Deezer lookup | + +> Fields marked `?` may be `null` if the data hasn't been enriched from that provider yet. #### `GET /api/v1/library/artists//albums` -List albums for an artist. +List all albums for a specific artist. + +--- + +### Library — Albums + +#### `GET /api/v1/library/albums` + +List/search all albums with pagination and optional filters. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `search` | string | | Filter by album title | +| `artist_id` | int | | Filter by artist ID | +| `year` | int | | Filter by release year | +| `page` | int | 1 | Page number | +| `limit` | int | 50 | Items per page (max 200) | +| `fields` | string | | Comma-separated field list | + +#### `GET /api/v1/library/albums/` + +Get a single album by ID with **all metadata** and embedded track list. + +```json +{ + "data": { + "album": { + "id": 87, + "artist_id": 42, + "title": "OK Computer", + "year": 1997, + "thumb_url": "https://i.scdn.co/image/...", + "genres": ["alternative rock"], + "track_count": 12, + "duration": 3198000, + "style": "Art Rock", + "mood": "Anxious", + "label": "Parlophone", + "explicit": false, + "record_type": "album", + "server_source": "plex", + "created_at": "2025-12-01T14:30:00", + "updated_at": "2026-02-15T09:12:00", + "musicbrainz_release_id": "a1c35a51-d102-4ce7-b7b0-8a4f68385bb2", + "spotify_album_id": "6dVIqQ8qmQ5GBnJ9shOYGE", + "itunes_album_id": "1097862703", + "audiodb_id": "2110483", + "deezer_id": "6575789", + "musicbrainz_match_status": "matched", + "spotify_match_status": "matched", + "itunes_match_status": "matched", + "audiodb_match_status": "matched", + "deezer_match_status": "matched", + "musicbrainz_last_attempted": "2026-01-10T08:00:00", + "spotify_last_attempted": "2026-01-10T08:00:00", + "itunes_last_attempted": "2026-01-10T08:00:00", + "audiodb_last_attempted": "2026-01-10T08:00:00", + "deezer_last_attempted": "2026-01-10T08:00:00" + }, + "tracks": [ + { + "id": 510, + "title": "Airbag", + "track_number": 1, + "...": "..." + } + ] + } +} +``` + +**Album fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Internal database ID | +| `artist_id` | int | Parent artist ID | +| `title` | string | Album title | +| `year` | int? | Release year | +| `thumb_url` | string? | Album cover art URL | +| `genres` | string[] | Genre tags | +| `track_count` | int? | Number of tracks | +| `duration` | int? | Total duration in milliseconds | +| `style` | string? | Musical style (from AudioDB) | +| `mood` | string? | Musical mood (from AudioDB) | +| `label` | string? | Record label | +| `explicit` | bool? | Whether album contains explicit content | +| `record_type` | string? | Album type (`album`, `single`, `ep`, `compilation`) | +| `server_source` | string? | Media server source | +| `created_at` | string? | ISO 8601 timestamp | +| `updated_at` | string? | ISO 8601 timestamp | +| `musicbrainz_release_id` | string? | MusicBrainz release MBID | +| `spotify_album_id` | string? | Spotify album ID | +| `itunes_album_id` | string? | Apple Music / iTunes album ID | +| `audiodb_id` | string? | TheAudioDB album ID | +| `deezer_id` | string? | Deezer album ID | +| `musicbrainz_match_status` | string? | MusicBrainz enrichment status | +| `spotify_match_status` | string? | Spotify enrichment status | +| `itunes_match_status` | string? | iTunes enrichment status | +| `audiodb_match_status` | string? | AudioDB enrichment status | +| `deezer_match_status` | string? | Deezer enrichment status | +| `musicbrainz_last_attempted` | string? | ISO 8601 timestamp | +| `spotify_last_attempted` | string? | ISO 8601 timestamp | +| `itunes_last_attempted` | string? | ISO 8601 timestamp | +| `audiodb_last_attempted` | string? | ISO 8601 timestamp | +| `deezer_last_attempted` | string? | ISO 8601 timestamp | #### `GET /api/v1/library/albums//tracks` -List tracks in an album. +List all tracks in an album with full metadata. + +--- + +### Library — Tracks + +#### `GET /api/v1/library/tracks/` + +Get a single track by ID with **all metadata**. + +```json +{ + "data": { + "track": { + "id": 512, + "album_id": 87, + "artist_id": 42, + "title": "Paranoid Android", + "artist_name": "Radiohead", + "album_title": "OK Computer", + "track_number": 2, + "duration": 383000, + "file_path": "/music/Radiohead/OK Computer/02 - Paranoid Android.flac", + "bitrate": 1024, + "bpm": 82.5, + "explicit": false, + "style": "Art Rock", + "mood": "Anxious", + "repair_status": null, + "repair_last_checked": null, + "server_source": "plex", + "created_at": "2025-12-01T14:30:00", + "updated_at": "2026-02-15T09:12:00", + "musicbrainz_recording_id": "b3e2b7e0-a147-4b3c-8eab-fd90bfff7e74", + "spotify_track_id": "6LgJvl0Xdtc73RJ1mN1a7Z", + "itunes_track_id": "1097863011", + "audiodb_id": null, + "deezer_id": "119606528", + "musicbrainz_match_status": "matched", + "spotify_match_status": "matched", + "itunes_match_status": "matched", + "audiodb_match_status": null, + "deezer_match_status": "matched", + "musicbrainz_last_attempted": "2026-01-10T08:00:00", + "spotify_last_attempted": "2026-01-10T08:00:00", + "itunes_last_attempted": "2026-01-10T08:00:00", + "audiodb_last_attempted": null, + "deezer_last_attempted": "2026-01-10T08:00:00" + } + } +} +``` + +**Track fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Internal database ID | +| `album_id` | int | Parent album ID | +| `artist_id` | int | Parent artist ID | +| `title` | string | Track title | +| `artist_name` | string? | Artist name (joined from artists table) | +| `album_title` | string? | Album title (joined from albums table) | +| `track_number` | int? | Track number on the album | +| `duration` | int? | Duration in milliseconds | +| `file_path` | string? | File path on the media server | +| `bitrate` | int? | Audio bitrate in kbps | +| `bpm` | float? | Beats per minute | +| `explicit` | bool? | Whether track contains explicit content | +| `style` | string? | Musical style (from AudioDB) | +| `mood` | string? | Musical mood (from AudioDB) | +| `repair_status` | string? | Track repair status | +| `repair_last_checked` | string? | ISO 8601 timestamp of last repair check | +| `server_source` | string? | Media server source | +| `created_at` | string? | ISO 8601 timestamp | +| `updated_at` | string? | ISO 8601 timestamp | +| `musicbrainz_recording_id` | string? | MusicBrainz recording MBID | +| `spotify_track_id` | string? | Spotify track ID | +| `itunes_track_id` | string? | Apple Music / iTunes track ID | +| `audiodb_id` | string? | TheAudioDB track ID | +| `deezer_id` | string? | Deezer track ID | +| `musicbrainz_match_status` | string? | MusicBrainz enrichment status | +| `spotify_match_status` | string? | Spotify enrichment status | +| `itunes_match_status` | string? | iTunes enrichment status | +| `audiodb_match_status` | string? | AudioDB enrichment status | +| `deezer_match_status` | string? | Deezer enrichment status | +| `musicbrainz_last_attempted` | string? | ISO 8601 timestamp | +| `spotify_last_attempted` | string? | ISO 8601 timestamp | +| `itunes_last_attempted` | string? | ISO 8601 timestamp | +| `audiodb_last_attempted` | string? | ISO 8601 timestamp | +| `deezer_last_attempted` | string? | ISO 8601 timestamp | #### `GET /api/v1/library/tracks` -Search tracks by title and/or artist. +Search tracks by title and/or artist. At least one of `title` or `artist` is required. -| Param | Type | Description | -|-------|------|-------------| -| `title` | string | Track title to search | -| `artist` | string | Artist name to search | -| `limit` | int | Max results (default 50, max 200) | +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `title` | string | | Track title to search | +| `artist` | string | | Artist name to search | +| `limit` | int | 50 | Max results (max 200) | +| `fields` | string | | Comma-separated field list | + +--- + +### Library — Genres + +#### `GET /api/v1/library/genres` + +List all genres in the library with occurrence counts. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `source` | string | `artists` | Table to aggregate from: `artists` or `albums` | + +```json +{ + "data": { + "genres": [ + { "name": "rock", "count": 234 }, + { "name": "alternative rock", "count": 189 }, + { "name": "indie rock", "count": 156 }, + { "name": "electronic", "count": 98 }, + { "name": "pop", "count": 87 } + ], + "source": "artists" + } +} +``` + +--- + +### Library — Recently Added + +#### `GET /api/v1/library/recently-added` + +Get recently added content, ordered by creation date. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `type` | string | `albums` | Entity type: `albums`, `artists`, or `tracks` | +| `limit` | int | 50 | Max items (max 200) | +| `fields` | string | | Comma-separated field list | + +```json +{ + "data": { + "items": [ + { + "id": 4831, + "artist_id": 42, + "title": "A Moon Shaped Pool", + "year": 2016, + "thumb_url": "https://...", + "genres": ["art rock"], + "...": "..." + } + ], + "type": "albums" + } +} +``` + +--- + +### Library — External ID Lookup + +#### `GET /api/v1/library/lookup` + +Look up a library entity by its external provider ID. Useful for cross-referencing with Spotify, MusicBrainz, iTunes, Deezer, or AudioDB. + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | `artist`, `album`, or `track` | +| `provider` | string | Yes | `spotify`, `musicbrainz`, `itunes`, `deezer`, or `audiodb` | +| `id` | string | Yes | The external ID value | +| `fields` | string | No | Comma-separated field list | + +**Example — find an artist by Spotify ID:** + +``` +GET /api/v1/library/lookup?type=artist&provider=spotify&id=4Z8W4fKeB5YxbusRsdQVPb +``` + +```json +{ + "data": { + "artist": { + "id": 42, + "name": "Radiohead", + "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb", + "...": "..." + } + } +} +``` + +**Example — find a track by MusicBrainz recording ID:** + +``` +GET /api/v1/library/lookup?type=track&provider=musicbrainz&id=b3e2b7e0-a147-4b3c-8eab-fd90bfff7e74 +``` + +Returns `404 NOT_FOUND` if no matching entity exists in the library. + +--- + +### Library — Stats #### `GET /api/v1/library/stats` -Library statistics (artist/album/track counts, database info). +Library statistics (counts and database info). + +```json +{ + "data": { + "artists": 1250, + "albums": 4830, + "tracks": 52100, + "database_size_mb": 145.2, + "last_update": "2026-03-04T09:00:00" + } +} +``` --- ### Search -Search external music sources (Spotify, iTunes, Hydrabase). +Search external music sources (Spotify, iTunes, Hydrabase). These endpoints search **external services**, not your local library (use `/library/tracks` or `/library/lookup` for that). #### `POST /api/v1/search/tracks` @@ -183,7 +685,34 @@ Search external music sources (Spotify, iTunes, Hydrabase). } ``` -`source`: `"auto"` (default — tries Hydrabase, then Spotify, then iTunes), `"spotify"`, or `"itunes"`. +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `query` | string | *required* | Search query | +| `source` | string | `auto` | `auto` (Hydrabase > Spotify > iTunes), `spotify`, or `itunes` | +| `limit` | int | 20 | Max results (max 50) | + +**Response:** + +```json +{ + "data": { + "tracks": [ + { + "id": "2cGxRwrMyEAp8dEbuZaVv6", + "name": "Around the World", + "artists": ["Daft Punk"], + "album": "Homework", + "duration_ms": 428000, + "popularity": 78, + "preview_url": "https://...", + "image_url": "https://i.scdn.co/image/...", + "release_date": "1997-01-17" + } + ], + "source": "spotify" + } +} +``` #### `POST /api/v1/search/albums` @@ -194,6 +723,27 @@ Search external music sources (Spotify, iTunes, Hydrabase). } ``` +**Response:** + +```json +{ + "data": { + "albums": [ + { + "id": "2noRn2Aes5aoNVsU6iWThc", + "name": "Discovery", + "artists": ["Daft Punk"], + "release_date": "2001-03-12", + "total_tracks": 14, + "album_type": "album", + "image_url": "https://..." + } + ], + "source": "spotify" + } +} +``` + #### `POST /api/v1/search/artists` ```json @@ -203,6 +753,26 @@ Search external music sources (Spotify, iTunes, Hydrabase). } ``` +**Response:** + +```json +{ + "data": { + "artists": [ + { + "id": "4tZwfgrHOc3mvqYlEYSvnL", + "name": "Daft Punk", + "popularity": 82, + "genres": ["electro", "french house"], + "followers": 21000000, + "image_url": "https://..." + } + ], + "source": "spotify" + } +} +``` + --- ### Downloads @@ -211,6 +781,52 @@ Search external music sources (Spotify, iTunes, Hydrabase). List active and recent download tasks. +```json +{ + "data": { + "downloads": [ + { + "id": "task_abc123", + "status": "downloading", + "track_name": "Paranoid Android", + "artist_name": "Radiohead", + "album_name": "OK Computer", + "username": "soulseek_user_42", + "filename": "02 - Paranoid Android.flac", + "progress": 67, + "size": 45000000, + "error": null, + "batch_id": "batch_xyz", + "track_index": 2, + "retry_count": 0, + "metadata_enhanced": false, + "status_change_time": 1709550000.123 + } + ] + } +} +``` + +**Download fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique task identifier | +| `status` | string | `pending`, `searching`, `downloading`, `completed`, `failed` | +| `track_name` | string? | Track being downloaded | +| `artist_name` | string? | Artist name | +| `album_name` | string? | Album name | +| `username` | string? | Soulseek peer username | +| `filename` | string? | Remote filename | +| `progress` | int | Download progress percentage (0-100) | +| `size` | int? | File size in bytes | +| `error` | string? | Error message if failed | +| `batch_id` | string? | Batch download group ID | +| `track_index` | int? | Track position in batch | +| `retry_count` | int | Number of retry attempts | +| `metadata_enhanced` | bool | Whether metadata was enhanced post-download | +| `status_change_time` | float? | Unix timestamp of last status change | + #### `POST /api/v1/downloads//cancel` Cancel a specific download. @@ -229,17 +845,61 @@ Cancel all active downloads and clear completed ones. ### Wishlist -Tracks that failed to download, queued for retry. +Tracks that failed to download, queued for retry. Profile-scoped via `X-Profile-Id`. #### `GET /api/v1/wishlist` -List wishlist tracks. +List wishlist tracks with standardized format. -| Param | Type | Description | +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `category` | string | | `singles` or `albums` | +| `page` | int | 1 | Page number | +| `limit` | int | 50 | Items per page (max 200) | +| `fields` | string | | Comma-separated field list | + +```json +{ + "data": { + "tracks": [ + { + "id": 15, + "spotify_track_id": "6LgJvl0Xdtc73RJ1mN1a7Z", + "track_name": "Paranoid Android", + "artist_name": "Radiohead", + "album_name": "OK Computer", + "spotify_data": { "...full Spotify track object..." }, + "failure_reason": "No sources found", + "retry_count": 3, + "last_attempted": "2026-03-03T15:30:00", + "date_added": "2026-03-01T10:00:00", + "source_type": "playlist", + "source_info": { "playlist_name": "My Playlist", "playlist_id": "..." }, + "profile_id": 1 + } + ] + }, + "pagination": { "..." } +} +``` + +**Wishlist track fields:** + +| Field | Type | Description | |-------|------|-------------| -| `category` | string | `singles` or `albums` | -| `page` | int | Page number | -| `limit` | int | Items per page | +| `id` | int | Internal database ID | +| `spotify_track_id` | string | Spotify track ID | +| `track_name` | string | Extracted track name | +| `artist_name` | string | Extracted artist name(s) | +| `album_name` | string? | Extracted album name | +| `spotify_data` | object | Full Spotify track metadata object | +| `failure_reason` | string? | Why the download failed | +| `retry_count` | int | Number of retry attempts | +| `last_attempted` | string? | ISO 8601 timestamp of last attempt | +| `date_added` | string? | ISO 8601 timestamp when added | +| `source_type` | string? | How it was added: `playlist`, `album`, `manual`, `api` | +| `source_info` | object? | Context about the source (playlist name, etc.) | +| `profile_id` | int? | Profile this track belongs to | #### `POST /api/v1/wishlist` @@ -247,29 +907,84 @@ Add a track to the wishlist. ```json { - "spotify_track_data": { "id": "...", "name": "...", "artists": [...] }, + "spotify_track_data": { + "id": "6LgJvl0Xdtc73RJ1mN1a7Z", + "name": "Paranoid Android", + "artists": [{ "name": "Radiohead" }], + "album": { "name": "OK Computer", "album_type": "album" } + }, "failure_reason": "No sources found", "source_type": "api" } ``` -#### `DELETE /api/v1/wishlist/` +#### `DELETE /api/v1/wishlist/` -Remove a track from the wishlist. +Remove a track from the wishlist by its Spotify track ID. #### `POST /api/v1/wishlist/process` -Trigger wishlist download processing. +Trigger wishlist download processing (retries all failed tracks). --- ### Watchlist -Artists being monitored for new releases. +Artists being monitored for new releases. Profile-scoped via `X-Profile-Id`. #### `GET /api/v1/watchlist` -List all watched artists. +List all watched artists for the current profile. + +```json +{ + "data": { + "artists": [ + { + "id": 5, + "spotify_artist_id": "4tZwfgrHOc3mvqYlEYSvnL", + "itunes_artist_id": "5468295", + "artist_name": "Daft Punk", + "image_url": "https://i.scdn.co/image/...", + "date_added": "2026-01-15T10:00:00", + "last_scan_timestamp": "2026-03-04T06:00:00", + "created_at": "2026-01-15T10:00:00", + "updated_at": "2026-03-04T06:00:00", + "profile_id": 1, + "include_albums": true, + "include_eps": true, + "include_singles": true, + "include_live": false, + "include_remixes": false, + "include_acoustic": false, + "include_compilations": false + } + ] + } +} +``` + +**Watchlist artist fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Internal database ID | +| `spotify_artist_id` | string? | Spotify artist ID | +| `itunes_artist_id` | string? | iTunes artist ID | +| `artist_name` | string | Artist name | +| `image_url` | string? | Artist image URL | +| `date_added` | string? | ISO 8601 timestamp | +| `last_scan_timestamp` | string? | ISO 8601 timestamp of last scan | +| `created_at` | string? | ISO 8601 timestamp | +| `updated_at` | string? | ISO 8601 timestamp | +| `profile_id` | int? | Profile this entry belongs to | +| `include_albums` | bool | Monitor for new albums | +| `include_eps` | bool | Monitor for new EPs | +| `include_singles` | bool | Monitor for new singles | +| `include_live` | bool | Include live recordings | +| `include_remixes` | bool | Include remixes | +| `include_acoustic` | bool | Include acoustic versions | +| `include_compilations` | bool | Include compilations | #### `POST /api/v1/watchlist` @@ -282,13 +997,163 @@ Add an artist to the watchlist. } ``` +#### `PATCH /api/v1/watchlist/` + +Update content type filters for a watched artist without having to remove and re-add them. Only the fields you include in the body will be updated. + +```json +{ + "include_live": true, + "include_remixes": true, + "include_compilations": false +} +``` + +Accepts any combination of: `include_albums`, `include_eps`, `include_singles`, `include_live`, `include_remixes`, `include_acoustic`, `include_compilations`. + +**Response:** + +```json +{ + "data": { + "message": "Watchlist filters updated.", + "updated": { + "include_live": true, + "include_remixes": true, + "include_compilations": false + } + } +} +``` + #### `DELETE /api/v1/watchlist/` -Remove an artist from the watchlist. +Remove an artist from the watchlist. `artist_id` can be a Spotify or iTunes artist ID. #### `POST /api/v1/watchlist/scan` -Trigger a watchlist scan for new releases. +Trigger a watchlist scan for new releases. Returns `409 CONFLICT` if a scan is already running. + +--- + +### Discovery + +Browse discovery pool, similar artists, and recent releases. Profile-scoped via `X-Profile-Id`. + +#### `GET /api/v1/discover/pool` + +List discovery pool tracks with pagination and optional filters. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `new_releases_only` | string | `false` | Set to `true` to filter to new releases only | +| `source` | string | | `spotify` or `itunes` (omit for all) | +| `page` | int | 1 | Page number | +| `limit` | int | 100 | Items per page (max 500) | +| `fields` | string | | Comma-separated field list | + +```json +{ + "data": { + "tracks": [ + { + "id": 1024, + "spotify_track_id": "3n3Ppam7vgaVa1iaRUc9Lp", + "spotify_album_id": "2noRn2Aes5aoNVsU6iWThc", + "spotify_artist_id": "4tZwfgrHOc3mvqYlEYSvnL", + "itunes_track_id": null, + "itunes_album_id": null, + "itunes_artist_id": null, + "source": "spotify", + "track_name": "Something About Us", + "artist_name": "Daft Punk", + "album_name": "Discovery", + "album_cover_url": "https://i.scdn.co/image/...", + "duration_ms": 232000, + "popularity": 76, + "release_date": "2001-03-12", + "is_new_release": false, + "artist_genres": ["electro", "french house"], + "added_date": "2026-03-01T12:00:00" + } + ] + }, + "pagination": { "page": 1, "limit": 100, "total": 450, "..." } +} +``` + +#### `GET /api/v1/discover/similar-artists` + +List top similar artists discovered from watchlist analysis. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `limit` | int | 50 | Max artists (max 200) | +| `fields` | string | | Comma-separated field list | + +```json +{ + "data": { + "artists": [ + { + "id": 88, + "source_artist_id": "4tZwfgrHOc3mvqYlEYSvnL", + "similar_artist_spotify_id": "12Chz98pHFMPJEknJQMWvI", + "similar_artist_itunes_id": null, + "similar_artist_name": "Justice", + "similarity_rank": 1, + "occurrence_count": 5, + "last_updated": "2026-03-01T12:00:00", + "last_featured": "2026-03-03T08:00:00" + } + ] + } +} +``` + +#### `GET /api/v1/discover/recent-releases` + +List recent releases from watched artists. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `limit` | int | 50 | Max releases (max 200) | +| `fields` | string | | Comma-separated field list | + +```json +{ + "data": { + "releases": [ + { + "id": 12, + "watchlist_artist_id": 5, + "album_spotify_id": "2noRn2Aes5aoNVsU6iWThc", + "album_itunes_id": null, + "source": "spotify", + "album_name": "Random Access Memories (10th Anniversary Edition)", + "release_date": "2023-05-12", + "album_cover_url": "https://...", + "track_count": 22, + "added_date": "2026-03-01T06:00:00" + } + ] + } +} +``` + +#### `GET /api/v1/discover/pool/metadata` + +Get discovery pool metadata (when it was last populated, track count). + +```json +{ + "data": { + "last_populated": "2026-03-04T06:00:00", + "track_count": 450, + "updated_at": "2026-03-04T06:00:00" + } +} +``` --- @@ -296,15 +1161,60 @@ Trigger a watchlist scan for new releases. #### `GET /api/v1/playlists` -List user playlists. +List user playlists from Spotify or Tidal. | Param | Type | Default | Description | |-------|------|---------|-------------| | `source` | string | `spotify` | `spotify` or `tidal` | +```json +{ + "data": { + "playlists": [ + { + "id": "37i9dQZF1DXcBWIGoYBM5M", + "name": "Today's Top Hits", + "owner": "spotify", + "track_count": 50, + "image_url": "https://..." + } + ], + "source": "spotify" + } +} +``` + #### `GET /api/v1/playlists/` -Get playlist details with tracks. +Get playlist details with full track list. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `source` | string | `spotify` | Currently only `spotify` supported for detail view | + +```json +{ + "data": { + "playlist": { + "id": "37i9dQZF1DXcBWIGoYBM5M", + "name": "Today's Top Hits", + "owner": "Spotify", + "total_tracks": 50, + "tracks": [ + { + "id": "2cGxRwrMyEAp8dEbuZaVv6", + "name": "Around the World", + "artists": ["Daft Punk"], + "album": "Homework", + "duration_ms": 428000, + "image_url": "https://..." + } + ] + }, + "source": "spotify" + } +} +``` #### `POST /api/v1/playlists//sync` @@ -313,7 +1223,13 @@ Trigger playlist sync/download. ```json { "playlist_name": "My Playlist", - "tracks": [...] + "tracks": [ + { + "id": "2cGxRwrMyEAp8dEbuZaVv6", + "name": "Around the World", + "artists": [{ "name": "Daft Punk" }] + } + ] } ``` @@ -323,11 +1239,11 @@ Trigger playlist sync/download. #### `GET /api/v1/settings` -Get current settings (sensitive values like passwords and tokens are redacted). +Get current settings. Sensitive values (passwords, tokens, secrets) are redacted. #### `PATCH /api/v1/settings` -Update settings (partial update). +Update settings (partial update). Uses dot-notation keys. ```json { @@ -336,6 +1252,17 @@ Update settings (partial update). } ``` +**Response:** + +```json +{ + "data": { + "message": "Settings updated.", + "updated_keys": ["soulseek.search_timeout", "logging.level"] + } +} +``` + --- ### API Key Management @@ -344,9 +1271,25 @@ Update settings (partial update). List all API keys (shows prefix and label only, never the full key). +```json +{ + "data": { + "keys": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "label": "Discord Bot", + "key_prefix": "sk_a3Bf9x2", + "created_at": "2026-03-01T12:00:00", + "last_used_at": "2026-03-04T09:15:00" + } + ] + } +} +``` + #### `POST /api/v1/api-keys` -Generate a new API key. +Generate a new API key. The raw key is returned **once** — save it immediately. ```json { @@ -354,27 +1297,56 @@ Generate a new API key. } ``` -Response includes the raw key (shown only once): +**Response:** ```json { "data": { - "key": "sk_a3Bf9x2Kp7...", - "id": "uuid", + "key": "sk_a3Bf9x2Kp7Qm4Rn8Yt6Wv0Xz1Cb5Dj9Fg", + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "label": "Discord Bot", "key_prefix": "sk_a3Bf9x2", - "created_at": "2026-03-03T12:00:00Z" + "created_at": "2026-03-04T10:00:00" } } ``` #### `DELETE /api/v1/api-keys/` -Revoke an API key. +Revoke an API key by its UUID. #### `POST /api/v1/api-keys/bootstrap` -Generate the first API key when none exist (no auth required). Returns 403 if keys already exist. +Generate the first API key when none exist. **No authentication required.** Returns `403` if keys already exist. + +```json +{ + "label": "My First Key" +} +``` + +--- + +## Field Filtering + +All library, watchlist, wishlist, and discovery endpoints support the `?fields=` parameter to request only specific fields. This reduces response size when you only need a few fields. + +``` +GET /api/v1/library/artists/42?fields=id,name,genres,spotify_artist_id +``` + +```json +{ + "data": { + "artist": { + "id": 42, + "name": "Radiohead", + "genres": ["alternative rock", "art rock"], + "spotify_artist_id": "4Z8W4fKeB5YxbusRsdQVPb" + } + } +} +``` --- @@ -390,20 +1362,55 @@ API_KEY = "sk_your_key_here" headers = {"Authorization": f"Bearer {API_KEY}"} -# Search for tracks -resp = requests.post(f"{API_URL}/search/tracks", +# Get full artist details with all enrichment metadata +artist = requests.get(f"{API_URL}/library/artists/42", headers=headers).json() +print(f"Artist: {artist['data']['artist']['name']}") +print(f"Spotify: {artist['data']['artist']['spotify_artist_id']}") +print(f"MusicBrainz: {artist['data']['artist']['musicbrainz_id']}") +print(f"Albums: {len(artist['data']['albums'])}") + +# Get a specific album with tracks +album = requests.get(f"{API_URL}/library/albums/87", headers=headers).json() +for track in album["data"]["tracks"]: + print(f" {track['track_number']}. {track['title']} ({track['duration']}ms)") + +# Look up by Spotify ID +result = requests.get(f"{API_URL}/library/lookup", + headers=headers, + params={"type": "artist", "provider": "spotify", "id": "4Z8W4fKeB5YxbusRsdQVPb"} +).json() + +# Browse genres +genres = requests.get(f"{API_URL}/library/genres", headers=headers).json() +for g in genres["data"]["genres"][:10]: + print(f" {g['name']}: {g['count']} artists") + +# Recently added albums +recent = requests.get(f"{API_URL}/library/recently-added?type=albums&limit=10", + headers=headers).json() + +# Search external sources +search = requests.post(f"{API_URL}/search/tracks", headers=headers, json={"query": "Daft Punk", "limit": 5}) -tracks = resp.json()["data"]["tracks"] -# Add artist to watchlist +# Add to watchlist (as profile 2) requests.post(f"{API_URL}/watchlist", - headers=headers, + headers={**headers, "X-Profile-Id": "2"}, json={"artist_id": "4tZwfgrHOc3mvqYlEYSvnL", "artist_name": "Daft Punk"}) -# Check system status -status = requests.get(f"{API_URL}/system/status", headers=headers).json() -print(f"Uptime: {status['data']['uptime']}") +# Update watchlist filters +requests.patch(f"{API_URL}/watchlist/4tZwfgrHOc3mvqYlEYSvnL", + headers=headers, + json={"include_live": True, "include_remixes": True}) + +# Get discovery pool +pool = requests.get(f"{API_URL}/discover/pool?limit=50", headers=headers).json() + +# Get only specific fields to reduce payload +minimal = requests.get( + f"{API_URL}/library/artists?fields=id,name,thumb_url&limit=100", + headers=headers).json() ``` ### JavaScript @@ -417,12 +1424,27 @@ const headers = { 'Content-Type': 'application/json' }; -// Browse library +// Browse library artists const artists = await fetch(`${API_URL}/library/artists?page=1&limit=25`, { headers }) .then(r => r.json()); +// Get album with full metadata and tracks +const album = await fetch(`${API_URL}/library/albums/87`, { headers }) + .then(r => r.json()); + +// Look up by external ID +const lookup = await fetch( + `${API_URL}/library/lookup?type=track&provider=spotify&id=6LgJvl0Xdtc73RJ1mN1a7Z`, + { headers } +).then(r => r.json()); + // Trigger watchlist scan await fetch(`${API_URL}/watchlist/scan`, { method: 'POST', headers }); + +// Get discovery similar artists for profile 2 +const similar = await fetch(`${API_URL}/discover/similar-artists?limit=20`, { + headers: { ...headers, 'X-Profile-Id': '2' } +}).then(r => r.json()); ``` ### curl @@ -431,13 +1453,115 @@ await fetch(`${API_URL}/watchlist/scan`, { method: 'POST', headers }); # System status curl -H "Authorization: Bearer sk_..." http://localhost:8008/api/v1/system/status -# Search tracks +# Get artist with full metadata +curl -H "Authorization: Bearer sk_..." \ + http://localhost:8008/api/v1/library/artists/42 + +# Get album with tracks +curl -H "Authorization: Bearer sk_..." \ + http://localhost:8008/api/v1/library/albums/87 + +# Get single track +curl -H "Authorization: Bearer sk_..." \ + http://localhost:8008/api/v1/library/tracks/512 + +# Look up by Spotify ID +curl -H "Authorization: Bearer sk_..." \ + "http://localhost:8008/api/v1/library/lookup?type=artist&provider=spotify&id=4Z8W4fKeB5YxbusRsdQVPb" + +# Browse genres +curl -H "Authorization: Bearer sk_..." \ + http://localhost:8008/api/v1/library/genres + +# Recently added albums +curl -H "Authorization: Bearer sk_..." \ + "http://localhost:8008/api/v1/library/recently-added?type=albums&limit=10" + +# Search external tracks curl -X POST http://localhost:8008/api/v1/search/tracks \ -H "Authorization: Bearer sk_..." \ -H "Content-Type: application/json" \ -d '{"query": "Boards of Canada", "limit": 5}' -# Library artists page 1 +# Watchlist with profile curl -H "Authorization: Bearer sk_..." \ - "http://localhost:8008/api/v1/library/artists?page=1&limit=25" + -H "X-Profile-Id: 2" \ + http://localhost:8008/api/v1/watchlist + +# Update watchlist filters +curl -X PATCH http://localhost:8008/api/v1/watchlist/4tZwfgrHOc3mvqYlEYSvnL \ + -H "Authorization: Bearer sk_..." \ + -H "Content-Type: application/json" \ + -d '{"include_live": true, "include_remixes": true}' + +# Discovery pool +curl -H "Authorization: Bearer sk_..." \ + "http://localhost:8008/api/v1/discover/pool?limit=50&new_releases_only=true" + +# Field filtering — only get id, name, and Spotify ID +curl -H "Authorization: Bearer sk_..." \ + "http://localhost:8008/api/v1/library/artists?fields=id,name,spotify_artist_id&limit=100" ``` + +--- + +## Endpoint Reference + +| Method | Endpoint | Description | +|--------|----------|-------------| +| **System** | | | +| GET | `/system/status` | Server status and service connectivity | +| GET | `/system/activity` | Recent activity feed | +| GET | `/system/stats` | Combined library + download stats | +| **Library — Artists** | | | +| GET | `/library/artists` | List/search artists (paginated) | +| GET | `/library/artists/` | Artist detail + albums | +| GET | `/library/artists//albums` | Albums for an artist | +| **Library — Albums** | | | +| GET | `/library/albums` | List/search albums (paginated) | +| GET | `/library/albums/` | Album detail + tracks | +| GET | `/library/albums//tracks` | Tracks in an album | +| **Library — Tracks** | | | +| GET | `/library/tracks/` | Track detail | +| GET | `/library/tracks` | Search tracks by title/artist | +| **Library — Browse** | | | +| GET | `/library/genres` | Genre listing with counts | +| GET | `/library/recently-added` | Recently added content | +| GET | `/library/lookup` | External ID lookup | +| GET | `/library/stats` | Library statistics | +| **Search** | | | +| POST | `/search/tracks` | Search external track sources | +| POST | `/search/albums` | Search external album sources | +| POST | `/search/artists` | Search external artist sources | +| **Downloads** | | | +| GET | `/downloads` | List download tasks | +| POST | `/downloads//cancel` | Cancel a download | +| POST | `/downloads/cancel-all` | Cancel all downloads | +| **Wishlist** | | | +| GET | `/wishlist` | List wishlist tracks | +| POST | `/wishlist` | Add to wishlist | +| DELETE | `/wishlist/` | Remove from wishlist | +| POST | `/wishlist/process` | Trigger processing | +| **Watchlist** | | | +| GET | `/watchlist` | List watched artists | +| POST | `/watchlist` | Add artist to watchlist | +| PATCH | `/watchlist/` | Update content filters | +| DELETE | `/watchlist/` | Remove from watchlist | +| POST | `/watchlist/scan` | Trigger scan | +| **Discovery** | | | +| GET | `/discover/pool` | Discovery pool tracks | +| GET | `/discover/similar-artists` | Similar artists | +| GET | `/discover/recent-releases` | Recent releases | +| GET | `/discover/pool/metadata` | Pool metadata | +| **Playlists** | | | +| GET | `/playlists` | List playlists | +| GET | `/playlists/` | Playlist detail + tracks | +| POST | `/playlists//sync` | Trigger playlist sync | +| **Settings** | | | +| GET | `/settings` | Get settings (redacted) | +| PATCH | `/settings` | Update settings | +| **API Keys** | | | +| GET | `/api-keys` | List API keys | +| POST | `/api-keys` | Generate new key | +| DELETE | `/api-keys/` | Revoke key | +| POST | `/api-keys/bootstrap` | Bootstrap first key (no auth) | diff --git a/api/__init__.py b/api/__init__.py index 714084b3..66f8d5a4 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -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) diff --git a/api/discover.py b/api/discover.py new file mode 100644 index 00000000..c8ad0d86 --- /dev/null +++ b/api/discover.py @@ -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) diff --git a/api/downloads.py b/api/downloads.py index 7854c119..fd64ed7e 100644 --- a/api/downloads.py +++ b/api/downloads.py @@ -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: diff --git a/api/helpers.py b/api/helpers.py index c41776aa..cae52557 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -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 diff --git a/api/library.py b/api/library.py index 94a0c0f8..2562d1bf 100644 --- a/api/library.py +++ b/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/", 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//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/", 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//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/", 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, - } diff --git a/api/serializers.py b/api/serializers.py new file mode 100644 index 00000000..de410c4b --- /dev/null +++ b/api/serializers.py @@ -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) diff --git a/api/watchlist.py b/api/watchlist.py index 6fec71e8..2712dc05 100644 --- a/api/watchlist.py +++ b/api/watchlist.py @@ -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/", 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, - } diff --git a/api/wishlist.py b/api/wishlist.py index cd3fc98e..bff40e63 100644 --- a/api/wishlist.py +++ b/api/wishlist.py @@ -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) diff --git a/database/music_database.py b/database/music_database.py index 3b07dfe9..1f469c0f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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()