diff --git a/Support/API.md b/Support/API.md index 33c299be..df57a884 100644 --- a/Support/API.md +++ b/Support/API.md @@ -1155,6 +1155,78 @@ Get discovery pool metadata (when it was last populated, track count). } ``` +#### `GET /api/v1/discover/bubbles` + +List all bubble snapshots for the current profile. Returns snapshots for three types: `artist_bubbles`, `search_bubbles`, `discover_downloads`. Each snapshot is `null` if it hasn't been created yet. + +```json +{ + "data": { + "snapshots": { + "artist_bubbles": { + "data": { + "bubbles": [ + { "name": "Radiohead", "value": 45, "genre": "alternative rock" }, + { "name": "Daft Punk", "value": 32, "genre": "electronic" }, + { "name": "Portishead", "value": 18, "genre": "trip hop" } + ], + "total_artists": 3, + "generated_at": "2026-03-04T06:00:00" + }, + "timestamp": "2026-03-04T06:00:00" + }, + "search_bubbles": { + "data": { + "bubbles": [ + { "query": "ambient electronic", "count": 12 }, + { "query": "shoegaze", "count": 8 } + ], + "generated_at": "2026-03-03T14:00:00" + }, + "timestamp": "2026-03-03T14:00:00" + }, + "discover_downloads": null + } + } +} +``` + +#### `GET /api/v1/discover/bubbles/` + +Get a specific bubble snapshot by type. + +| Param | Type | Description | +|-------|------|-------------| +| `snapshot_type` | path | `artist_bubbles`, `search_bubbles`, or `discover_downloads` | + +```json +{ + "data": { + "snapshot": { + "data": { + "bubbles": [ + { "name": "Radiohead", "value": 45, "genre": "alternative rock" }, + { "name": "Daft Punk", "value": 32, "genre": "electronic" }, + { "name": "Portishead", "value": 18, "genre": "trip hop" } + ], + "total_artists": 3, + "generated_at": "2026-03-04T06:00:00" + }, + "timestamp": "2026-03-04T06:00:00" + } + } +} +``` + +**Bubble snapshot fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `data` | object | Parsed snapshot data (structure varies by type — contains bubble arrays and metadata) | +| `timestamp` | string | ISO 8601 timestamp when the snapshot was created/updated | + +> **Snapshot types:** `artist_bubbles` — artist listening weight visualization data. `search_bubbles` — search frequency visualization data. `discover_downloads` — discovery download activity visualization data. Returns `404` if the requested snapshot type doesn't exist for this profile. + --- ### Playlists @@ -1235,6 +1307,745 @@ Trigger playlist sync/download. --- +### Profiles + +Manage user profiles. Profiles scope watchlist, wishlist, and discovery data. + +#### `GET /api/v1/profiles` + +List all profiles. + +```json +{ + "data": { + "profiles": [ + { + "id": 1, + "name": "Admin", + "avatar_color": "#6366f1", + "avatar_url": null, + "is_admin": true, + "has_pin": false, + "created_at": "2026-01-15T10:00:00", + "updated_at": "2026-02-20T14:30:00" + }, + { + "id": 2, + "name": "Family", + "avatar_color": "#10b981", + "avatar_url": "https://example.com/avatar.jpg", + "is_admin": false, + "has_pin": true, + "created_at": "2026-02-01T15:00:00", + "updated_at": "2026-03-01T09:45:00" + } + ] + } +} +``` + +#### `GET /api/v1/profiles/` + +Get a single profile by ID. + +```json +{ + "data": { + "profile": { + "id": 2, + "name": "Family", + "avatar_color": "#10b981", + "avatar_url": "https://example.com/avatar.jpg", + "is_admin": false, + "has_pin": true, + "created_at": "2026-02-01T15:00:00", + "updated_at": "2026-03-01T09:45:00" + } + } +} +``` + +**Profile fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Profile ID (auto-increment) | +| `name` | string | Display name (unique) | +| `avatar_color` | string | Hex color for the default avatar circle (default `#6366f1`) | +| `avatar_url` | string? | Custom avatar image URL | +| `is_admin` | bool | Whether this profile has admin privileges | +| `has_pin` | bool | Whether a PIN is set (the PIN hash itself is never exposed) | +| `created_at` | string | ISO 8601 timestamp when profile was created | +| `updated_at` | string | ISO 8601 timestamp of last profile update | + +> **Note:** The `pin_hash` column exists in the database but is never returned by the API. Only the computed `has_pin` boolean is exposed. + +#### `POST /api/v1/profiles` + +Create a new profile. + +**Request body:** + +```json +{ + "name": "Family", + "avatar_color": "#10b981", + "avatar_url": "https://example.com/avatar.jpg", + "is_admin": false, + "pin": "1234" +} +``` + +All fields except `name` are optional. The `pin` field accepts a raw PIN string — it is hashed with PBKDF2-SHA256 before storage. + +**Response (201 Created):** + +```json +{ + "data": { + "profile": { + "id": 3, + "name": "Family", + "avatar_color": "#10b981", + "avatar_url": "https://example.com/avatar.jpg", + "is_admin": false, + "has_pin": true, + "created_at": "2026-03-04T12:00:00", + "updated_at": "2026-03-04T12:00:00" + } + } +} +``` + +Returns `409 CONFLICT` if the profile name already exists. + +#### `PUT /api/v1/profiles/` + +Update a profile. Only include the fields you want to change. + +**Request body:** + +```json +{ + "name": "New Name", + "avatar_color": "#ef4444", + "pin": "5678" +} +``` + +Set `"pin": ""` or `"pin": null` to remove a PIN. + +**Response:** + +```json +{ + "data": { + "profile": { + "id": 2, + "name": "New Name", + "avatar_color": "#ef4444", + "avatar_url": "https://example.com/avatar.jpg", + "is_admin": false, + "has_pin": true, + "created_at": "2026-02-01T15:00:00", + "updated_at": "2026-03-04T12:05:00" + } + } +} +``` + +#### `DELETE /api/v1/profiles/` + +Delete a profile and all its per-profile data (watchlist, wishlist, discovery pool, bubble snapshots). Cannot delete profile 1 (admin) — returns `403 FORBIDDEN`. + +**Response:** + +```json +{ + "data": { + "message": "Profile 3 deleted." + } +} +``` + +--- + +### Retag Queue + +Browse and manage the retag queue — albums/tracks pending metadata corrections. + +#### `GET /api/v1/retag/groups` + +List all retag groups with track counts. Groups are ordered by artist name (ascending) then creation date (descending). + +```json +{ + "data": { + "groups": [ + { + "id": 1, + "group_type": "album", + "artist_name": "Radiohead", + "album_name": "OK Computer", + "image_url": "https://i.scdn.co/image/ab67616d0000b273c8b444df094c596ea9da41f6", + "spotify_album_id": "6dVIqQ8qmQ5GBnJ9shOYGE", + "itunes_album_id": "1097862703", + "total_tracks": 12, + "release_date": "1997-06-16", + "created_at": "2026-03-04T10:00:00", + "track_count": 12 + }, + { + "id": 2, + "group_type": "album", + "artist_name": "Radiohead", + "album_name": "In Rainbows", + "image_url": "https://i.scdn.co/image/ab67616d0000b2737de1fcc2ebeab8b6e22a1b8a", + "spotify_album_id": "7eyQXxuf2nGj9d2367Gi5f", + "itunes_album_id": "1109731429", + "total_tracks": 10, + "release_date": "2007-10-10", + "created_at": "2026-03-04T10:05:00", + "track_count": 10 + } + ] + } +} +``` + +**Retag group fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Group ID (auto-increment) | +| `group_type` | string | Type of retag group (currently always `album`) | +| `artist_name` | string | Artist name for this retag group | +| `album_name` | string | Album name for this retag group | +| `image_url` | string? | Album cover image URL | +| `spotify_album_id` | string? | Spotify album ID for metadata lookup | +| `itunes_album_id` | string? | iTunes/Apple Music album ID for metadata lookup | +| `total_tracks` | int | Expected total tracks for the album | +| `release_date` | string? | Album release date (YYYY-MM-DD) | +| `created_at` | string | ISO 8601 timestamp when group was created | +| `track_count` | int | Computed: number of tracks currently in this group | + +#### `GET /api/v1/retag/groups/` + +Get a retag group with its full track list. Tracks are ordered by disc number then track number. + +```json +{ + "data": { + "group": { + "id": 1, + "group_type": "album", + "artist_name": "Radiohead", + "album_name": "OK Computer", + "image_url": "https://i.scdn.co/image/ab67616d0000b273c8b444df094c596ea9da41f6", + "spotify_album_id": "6dVIqQ8qmQ5GBnJ9shOYGE", + "itunes_album_id": "1097862703", + "total_tracks": 12, + "release_date": "1997-06-16", + "created_at": "2026-03-04T10:00:00", + "track_count": 12 + }, + "tracks": [ + { + "id": 1, + "group_id": 1, + "track_number": 1, + "disc_number": 1, + "title": "Airbag", + "file_path": "/downloads/Radiohead/OK Computer/01 - Airbag.flac", + "file_format": "flac", + "spotify_track_id": "6LgJvl0Xdtc73RJ1mN1a7Z", + "itunes_track_id": null, + "created_at": "2026-03-04T10:00:00" + }, + { + "id": 2, + "group_id": 1, + "track_number": 2, + "disc_number": 1, + "title": "Paranoid Android", + "file_path": "/downloads/Radiohead/OK Computer/02 - Paranoid Android.flac", + "file_format": "flac", + "spotify_track_id": "6LgJvl0Xdtc73RJ1mN1a7Z", + "itunes_track_id": "1097863062", + "created_at": "2026-03-04T10:00:00" + }, + { + "id": 3, + "group_id": 1, + "track_number": 3, + "disc_number": 1, + "title": "Subterranean Homesick Alien", + "file_path": "/downloads/Radiohead/OK Computer/03 - Subterranean Homesick Alien.flac", + "file_format": "flac", + "spotify_track_id": "3sFhbVuZGMAsmFSIFGVPgS", + "itunes_track_id": null, + "created_at": "2026-03-04T10:00:00" + } + ] + } +} +``` + +**Retag track fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Track ID (auto-increment) | +| `group_id` | int | Parent retag group ID (foreign key) | +| `track_number` | int? | Track number on disc | +| `disc_number` | int | Disc number (default 1) | +| `title` | string | Track title | +| `file_path` | string | Full file path to the downloaded audio file | +| `file_format` | string? | Audio format (`flac`, `mp3`, `opus`, etc.) | +| `spotify_track_id` | string? | Spotify track ID for metadata lookup | +| `itunes_track_id` | string? | iTunes/Apple Music track ID for metadata lookup | +| `created_at` | string | ISO 8601 timestamp when track was added to queue | + +#### `DELETE /api/v1/retag/groups/` + +Delete a specific retag group and all its tracks (cascade delete). + +```json +{ + "data": { + "message": "Retag group 1 deleted." + } +} +``` + +#### `DELETE /api/v1/retag/groups` + +Clear all retag groups and tracks from the queue. + +```json +{ + "data": { + "message": "Cleared 5 retag groups." + } +} +``` + +#### `GET /api/v1/retag/stats` + +Get retag queue statistics. + +```json +{ + "data": { + "groups": 5, + "tracks": 47, + "artists": 3 + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `groups` | int | Total number of retag groups in queue | +| `tracks` | int | Total number of tracks across all groups | +| `artists` | int | Number of distinct artists in the queue | + +--- + +### ListenBrainz + +Browse cached ListenBrainz playlists and their tracks. Playlists are cached locally when SoulSync pulls recommendations from ListenBrainz. + +#### `GET /api/v1/listenbrainz/playlists` + +List cached ListenBrainz playlists with optional type filtering and pagination. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `type` | string | | Filter by playlist_type (e.g. `weekly-jams`, `weekly-exploration`) | +| `page` | int | 1 | Page number | +| `limit` | int | 50 | Items per page (max 200) | + +```json +{ + "data": { + "playlists": [ + { + "id": 1, + "playlist_mbid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Weekly Jams for user123", + "creator": "listenbrainz", + "playlist_type": "weekly-jams", + "track_count": 50, + "annotation_data": { + "algorithm": "collaborative-filtering", + "source_patch": "weekly-jams" + }, + "last_updated": "2026-03-03T06:00:00", + "cached_date": "2026-03-03T06:05:00" + }, + { + "id": 2, + "playlist_mbid": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "title": "Weekly Exploration for user123", + "creator": "listenbrainz", + "playlist_type": "weekly-exploration", + "track_count": 50, + "annotation_data": { + "algorithm": "collaborative-filtering", + "source_patch": "weekly-exploration" + }, + "last_updated": "2026-03-03T06:00:00", + "cached_date": "2026-03-03T06:08:00" + } + ] + }, + "pagination": { "page": 1, "limit": 50, "total": 8, "total_pages": 1, "has_next": false, "has_prev": false } +} +``` + +**ListenBrainz playlist fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Internal database ID | +| `playlist_mbid` | string | MusicBrainz playlist MBID (unique) | +| `title` | string | Playlist title as given by ListenBrainz | +| `creator` | string? | Playlist creator (usually `listenbrainz`) | +| `playlist_type` | string | Type: `weekly-jams`, `weekly-exploration`, etc. | +| `track_count` | int | Number of tracks in the playlist | +| `annotation_data` | object? | Parsed JSON annotation metadata from ListenBrainz | +| `last_updated` | string | ISO 8601 timestamp of last update from ListenBrainz | +| `cached_date` | string | ISO 8601 timestamp when SoulSync cached this playlist | + +#### `GET /api/v1/listenbrainz/playlists/` + +Get a ListenBrainz playlist with its full track list. `playlist_id` can be the internal database ID (integer) or the MusicBrainz playlist MBID (UUID string). Tracks are returned in playlist order (by position). + +```json +{ + "data": { + "playlist": { + "id": 1, + "playlist_mbid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Weekly Jams for user123", + "creator": "listenbrainz", + "playlist_type": "weekly-jams", + "track_count": 50, + "annotation_data": { + "algorithm": "collaborative-filtering", + "source_patch": "weekly-jams" + }, + "last_updated": "2026-03-03T06:00:00", + "cached_date": "2026-03-03T06:05:00" + }, + "tracks": [ + { + "id": 1, + "playlist_id": 1, + "position": 1, + "track_name": "Paranoid Android", + "artist_name": "Radiohead", + "album_name": "OK Computer", + "duration_ms": 383000, + "recording_mbid": "b3e2b7e0-a147-4b3c-8eab-fd90bfff7e74", + "release_mbid": "a1c35a51-d102-4ce7-b7b0-8a4f68385bb2", + "album_cover_url": "https://coverartarchive.org/release/a1c35a51-d102-4ce7-b7b0-8a4f68385bb2/front-250.jpg", + "additional_metadata": { + "artist_mbids": ["a74b1b7f-71a5-4011-9441-d0b5e4122711"], + "caa_id": 12345678901, + "caa_release_mbid": "a1c35a51-d102-4ce7-b7b0-8a4f68385bb2" + } + }, + { + "id": 2, + "playlist_id": 1, + "position": 2, + "track_name": "All I Need", + "artist_name": "Radiohead", + "album_name": "In Rainbows", + "duration_ms": 226000, + "recording_mbid": "c7d9e0f1-2345-6789-abcd-ef0123456789", + "release_mbid": "d4e5f6a7-8901-2345-bcde-f67890123456", + "album_cover_url": "https://coverartarchive.org/release/d4e5f6a7-8901-2345-bcde-f67890123456/front-250.jpg", + "additional_metadata": { + "artist_mbids": ["a74b1b7f-71a5-4011-9441-d0b5e4122711"], + "caa_id": 23456789012, + "caa_release_mbid": "d4e5f6a7-8901-2345-bcde-f67890123456" + } + }, + { + "id": 3, + "playlist_id": 1, + "position": 3, + "track_name": "Something About Us", + "artist_name": "Daft Punk", + "album_name": "Discovery", + "duration_ms": 232000, + "recording_mbid": "e8f9a0b1-c2d3-e4f5-6789-012345678901", + "release_mbid": "f0a1b2c3-d4e5-f6a7-8901-234567890123", + "album_cover_url": "https://coverartarchive.org/release/f0a1b2c3-d4e5-f6a7-8901-234567890123/front-250.jpg", + "additional_metadata": { + "artist_mbids": ["056e4f3e-d505-4dad-8ec1-d04f521cbb56"], + "caa_id": 34567890123, + "caa_release_mbid": "f0a1b2c3-d4e5-f6a7-8901-234567890123" + } + } + ] + } +} +``` + +**ListenBrainz track fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Internal track ID | +| `playlist_id` | int | Parent playlist ID (foreign key) | +| `position` | int | Position in the playlist (1-based) | +| `track_name` | string | Track title | +| `artist_name` | string | Artist name | +| `album_name` | string | Album name | +| `duration_ms` | int | Track duration in milliseconds | +| `recording_mbid` | string? | MusicBrainz recording MBID | +| `release_mbid` | string? | MusicBrainz release MBID | +| `album_cover_url` | string? | Album cover art URL (usually from Cover Art Archive) | +| `additional_metadata` | object? | Parsed JSON with extra MusicBrainz data (artist MBIDs, CAA info, etc.) | + +--- + +### Cache + +Browse internal enrichment caches. Useful for debugging enrichment issues, understanding match quality, or auditing what metadata has been resolved. + +#### `GET /api/v1/cache/musicbrainz` + +List cached MusicBrainz lookups with optional filtering and pagination. Results ordered by `last_updated` descending. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `entity_type` | string | | Filter by type: `artist`, `album`, `track` | +| `search` | string | | Filter by entity name (case-insensitive partial match) | +| `page` | int | 1 | Page number | +| `limit` | int | 50 | Items per page (max 200) | + +```json +{ + "data": { + "entries": [ + { + "id": 42, + "entity_type": "artist", + "entity_name": "Radiohead", + "artist_name": null, + "musicbrainz_id": "a74b1b7f-71a5-4011-9441-d0b5e4122711", + "spotify_id": "4Z8W4fKeB5YxbusRsdQVPb", + "itunes_id": "657515", + "metadata_json": { + "name": "Radiohead", + "type": "Group", + "country": "GB", + "disambiguation": "English rock band", + "begin_date": "1985", + "end_date": null, + "tags": ["alternative rock", "art rock", "experimental"] + }, + "match_confidence": 95, + "last_updated": "2026-03-01T12:00:00" + }, + { + "id": 88, + "entity_type": "album", + "entity_name": "OK Computer", + "artist_name": "Radiohead", + "musicbrainz_id": "b1f5a82e-5cfa-36f8-b084-4a93d7e7e608", + "spotify_id": "6dVIqQ8qmQ5GBnJ9shOYGE", + "itunes_id": "1097862703", + "metadata_json": { + "title": "OK Computer", + "date": "1997-06-16", + "country": "XE", + "status": "Official", + "packaging": "Jewel Case", + "barcode": "724385522925" + }, + "match_confidence": 100, + "last_updated": "2026-02-28T08:30:00" + }, + { + "id": 215, + "entity_type": "track", + "entity_name": "Paranoid Android", + "artist_name": "Radiohead", + "musicbrainz_id": "b3e2b7e0-a147-4b3c-8eab-fd90bfff7e74", + "spotify_id": "6LgJvl0Xdtc73RJ1mN1a7Z", + "itunes_id": "1097863062", + "metadata_json": { + "title": "Paranoid Android", + "length": 383000, + "isrcs": ["GBAYE9700074"] + }, + "match_confidence": 98, + "last_updated": "2026-02-28T08:35:00" + } + ] + }, + "pagination": { "page": 1, "limit": 50, "total": 1250, "total_pages": 25, "has_next": true, "has_prev": false } +} +``` + +**MusicBrainz cache fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Cache entry ID | +| `entity_type` | string | Entity type: `artist`, `album`, or `track` | +| `entity_name` | string | Name that was looked up | +| `artist_name` | string? | Associated artist name (for album/track lookups) | +| `musicbrainz_id` | string? | Resolved MusicBrainz MBID (`null` if no match) | +| `spotify_id` | string? | Cross-referenced Spotify ID | +| `itunes_id` | string? | Cross-referenced iTunes/Apple Music ID | +| `metadata_json` | object? | Parsed JSON with full MusicBrainz metadata (tags, dates, ISRCs, etc.) | +| `match_confidence` | int? | Match confidence score (0-100) | +| `last_updated` | string | ISO 8601 timestamp of last cache update | + +> **Unique constraint:** `(entity_type, entity_name, artist_name)` — each entity is cached once per type+name combination. + +#### `GET /api/v1/cache/musicbrainz/stats` + +Get MusicBrainz cache statistics — total entries, matched vs unmatched, and breakdown by entity type. + +```json +{ + "data": { + "total": 1250, + "matched": 1100, + "unmatched": 150, + "by_type": { + "artist": 500, + "album": 450, + "track": 300 + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `total` | int | Total cache entries | +| `matched` | int | Entries with a resolved `musicbrainz_id` | +| `unmatched` | int | Entries where no MusicBrainz match was found | +| `by_type` | object | Entry counts keyed by `entity_type` | + +#### `GET /api/v1/cache/discovery-matches` + +List cached discovery provider matches. These are stored when the discovery system resolves tracks from external providers. Results ordered by `last_used_at` descending. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `provider` | string | | Filter by provider (e.g. `spotify`, `itunes`) | +| `search` | string | | Filter by title or artist (case-insensitive partial match on both) | +| `page` | int | 1 | Page number | +| `limit` | int | 50 | Items per page (max 200) | + +```json +{ + "data": { + "entries": [ + { + "id": 100, + "normalized_title": "paranoid android", + "normalized_artist": "radiohead", + "provider": "spotify", + "match_confidence": 0.95, + "matched_data_json": { + "track_id": "6LgJvl0Xdtc73RJ1mN1a7Z", + "track_name": "Paranoid Android", + "artist_name": "Radiohead", + "album_name": "OK Computer", + "album_id": "6dVIqQ8qmQ5GBnJ9shOYGE", + "duration_ms": 383000, + "popularity": 72, + "preview_url": "https://p.scdn.co/mp3-preview/..." + }, + "original_title": "Paranoid Android", + "original_artist": "Radiohead", + "created_at": "2026-02-15T10:00:00", + "last_used_at": "2026-03-04T09:00:00", + "use_count": 12 + }, + { + "id": 101, + "normalized_title": "something about us", + "normalized_artist": "daft punk", + "provider": "itunes", + "match_confidence": 0.92, + "matched_data_json": { + "track_id": "724633277", + "track_name": "Something About Us", + "artist_name": "Daft Punk", + "album_name": "Discovery", + "collection_id": "724633180", + "duration_ms": 232000, + "artwork_url": "https://is1-ssl.mzstatic.com/image/..." + }, + "original_title": "Something About Us", + "original_artist": "Daft Punk", + "created_at": "2026-02-20T14:30:00", + "last_used_at": "2026-03-03T18:00:00", + "use_count": 5 + } + ] + }, + "pagination": { "page": 1, "limit": 50, "total": 3200, "total_pages": 64, "has_next": true, "has_prev": false } +} +``` + +**Discovery match cache fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Cache entry ID | +| `normalized_title` | string | Lowercase normalized track title used for matching | +| `normalized_artist` | string | Lowercase normalized artist name used for matching | +| `provider` | string | Provider that was matched against (`spotify`, `itunes`) | +| `match_confidence` | float | Match confidence score (0.0-1.0) | +| `matched_data_json` | object? | Parsed JSON with full matched track data from the provider | +| `original_title` | string? | Original (un-normalized) track title | +| `original_artist` | string? | Original (un-normalized) artist name | +| `created_at` | string | ISO 8601 timestamp when match was first cached | +| `last_used_at` | string | ISO 8601 timestamp when match was last reused | +| `use_count` | int | Number of times this cached match has been reused | + +> **Unique constraint:** `(normalized_title, normalized_artist, provider)` — each title+artist+provider combination is cached once. + +#### `GET /api/v1/cache/discovery-matches/stats` + +Get discovery match cache statistics — total entries, total reuses, average confidence, and breakdown by provider. + +```json +{ + "data": { + "total": 3200, + "total_uses": 15000, + "avg_confidence": 0.872, + "by_provider": { + "spotify": 2800, + "itunes": 400 + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `total` | int | Total cached match entries | +| `total_uses` | int | Sum of all `use_count` values across entries | +| `avg_confidence` | float? | Average match confidence (rounded to 3 decimals, `null` if empty) | +| `by_provider` | object | Entry counts keyed by provider name | + +--- + ### Settings #### `GET /api/v1/settings` @@ -1553,6 +2364,28 @@ curl -H "Authorization: Bearer sk_..." \ | GET | `/discover/similar-artists` | Similar artists | | GET | `/discover/recent-releases` | Recent releases | | GET | `/discover/pool/metadata` | Pool metadata | +| GET | `/discover/bubbles` | All bubble snapshots | +| GET | `/discover/bubbles/` | Specific bubble snapshot | +| **Profiles** | | | +| GET | `/profiles` | List all profiles | +| GET | `/profiles/` | Profile detail | +| POST | `/profiles` | Create profile | +| PUT | `/profiles/` | Update profile | +| DELETE | `/profiles/` | Delete profile | +| **Retag Queue** | | | +| GET | `/retag/groups` | List retag groups | +| GET | `/retag/groups/` | Retag group detail + tracks | +| DELETE | `/retag/groups/` | Delete retag group | +| DELETE | `/retag/groups` | Clear all retag groups | +| GET | `/retag/stats` | Retag queue statistics | +| **ListenBrainz** | | | +| GET | `/listenbrainz/playlists` | List cached playlists | +| GET | `/listenbrainz/playlists/` | Playlist detail + tracks | +| **Cache** | | | +| GET | `/cache/musicbrainz` | Browse MusicBrainz cache | +| GET | `/cache/musicbrainz/stats` | MusicBrainz cache statistics | +| GET | `/cache/discovery-matches` | Browse discovery match cache | +| GET | `/cache/discovery-matches/stats` | Discovery match cache statistics | | **Playlists** | | | | GET | `/playlists` | List playlists | | GET | `/playlists/` | Playlist detail + tracks | diff --git a/api/__init__.py b/api/__init__.py index 66f8d5a4..acca117d 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -38,6 +38,10 @@ def create_api_blueprint(): from .playlists import register_routes as reg_playlists from .settings import register_routes as reg_settings from .discover import register_routes as reg_discover + from .profiles import register_routes as reg_profiles + from .retag import register_routes as reg_retag + from .listenbrainz import register_routes as reg_listenbrainz + from .cache import register_routes as reg_cache reg_library(bp) reg_system(bp) @@ -48,6 +52,10 @@ def create_api_blueprint(): reg_playlists(bp) reg_settings(bp) reg_discover(bp) + reg_profiles(bp) + reg_retag(bp) + reg_listenbrainz(bp) + reg_cache(bp) # ---- error handlers (scoped to this Blueprint) ---- @bp.errorhandler(400) diff --git a/api/cache.py b/api/cache.py new file mode 100644 index 00000000..a7058020 --- /dev/null +++ b/api/cache.py @@ -0,0 +1,206 @@ +""" +Cache endpoints — browse MusicBrainz and discovery match caches. +""" + +import json +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 + + +def register_routes(bp): + + # ── MusicBrainz Cache ────────────────────────────────────── + + @bp.route("/cache/musicbrainz", methods=["GET"]) + @require_api_key + def list_musicbrainz_cache(): + """List cached MusicBrainz lookups. + + Query params: + entity_type: Filter by type ('artist', 'album', 'track') + search: Filter by entity_name + page: Page number + limit: Items per page + """ + page, limit = parse_pagination(request) + entity_type = request.args.get("entity_type") + search = request.args.get("search", "").strip() + + try: + db = get_database() + conn = db._get_connection() + cursor = conn.cursor() + + where_parts = [] + params = [] + + if entity_type: + where_parts.append("entity_type = ?") + params.append(entity_type) + if search: + where_parts.append("LOWER(entity_name) LIKE LOWER(?)") + params.append(f"%{search}%") + + where_clause = f"WHERE {' AND '.join(where_parts)}" if where_parts else "" + + cursor.execute(f"SELECT COUNT(*) as cnt FROM musicbrainz_cache {where_clause}", params) + total = cursor.fetchone()["cnt"] + + offset = (page - 1) * limit + cursor.execute(f""" + SELECT * FROM musicbrainz_cache + {where_clause} + ORDER BY last_updated DESC + LIMIT ? OFFSET ? + """, params + [limit, offset]) + + entries = [] + for row in cursor.fetchall(): + entry = dict(row) + if entry.get("metadata_json") and isinstance(entry["metadata_json"], str): + try: + entry["metadata_json"] = json.loads(entry["metadata_json"]) + except (json.JSONDecodeError, TypeError): + pass + entries.append(entry) + + return api_success( + {"entries": entries}, + pagination=build_pagination(page, limit, total), + ) + except Exception as e: + return api_error("CACHE_ERROR", str(e), 500) + + @bp.route("/cache/musicbrainz/stats", methods=["GET"]) + @require_api_key + def musicbrainz_cache_stats(): + """Get MusicBrainz cache statistics.""" + try: + db = get_database() + conn = db._get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT COUNT(*) as total FROM musicbrainz_cache") + total = cursor.fetchone()["total"] + + cursor.execute(""" + SELECT entity_type, COUNT(*) as count + FROM musicbrainz_cache + GROUP BY entity_type + ORDER BY count DESC + """) + by_type = {row["entity_type"]: row["count"] for row in cursor.fetchall()} + + cursor.execute(""" + SELECT COUNT(*) as matched FROM musicbrainz_cache + WHERE musicbrainz_id IS NOT NULL + """) + matched = cursor.fetchone()["matched"] + + return api_success({ + "total": total, + "matched": matched, + "unmatched": total - matched, + "by_type": by_type, + }) + except Exception as e: + return api_error("CACHE_ERROR", str(e), 500) + + # ── Discovery Match Cache ────────────────────────────────── + + @bp.route("/cache/discovery-matches", methods=["GET"]) + @require_api_key + def list_discovery_match_cache(): + """List cached discovery provider matches. + + Query params: + provider: Filter by provider ('spotify', 'itunes', etc.) + search: Filter by title or artist + page: Page number + limit: Items per page + """ + page, limit = parse_pagination(request) + provider = request.args.get("provider") + search = request.args.get("search", "").strip() + + try: + db = get_database() + conn = db._get_connection() + cursor = conn.cursor() + + where_parts = [] + params = [] + + if provider: + where_parts.append("provider = ?") + params.append(provider) + if search: + where_parts.append("(LOWER(original_title) LIKE LOWER(?) OR LOWER(original_artist) LIKE LOWER(?))") + params.extend([f"%{search}%", f"%{search}%"]) + + where_clause = f"WHERE {' AND '.join(where_parts)}" if where_parts else "" + + cursor.execute(f"SELECT COUNT(*) as cnt FROM discovery_match_cache {where_clause}", params) + total = cursor.fetchone()["cnt"] + + offset = (page - 1) * limit + cursor.execute(f""" + SELECT * FROM discovery_match_cache + {where_clause} + ORDER BY last_used_at DESC + LIMIT ? OFFSET ? + """, params + [limit, offset]) + + entries = [] + for row in cursor.fetchall(): + entry = dict(row) + if entry.get("matched_data_json") and isinstance(entry["matched_data_json"], str): + try: + entry["matched_data_json"] = json.loads(entry["matched_data_json"]) + except (json.JSONDecodeError, TypeError): + pass + entries.append(entry) + + return api_success( + {"entries": entries}, + pagination=build_pagination(page, limit, total), + ) + except Exception as e: + return api_error("CACHE_ERROR", str(e), 500) + + @bp.route("/cache/discovery-matches/stats", methods=["GET"]) + @require_api_key + def discovery_match_cache_stats(): + """Get discovery match cache statistics.""" + try: + db = get_database() + conn = db._get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT COUNT(*) as total FROM discovery_match_cache") + total = cursor.fetchone()["total"] + + cursor.execute(""" + SELECT provider, COUNT(*) as count + FROM discovery_match_cache + GROUP BY provider + ORDER BY count DESC + """) + by_provider = {row["provider"]: row["count"] for row in cursor.fetchall()} + + cursor.execute("SELECT SUM(use_count) as total_uses FROM discovery_match_cache") + total_uses = cursor.fetchone()["total_uses"] or 0 + + cursor.execute("SELECT AVG(match_confidence) as avg_confidence FROM discovery_match_cache") + avg_confidence = cursor.fetchone()["avg_confidence"] + + return api_success({ + "total": total, + "total_uses": total_uses, + "avg_confidence": round(avg_confidence, 3) if avg_confidence else None, + "by_provider": by_provider, + }) + except Exception as e: + return api_error("CACHE_ERROR", str(e), 500) diff --git a/api/discover.py b/api/discover.py index c8ad0d86..7e85fda3 100644 --- a/api/discover.py +++ b/api/discover.py @@ -151,3 +151,48 @@ def register_routes(bp): }) except Exception as e: return api_error("DISCOVER_ERROR", str(e), 500) + + # ── Bubble Snapshots ─────────────────────────────────────── + + @bp.route("/discover/bubbles", methods=["GET"]) + @require_api_key + def list_bubble_snapshots(): + """List all bubble snapshots for the current profile. + + Returns snapshots for all types: artist_bubbles, search_bubbles, discover_downloads. + """ + profile_id = parse_profile_id(request) + + try: + db = get_database() + result = {} + for snap_type in ("artist_bubbles", "search_bubbles", "discover_downloads"): + snapshot = db.get_bubble_snapshot(snap_type, profile_id=profile_id) + result[snap_type] = snapshot # None if not found + + return api_success({"snapshots": result}) + except Exception as e: + return api_error("DISCOVER_ERROR", str(e), 500) + + @bp.route("/discover/bubbles/", methods=["GET"]) + @require_api_key + def get_bubble_snapshot(snapshot_type): + """Get a specific bubble snapshot by type. + + Types: artist_bubbles, search_bubbles, discover_downloads + """ + valid_types = ("artist_bubbles", "search_bubbles", "discover_downloads") + if snapshot_type not in valid_types: + return api_error("BAD_REQUEST", f"type must be one of: {', '.join(valid_types)}", 400) + + profile_id = parse_profile_id(request) + + try: + db = get_database() + snapshot = db.get_bubble_snapshot(snapshot_type, profile_id=profile_id) + if not snapshot: + return api_error("NOT_FOUND", f"No '{snapshot_type}' snapshot found.", 404) + + return api_success({"snapshot": snapshot}) + except Exception as e: + return api_error("DISCOVER_ERROR", str(e), 500) diff --git a/api/listenbrainz.py b/api/listenbrainz.py new file mode 100644 index 00000000..98d13e1e --- /dev/null +++ b/api/listenbrainz.py @@ -0,0 +1,124 @@ +""" +ListenBrainz endpoints — browse cached ListenBrainz playlists and tracks. +""" + +import json +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 + + +def register_routes(bp): + + @bp.route("/listenbrainz/playlists", methods=["GET"]) + @require_api_key + def list_playlists(): + """List cached ListenBrainz playlists. + + Query params: + type: Filter by playlist_type (e.g. 'weekly-jams', 'weekly-exploration') + page: Page number + limit: Items per page + """ + page, limit = parse_pagination(request) + playlist_type = request.args.get("type") + + try: + db = get_database() + conn = db._get_connection() + cursor = conn.cursor() + + where_parts = [] + params = [] + + if playlist_type: + where_parts.append("playlist_type = ?") + params.append(playlist_type) + + where_clause = f"WHERE {' AND '.join(where_parts)}" if where_parts else "" + + # Count + cursor.execute(f"SELECT COUNT(*) as cnt FROM listenbrainz_playlists {where_clause}", params) + total = cursor.fetchone()["cnt"] + + # Fetch page + offset = (page - 1) * limit + cursor.execute(f""" + SELECT * FROM listenbrainz_playlists + {where_clause} + ORDER BY last_updated DESC + LIMIT ? OFFSET ? + """, params + [limit, offset]) + + playlists = [] + for row in cursor.fetchall(): + p = dict(row) + # Parse annotation_data if it's JSON + if p.get("annotation_data") and isinstance(p["annotation_data"], str): + try: + p["annotation_data"] = json.loads(p["annotation_data"]) + except (json.JSONDecodeError, TypeError): + pass + playlists.append(p) + + return api_success( + {"playlists": playlists}, + pagination=build_pagination(page, limit, total), + ) + except Exception as e: + return api_error("LISTENBRAINZ_ERROR", str(e), 500) + + @bp.route("/listenbrainz/playlists/", methods=["GET"]) + @require_api_key + def get_playlist(playlist_id): + """Get a ListenBrainz playlist with its tracks. + + playlist_id can be the internal ID or the MusicBrainz playlist MBID. + """ + try: + db = get_database() + conn = db._get_connection() + cursor = conn.cursor() + + # Try by internal ID first, then by MBID + try: + int_id = int(playlist_id) + cursor.execute("SELECT * FROM listenbrainz_playlists WHERE id = ?", (int_id,)) + except ValueError: + cursor.execute("SELECT * FROM listenbrainz_playlists WHERE playlist_mbid = ?", (playlist_id,)) + + row = cursor.fetchone() + if not row: + return api_error("NOT_FOUND", f"ListenBrainz playlist '{playlist_id}' not found.", 404) + + playlist = dict(row) + if playlist.get("annotation_data") and isinstance(playlist["annotation_data"], str): + try: + playlist["annotation_data"] = json.loads(playlist["annotation_data"]) + except (json.JSONDecodeError, TypeError): + pass + + # Get tracks + cursor.execute(""" + SELECT * FROM listenbrainz_tracks + WHERE playlist_id = ? + ORDER BY position ASC + """, (playlist["id"],)) + + tracks = [] + for t_row in cursor.fetchall(): + track = dict(t_row) + if track.get("additional_metadata") and isinstance(track["additional_metadata"], str): + try: + track["additional_metadata"] = json.loads(track["additional_metadata"]) + except (json.JSONDecodeError, TypeError): + pass + tracks.append(track) + + return api_success({ + "playlist": playlist, + "tracks": tracks, + }) + except Exception as e: + return api_error("LISTENBRAINZ_ERROR", str(e), 500) diff --git a/api/profiles.py b/api/profiles.py new file mode 100644 index 00000000..52f1d347 --- /dev/null +++ b/api/profiles.py @@ -0,0 +1,130 @@ +""" +Profile management endpoints — list, create, update, delete profiles. +""" + +from flask import request +from database.music_database import get_database +from .auth import require_api_key +from .helpers import api_success, api_error + + +def register_routes(bp): + + @bp.route("/profiles", methods=["GET"]) + @require_api_key + def list_profiles(): + """List all profiles.""" + try: + db = get_database() + profiles = db.get_all_profiles() + return api_success({"profiles": profiles}) + except Exception as e: + return api_error("PROFILE_ERROR", str(e), 500) + + @bp.route("/profiles/", methods=["GET"]) + @require_api_key + def get_profile(profile_id): + """Get a single profile by ID.""" + try: + db = get_database() + profile = db.get_profile(profile_id) + if not profile: + return api_error("NOT_FOUND", f"Profile {profile_id} not found.", 404) + return api_success({"profile": profile}) + except Exception as e: + return api_error("PROFILE_ERROR", str(e), 500) + + @bp.route("/profiles", methods=["POST"]) + @require_api_key + def create_profile(): + """Create a new profile. + + Body: {"name": "...", "avatar_color": "#hex", "avatar_url": "...", "is_admin": false} + """ + body = request.get_json(silent=True) or {} + name = body.get("name", "").strip() + + if not name: + return api_error("BAD_REQUEST", "Missing 'name' in body.", 400) + + avatar_color = body.get("avatar_color", "#6366f1") + avatar_url = body.get("avatar_url") + is_admin = bool(body.get("is_admin", False)) + + # Handle optional PIN + pin_hash = None + pin = body.get("pin") + if pin: + from werkzeug.security import generate_password_hash + pin_hash = generate_password_hash(pin, method="pbkdf2:sha256") + + try: + db = get_database() + profile_id = db.create_profile( + name=name, + avatar_color=avatar_color, + pin_hash=pin_hash, + is_admin=is_admin, + avatar_url=avatar_url, + ) + if profile_id: + profile = db.get_profile(profile_id) + return api_success({"profile": profile}, status=201) + return api_error("CONFLICT", "Profile name already exists.", 409) + except Exception as e: + return api_error("PROFILE_ERROR", str(e), 500) + + @bp.route("/profiles/", methods=["PUT"]) + @require_api_key + def update_profile(profile_id): + """Update a profile. + + Body: {"name": "...", "avatar_color": "#hex", "avatar_url": "...", "is_admin": false} + """ + body = request.get_json(silent=True) or {} + + kwargs = {} + if "name" in body: + kwargs["name"] = body["name"].strip() + if "avatar_color" in body: + kwargs["avatar_color"] = body["avatar_color"] + if "avatar_url" in body: + kwargs["avatar_url"] = body["avatar_url"] + if "is_admin" in body: + kwargs["is_admin"] = int(bool(body["is_admin"])) + if "pin" in body: + pin = body["pin"] + if pin: + from werkzeug.security import generate_password_hash + kwargs["pin_hash"] = generate_password_hash(pin, method="pbkdf2:sha256") + else: + kwargs["pin_hash"] = None # Clear PIN + + if not kwargs: + return api_error("BAD_REQUEST", "No valid fields to update.", 400) + + try: + db = get_database() + ok = db.update_profile(profile_id, **kwargs) + if ok: + profile = db.get_profile(profile_id) + return api_success({"profile": profile}) + return api_error("NOT_FOUND", f"Profile {profile_id} not found.", 404) + except Exception as e: + return api_error("PROFILE_ERROR", str(e), 500) + + @bp.route("/profiles/", methods=["DELETE"]) + @require_api_key + def delete_profile(profile_id): + """Delete a profile and all its data. Cannot delete profile 1 (admin).""" + if profile_id == 1: + return api_error("FORBIDDEN", "Cannot delete the default admin profile.", 403) + + try: + db = get_database() + ok = db.delete_profile(profile_id) + if ok: + return api_success({"message": f"Profile {profile_id} deleted."}) + return api_error("NOT_FOUND", f"Profile {profile_id} not found.", 404) + except Exception as e: + return api_error("PROFILE_ERROR", str(e), 500) diff --git a/api/retag.py b/api/retag.py new file mode 100644 index 00000000..0ef9176c --- /dev/null +++ b/api/retag.py @@ -0,0 +1,77 @@ +""" +Retag queue endpoints — view and manage pending metadata corrections. +""" + +from flask import request +from database.music_database import get_database +from .auth import require_api_key +from .helpers import api_success, api_error + + +def register_routes(bp): + + @bp.route("/retag/groups", methods=["GET"]) + @require_api_key + def list_retag_groups(): + """List all retag groups with track counts.""" + try: + db = get_database() + groups = db.get_retag_groups() + return api_success({"groups": groups}) + except Exception as e: + return api_error("RETAG_ERROR", str(e), 500) + + @bp.route("/retag/groups/", methods=["GET"]) + @require_api_key + def get_retag_group(group_id): + """Get a retag group with its tracks.""" + try: + db = get_database() + # Get group info + groups = db.get_retag_groups() + group = next((g for g in groups if g["id"] == group_id), None) + if not group: + return api_error("NOT_FOUND", f"Retag group {group_id} not found.", 404) + + tracks = db.get_retag_tracks(group_id) + return api_success({ + "group": group, + "tracks": tracks, + }) + except Exception as e: + return api_error("RETAG_ERROR", str(e), 500) + + @bp.route("/retag/groups/", methods=["DELETE"]) + @require_api_key + def delete_retag_group(group_id): + """Delete a retag group and its tracks.""" + try: + db = get_database() + ok = db.delete_retag_group(group_id) + if ok: + return api_success({"message": f"Retag group {group_id} deleted."}) + return api_error("NOT_FOUND", f"Retag group {group_id} not found.", 404) + except Exception as e: + return api_error("RETAG_ERROR", str(e), 500) + + @bp.route("/retag/groups", methods=["DELETE"]) + @require_api_key + def clear_retag_groups(): + """Delete all retag groups and tracks.""" + try: + db = get_database() + count = db.clear_all_retag_groups() + return api_success({"message": f"Cleared {count} retag groups."}) + except Exception as e: + return api_error("RETAG_ERROR", str(e), 500) + + @bp.route("/retag/stats", methods=["GET"]) + @require_api_key + def retag_stats(): + """Get retag queue statistics.""" + try: + db = get_database() + stats = db.get_retag_stats() + return api_success(stats) + except Exception as e: + return api_error("RETAG_ERROR", str(e), 500)