enrich SoulSync API and update the DOCS

This commit is contained in:
Broque Thomas 2026-03-04 13:42:47 -08:00
parent 86a502f556
commit 50e6b45f1b
7 changed files with 1423 additions and 0 deletions

View file

@ -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/<snapshot_type>`
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/<profile_id>`
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/<profile_id>`
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/<profile_id>`
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/<group_id>`
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/<group_id>`
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/<playlist_id>`
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/<type>` | Specific bubble snapshot |
| **Profiles** | | |
| GET | `/profiles` | List all profiles |
| GET | `/profiles/<id>` | Profile detail |
| POST | `/profiles` | Create profile |
| PUT | `/profiles/<id>` | Update profile |
| DELETE | `/profiles/<id>` | Delete profile |
| **Retag Queue** | | |
| GET | `/retag/groups` | List retag groups |
| GET | `/retag/groups/<id>` | Retag group detail + tracks |
| DELETE | `/retag/groups/<id>` | 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/<id>` | 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/<id>` | Playlist detail + tracks |

View file

@ -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)

206
api/cache.py Normal file
View file

@ -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)

View file

@ -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/<snapshot_type>", 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)

124
api/listenbrainz.py Normal file
View file

@ -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/<playlist_id>", 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)

130
api/profiles.py Normal file
View file

@ -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/<int:profile_id>", 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/<int:profile_id>", 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/<int:profile_id>", 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)

77
api/retag.py Normal file
View file

@ -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/<int:group_id>", 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/<int:group_id>", 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)