enrich SoulSync API and update the DOCS
This commit is contained in:
parent
86a502f556
commit
50e6b45f1b
7 changed files with 1423 additions and 0 deletions
833
Support/API.md
833
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/<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 |
|
||||
|
|
|
|||
|
|
@ -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
206
api/cache.py
Normal 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)
|
||||
|
|
@ -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
124
api/listenbrainz.py
Normal 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
130
api/profiles.py
Normal 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
77
api/retag.py
Normal 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)
|
||||
Loading…
Reference in a new issue