a1f6701bac
- gRPC service with MusicBrainz provider - PostgreSQL schema with migrations - Service layer with database-first caching - Repository pattern for data access - YAML configuration support - Research documentation for 17 music metadata projects
1263 lines
29 KiB
Markdown
1263 lines
29 KiB
Markdown
# Melodee: API Analysis
|
|
|
|
## API Strategy Overview
|
|
|
|
Melodee implements three distinct API protocols to maximize client compatibility while maintaining a modern native API. This multi-protocol approach allows users to choose from existing client ecosystems (Subsonic, Jellyfin) or build custom integrations using the native REST API.
|
|
|
|
The three protocols are:
|
|
|
|
1. **Native REST API** (`/api/v1/`): Modern RESTful design with JWT authentication
|
|
2. **OpenSubsonic API** (`/rest/`): Subsonic-compatible endpoints with token/salt authentication
|
|
3. **Jellyfin API** (`/api/jf/`): Jellyfin-compatible endpoints with custom token authentication
|
|
|
|
Each protocol has distinct authentication mechanisms, rate limits, and client expectations. This analysis examines the design, implementation, and tradeoffs of each protocol.
|
|
|
|
## Native REST API
|
|
|
|
### Design Philosophy
|
|
|
|
The native API follows RESTful principles with resource-oriented URLs, standard HTTP methods, and JSON payloads. The design prioritizes:
|
|
|
|
- **Discoverability**: Scalar-generated documentation with interactive testing
|
|
- **Consistency**: Uniform response formats and error handling
|
|
- **Extensibility**: Versioned endpoints (`/api/v1/`) for backward compatibility
|
|
- **Security**: JWT-based stateless authentication
|
|
|
|
### Authentication
|
|
|
|
#### JWT Token Flow
|
|
|
|
```
|
|
1. Client POSTs credentials to /api/v1/auth/login
|
|
Request: { "email": "user@example.com", "password": "secret" }
|
|
|
|
2. Server validates credentials and generates JWT
|
|
Response: { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expiresAt": "2025-04-29T10:30:00Z" }
|
|
|
|
3. Client includes token in Authorization header
|
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
|
|
4. Server validates token signature and expiration on each request
|
|
```
|
|
|
|
#### JWT Claims
|
|
|
|
```json
|
|
{
|
|
"sub": "42",
|
|
"email": "user@example.com",
|
|
"role": "user",
|
|
"iat": 1714301400,
|
|
"exp": 1714387800
|
|
}
|
|
```
|
|
|
|
**Claims Breakdown**:
|
|
- `sub` (subject): User ID for database lookups
|
|
- `email`: User email for display and logging
|
|
- `role`: Authorization level (`admin` or `user`)
|
|
- `iat` (issued at): Token creation timestamp
|
|
- `exp` (expiration): Token expiry timestamp (24 hours from issue)
|
|
|
|
#### Token Refresh
|
|
|
|
The API does not implement refresh tokens. Clients must re-authenticate after token expiration. This simplifies implementation but creates friction for long-running sessions.
|
|
|
|
**Alternative Design**:
|
|
```
|
|
POST /api/v1/auth/refresh
|
|
Authorization: Bearer <expired-token>
|
|
|
|
Response: { "token": "new-token", "expiresAt": "..." }
|
|
```
|
|
|
|
Refresh tokens would require server-side storage (database or Redis) to track revocation, adding complexity.
|
|
|
|
#### Google OAuth Integration
|
|
|
|
```
|
|
1. Client redirects user to /api/v1/auth/google
|
|
2. Server redirects to Google OAuth consent screen
|
|
3. User grants permissions
|
|
4. Google redirects to /api/v1/auth/google/callback?code=...
|
|
5. Server exchanges code for access token
|
|
6. Server retrieves user profile from Google
|
|
7. Server creates or updates local user account
|
|
8. Server generates JWT and redirects to client with token
|
|
```
|
|
|
|
OAuth flow eliminates password management for users and delegates security to Google. The server stores minimal Google data (email, profile picture URL) and generates a local JWT for subsequent requests.
|
|
|
|
### Endpoints
|
|
|
|
#### Library Endpoints
|
|
|
|
**List Albums**
|
|
```
|
|
GET /api/v1/albums?page=1&pageSize=20&sort=title&order=asc&genre=Rock
|
|
|
|
Response:
|
|
{
|
|
"items": [
|
|
{
|
|
"id": 42,
|
|
"title": "OK Computer",
|
|
"artistId": 10,
|
|
"artistName": "Radiohead",
|
|
"releaseDate": "1997-05-21",
|
|
"genre": "Alternative Rock",
|
|
"trackCount": 12,
|
|
"duration": 3213,
|
|
"coverArtUrl": "/api/v1/albums/42/cover",
|
|
"externalIds": {
|
|
"musicbrainz": "6a09041b-0f79-3278-88d0-0c07e1d078e0",
|
|
"spotify": "6dVIqQ8qmQ5GBnJ9shOYGE"
|
|
}
|
|
}
|
|
],
|
|
"totalCount": 150,
|
|
"page": 1,
|
|
"pageSize": 20
|
|
}
|
|
```
|
|
|
|
**Get Album Details**
|
|
```
|
|
GET /api/v1/albums/42
|
|
|
|
Response:
|
|
{
|
|
"id": 42,
|
|
"title": "OK Computer",
|
|
"artist": {
|
|
"id": 10,
|
|
"name": "Radiohead",
|
|
"bio": "English rock band formed in 1985...",
|
|
"imageUrl": "/api/v1/artists/10/image"
|
|
},
|
|
"releaseDate": "1997-05-21",
|
|
"genre": "Alternative Rock",
|
|
"tracks": [
|
|
{
|
|
"id": 420,
|
|
"position": 1,
|
|
"title": "Airbag",
|
|
"duration": 284,
|
|
"fileFormat": "FLAC",
|
|
"bitrate": 1411,
|
|
"sampleRate": 44100
|
|
}
|
|
],
|
|
"coverArtUrl": "/api/v1/albums/42/cover",
|
|
"externalIds": { ... }
|
|
}
|
|
```
|
|
|
|
**Search**
|
|
```
|
|
GET /api/v1/search?q=radiohead&type=album,artist,track
|
|
|
|
Response:
|
|
{
|
|
"albums": [ ... ],
|
|
"artists": [ ... ],
|
|
"tracks": [ ... ]
|
|
}
|
|
```
|
|
|
|
Search supports multiple entity types in a single request. The `type` parameter filters results. Omitting `type` returns all entity types.
|
|
|
|
#### Streaming Endpoints
|
|
|
|
**Stream Track**
|
|
```
|
|
GET /api/v1/stream/420?bitrate=320&format=mp3
|
|
|
|
Response: Audio stream (Content-Type: audio/mpeg)
|
|
Headers:
|
|
Content-Length: 8421376
|
|
Accept-Ranges: bytes
|
|
Content-Range: bytes 0-8421375/8421376
|
|
```
|
|
|
|
**Transcoding Parameters**:
|
|
- `bitrate`: Target bitrate in kbps (128, 192, 256, 320)
|
|
- `format`: Target format (mp3, ogg, opus, aac)
|
|
|
|
If the source file matches the requested bitrate and format, the server streams the original file. Otherwise, FFmpeg transcodes on-the-fly.
|
|
|
|
**Range Requests**:
|
|
```
|
|
GET /api/v1/stream/420
|
|
Range: bytes=1000000-2000000
|
|
|
|
Response: Partial content (206)
|
|
Content-Range: bytes 1000000-2000000/8421376
|
|
```
|
|
|
|
Range requests enable seeking in audio players without downloading the entire file.
|
|
|
|
#### Playlist Endpoints
|
|
|
|
**Create Playlist**
|
|
```
|
|
POST /api/v1/playlists
|
|
{
|
|
"name": "Favorites",
|
|
"isPublic": false,
|
|
"isSmart": false,
|
|
"trackIds": [420, 421, 422]
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"id": 5,
|
|
"name": "Favorites",
|
|
"userId": 42,
|
|
"isPublic": false,
|
|
"isSmart": false,
|
|
"trackCount": 3,
|
|
"duration": 856,
|
|
"createdAt": "2025-04-28T10:30:00Z"
|
|
}
|
|
```
|
|
|
|
**Create Smart Playlist**
|
|
```
|
|
POST /api/v1/playlists
|
|
{
|
|
"name": "Recently Added Jazz",
|
|
"isPublic": true,
|
|
"isSmart": true,
|
|
"smartQuery": "genre:Jazz AND added:>7d"
|
|
}
|
|
```
|
|
|
|
Smart playlists use MQL (Melodee Query Language) to define dynamic track lists. The server evaluates the query on each request, returning current matches.
|
|
|
|
**Update Playlist**
|
|
```
|
|
PUT /api/v1/playlists/5
|
|
{
|
|
"name": "Top Favorites",
|
|
"trackIds": [420, 421, 422, 423]
|
|
}
|
|
```
|
|
|
|
**Add Tracks to Playlist**
|
|
```
|
|
POST /api/v1/playlists/5/tracks
|
|
{
|
|
"trackIds": [424, 425]
|
|
}
|
|
```
|
|
|
|
**Remove Tracks from Playlist**
|
|
```
|
|
DELETE /api/v1/playlists/5/tracks
|
|
{
|
|
"trackIds": [421]
|
|
}
|
|
```
|
|
|
|
**Reorder Tracks**
|
|
```
|
|
PUT /api/v1/playlists/5/tracks/reorder
|
|
{
|
|
"trackId": 420,
|
|
"newPosition": 3
|
|
}
|
|
```
|
|
|
|
#### Scrobbling Endpoints
|
|
|
|
**Submit Scrobble**
|
|
```
|
|
POST /api/v1/scrobble
|
|
{
|
|
"trackId": 420,
|
|
"playedAt": "2025-04-28T10:30:00Z"
|
|
}
|
|
|
|
Response: 204 No Content
|
|
```
|
|
|
|
Scrobbles are submitted asynchronously. The server queues the scrobble for batch submission to Last.fm and immediate insertion into the internal scrobble database.
|
|
|
|
**Get Scrobble History**
|
|
```
|
|
GET /api/v1/scrobbles?page=1&pageSize=50
|
|
|
|
Response:
|
|
{
|
|
"items": [
|
|
{
|
|
"id": 1000,
|
|
"trackId": 420,
|
|
"trackTitle": "Airbag",
|
|
"artistName": "Radiohead",
|
|
"albumTitle": "OK Computer",
|
|
"playedAt": "2025-04-28T10:30:00Z"
|
|
}
|
|
],
|
|
"totalCount": 5000,
|
|
"page": 1,
|
|
"pageSize": 50
|
|
}
|
|
```
|
|
|
|
#### Statistics Endpoints
|
|
|
|
**Get Listening Statistics**
|
|
```
|
|
GET /api/v1/stats/listening?period=30d
|
|
|
|
Response:
|
|
{
|
|
"totalPlays": 450,
|
|
"totalDuration": 82800,
|
|
"uniqueTracks": 120,
|
|
"uniqueAlbums": 35,
|
|
"uniqueArtists": 28,
|
|
"topTracks": [
|
|
{
|
|
"trackId": 420,
|
|
"title": "Airbag",
|
|
"artistName": "Radiohead",
|
|
"playCount": 15
|
|
}
|
|
],
|
|
"topAlbums": [ ... ],
|
|
"topArtists": [ ... ],
|
|
"topGenres": [ ... ]
|
|
}
|
|
```
|
|
|
|
**Get Charts**
|
|
```
|
|
GET /api/v1/charts?type=weekly&category=tracks
|
|
|
|
Response:
|
|
{
|
|
"period": "2025-04-21 to 2025-04-28",
|
|
"items": [
|
|
{
|
|
"rank": 1,
|
|
"trackId": 420,
|
|
"title": "Airbag",
|
|
"artistName": "Radiohead",
|
|
"playCount": 15,
|
|
"change": 2
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
The `change` field indicates rank movement from the previous period (positive = up, negative = down, 0 = no change).
|
|
|
|
#### User Request Endpoints
|
|
|
|
**Submit Album Request**
|
|
```
|
|
POST /api/v1/requests
|
|
{
|
|
"type": "album",
|
|
"artist": "Radiohead",
|
|
"title": "In Rainbows",
|
|
"notes": "Missing from library"
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"id": 10,
|
|
"type": "album",
|
|
"artist": "Radiohead",
|
|
"title": "In Rainbows",
|
|
"notes": "Missing from library",
|
|
"status": "pending",
|
|
"userId": 42,
|
|
"createdAt": "2025-04-28T10:30:00Z"
|
|
}
|
|
```
|
|
|
|
**List Requests**
|
|
```
|
|
GET /api/v1/requests?status=pending
|
|
|
|
Response:
|
|
{
|
|
"items": [
|
|
{
|
|
"id": 10,
|
|
"type": "album",
|
|
"artist": "Radiohead",
|
|
"title": "In Rainbows",
|
|
"status": "pending",
|
|
"userId": 42,
|
|
"userName": "user@example.com",
|
|
"createdAt": "2025-04-28T10:30:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Update Request Status** (Admin only)
|
|
```
|
|
PUT /api/v1/requests/10
|
|
{
|
|
"status": "fulfilled",
|
|
"adminNotes": "Added to library"
|
|
}
|
|
```
|
|
|
|
#### Admin Endpoints
|
|
|
|
**List Users** (Admin only)
|
|
```
|
|
GET /api/v1/admin/users
|
|
|
|
Response:
|
|
{
|
|
"items": [
|
|
{
|
|
"id": 42,
|
|
"email": "user@example.com",
|
|
"role": "user",
|
|
"createdAt": "2025-01-01T00:00:00Z",
|
|
"lastLoginAt": "2025-04-28T10:00:00Z",
|
|
"scrobbleCount": 5000,
|
|
"playlistCount": 12
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Create User** (Admin only)
|
|
```
|
|
POST /api/v1/admin/users
|
|
{
|
|
"email": "newuser@example.com",
|
|
"password": "temporary",
|
|
"role": "user"
|
|
}
|
|
```
|
|
|
|
**Update User Role** (Admin only)
|
|
```
|
|
PUT /api/v1/admin/users/42
|
|
{
|
|
"role": "admin"
|
|
}
|
|
```
|
|
|
|
**Trigger Library Scan** (Admin only)
|
|
```
|
|
POST /api/v1/admin/library/scan
|
|
|
|
Response: 202 Accepted
|
|
{
|
|
"jobId": "scan-2025-04-28-103000",
|
|
"status": "queued"
|
|
}
|
|
```
|
|
|
|
**Get Job Status** (Admin only)
|
|
```
|
|
GET /api/v1/admin/jobs/scan-2025-04-28-103000
|
|
|
|
Response:
|
|
{
|
|
"jobId": "scan-2025-04-28-103000",
|
|
"status": "running",
|
|
"progress": 45,
|
|
"startedAt": "2025-04-28T10:30:00Z",
|
|
"message": "Processing album 450 of 1000"
|
|
}
|
|
```
|
|
|
|
### Rate Limiting
|
|
|
|
**Native API Rate Limits**:
|
|
- **General endpoints**: 30 requests per 30 seconds per user
|
|
- **Authentication endpoints**: 10 requests per 60 seconds per IP address
|
|
- **Streaming endpoints**: No rate limit (bandwidth-limited by network)
|
|
|
|
**Rate Limit Headers**:
|
|
```
|
|
X-RateLimit-Limit: 30
|
|
X-RateLimit-Remaining: 25
|
|
X-RateLimit-Reset: 1714301430
|
|
```
|
|
|
|
**Rate Limit Exceeded Response**:
|
|
```
|
|
HTTP/1.1 429 Too Many Requests
|
|
Retry-After: 15
|
|
|
|
{
|
|
"error": "Rate limit exceeded",
|
|
"message": "You have exceeded the rate limit of 30 requests per 30 seconds",
|
|
"retryAfter": 15
|
|
}
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
**Standard Error Response**:
|
|
```json
|
|
{
|
|
"error": "NotFound",
|
|
"message": "Album with ID 999 not found",
|
|
"details": {
|
|
"albumId": 999
|
|
},
|
|
"timestamp": "2025-04-28T10:30:00Z",
|
|
"path": "/api/v1/albums/999"
|
|
}
|
|
```
|
|
|
|
**HTTP Status Codes**:
|
|
- `200 OK`: Successful GET, PUT, PATCH
|
|
- `201 Created`: Successful POST
|
|
- `204 No Content`: Successful DELETE
|
|
- `400 Bad Request`: Invalid request payload
|
|
- `401 Unauthorized`: Missing or invalid authentication
|
|
- `403 Forbidden`: Insufficient permissions
|
|
- `404 Not Found`: Resource not found
|
|
- `409 Conflict`: Resource conflict (duplicate playlist name)
|
|
- `422 Unprocessable Entity`: Validation errors
|
|
- `429 Too Many Requests`: Rate limit exceeded
|
|
- `500 Internal Server Error`: Server error
|
|
|
|
**Validation Error Response**:
|
|
```json
|
|
{
|
|
"error": "ValidationError",
|
|
"message": "Request validation failed",
|
|
"errors": [
|
|
{
|
|
"field": "email",
|
|
"message": "Email is required"
|
|
},
|
|
{
|
|
"field": "password",
|
|
"message": "Password must be at least 8 characters"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Pagination
|
|
|
|
**Query Parameters**:
|
|
- `page`: Page number (1-indexed)
|
|
- `pageSize`: Items per page (default: 20, max: 100)
|
|
|
|
**Response Format**:
|
|
```json
|
|
{
|
|
"items": [ ... ],
|
|
"totalCount": 150,
|
|
"page": 1,
|
|
"pageSize": 20,
|
|
"totalPages": 8
|
|
}
|
|
```
|
|
|
|
**Link Headers** (optional):
|
|
```
|
|
Link: </api/v1/albums?page=2&pageSize=20>; rel="next",
|
|
</api/v1/albums?page=8&pageSize=20>; rel="last"
|
|
```
|
|
|
|
### Versioning
|
|
|
|
The `/api/v1/` prefix enables backward-compatible API evolution. Future versions (`/api/v2/`) can introduce breaking changes while maintaining v1 support.
|
|
|
|
**Version Negotiation** (future):
|
|
```
|
|
GET /api/albums
|
|
Accept: application/vnd.melodee.v2+json
|
|
|
|
Response: v2 format
|
|
```
|
|
|
|
### API Documentation
|
|
|
|
Scalar generates interactive API documentation from OpenAPI specifications. The documentation is available at `/api/docs` and includes:
|
|
|
|
- **Endpoint reference**: All endpoints with request/response schemas
|
|
- **Authentication guide**: JWT and OAuth flows
|
|
- **Interactive testing**: Try API calls directly in the browser
|
|
- **Code examples**: cURL, JavaScript, Python, C# examples
|
|
|
|
**OpenAPI Specification** (excerpt):
|
|
```yaml
|
|
openapi: 3.0.0
|
|
info:
|
|
title: Melodee API
|
|
version: 1.0.0
|
|
description: Music server and metadata aggregator API
|
|
|
|
paths:
|
|
/api/v1/albums:
|
|
get:
|
|
summary: List albums
|
|
parameters:
|
|
- name: page
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
default: 1
|
|
- name: pageSize
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
default: 20
|
|
responses:
|
|
'200':
|
|
description: Successful response
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/AlbumListResponse'
|
|
```
|
|
|
|
## OpenSubsonic API
|
|
|
|
### Protocol Overview
|
|
|
|
OpenSubsonic is an open-source continuation of the Subsonic API, maintained by the community after Subsonic's commercialization. The protocol uses query parameters for all requests and XML or JSON responses.
|
|
|
|
Melodee implements OpenSubsonic to support existing Subsonic clients:
|
|
- **DSub** (Android)
|
|
- **Ultrasonic** (Android)
|
|
- **Sublime Music** (Linux desktop)
|
|
- **Jamstash** (Web)
|
|
- **Sonixd** (Desktop)
|
|
|
|
### Authentication
|
|
|
|
**Token and Salt Method**:
|
|
```
|
|
1. Client generates random salt (e.g., "abc123")
|
|
2. Client computes token = MD5(password + salt)
|
|
3. Client sends username, token, and salt in query parameters
|
|
|
|
GET /rest/ping.view?u=user&t=5f4dcc3b5aa765d61d8327deb882cf99&s=abc123&v=1.16.1&c=DSub
|
|
```
|
|
|
|
**Server Validation**:
|
|
```csharp
|
|
public bool ValidateToken(string username, string token, string salt)
|
|
{
|
|
var user = _userRepository.GetByUsername(username);
|
|
if (user == null)
|
|
return false;
|
|
|
|
var expectedToken = MD5Hash(user.Password + salt);
|
|
return token == expectedToken;
|
|
}
|
|
```
|
|
|
|
**Security Considerations**:
|
|
- Token is vulnerable to replay attacks without HTTPS
|
|
- MD5 is cryptographically weak but sufficient for this use case (password is already hashed)
|
|
- Salt prevents precomputed rainbow table attacks
|
|
|
|
**Legacy Password Method** (deprecated):
|
|
```
|
|
GET /rest/ping.view?u=user&p=password&v=1.16.1&c=DSub
|
|
```
|
|
|
|
Melodee supports this for backward compatibility but logs a warning encouraging token/salt authentication.
|
|
|
|
### Endpoints
|
|
|
|
#### System Endpoints
|
|
|
|
**Ping**
|
|
```
|
|
GET /rest/ping.view?u=user&t=token&s=salt&v=1.16.1&c=DSub
|
|
|
|
Response (XML):
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
</subsonic-response>
|
|
|
|
Response (JSON):
|
|
{
|
|
"subsonic-response": {
|
|
"status": "ok",
|
|
"version": "1.16.1"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Get License**
|
|
```
|
|
GET /rest/getLicense.view
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<license valid="true" email="opensource@melodee.dev" licenseExpires="2099-12-31T23:59:59Z" />
|
|
</subsonic-response>
|
|
```
|
|
|
|
OpenSubsonic clients expect a license response even though the protocol is open-source. Melodee returns a perpetual license.
|
|
|
|
#### Browsing Endpoints
|
|
|
|
**Get Music Folders**
|
|
```
|
|
GET /rest/getMusicFolders.view
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<musicFolders>
|
|
<musicFolder id="1" name="Music" />
|
|
</musicFolders>
|
|
</subsonic-response>
|
|
```
|
|
|
|
Melodee presents a single music folder representing the entire library. Multi-folder support could be added for users with separate libraries (e.g., "Music", "Podcasts").
|
|
|
|
**Get Indexes**
|
|
```
|
|
GET /rest/getIndexes.view?musicFolderId=1
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<indexes lastModified="1714301400000">
|
|
<index name="A">
|
|
<artist id="10" name="Arcade Fire" albumCount="5" />
|
|
<artist id="11" name="Arctic Monkeys" albumCount="6" />
|
|
</index>
|
|
<index name="R">
|
|
<artist id="20" name="Radiohead" albumCount="9" />
|
|
</index>
|
|
</indexes>
|
|
</subsonic-response>
|
|
```
|
|
|
|
Artists are grouped alphabetically for efficient browsing in clients with index navigation.
|
|
|
|
**Get Artist**
|
|
```
|
|
GET /rest/getArtist.view?id=20
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<artist id="20" name="Radiohead" albumCount="9" coverArt="ar-20">
|
|
<album id="42" name="OK Computer" artist="Radiohead" artistId="20" coverArt="al-42" songCount="12" duration="3213" created="2025-01-01T00:00:00Z" year="1997" genre="Alternative Rock" />
|
|
</artist>
|
|
</subsonic-response>
|
|
```
|
|
|
|
**Get Album**
|
|
```
|
|
GET /rest/getAlbum.view?id=42
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<album id="42" name="OK Computer" artist="Radiohead" artistId="20" coverArt="al-42" songCount="12" duration="3213" created="2025-01-01T00:00:00Z" year="1997" genre="Alternative Rock">
|
|
<song id="420" parent="42" title="Airbag" album="OK Computer" artist="Radiohead" track="1" year="1997" genre="Alternative Rock" coverArt="al-42" size="8421376" contentType="audio/flac" suffix="flac" duration="284" bitRate="1411" path="Radiohead/OK Computer/01 Airbag.flac" />
|
|
</album>
|
|
</subsonic-response>
|
|
```
|
|
|
|
#### Album List Endpoints
|
|
|
|
**Get Album List 2**
|
|
```
|
|
GET /rest/getAlbumList2.view?type=recent&size=20&offset=0
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<albumList2>
|
|
<album id="42" name="OK Computer" artist="Radiohead" artistId="20" coverArt="al-42" songCount="12" duration="3213" created="2025-04-28T10:00:00Z" year="1997" genre="Alternative Rock" />
|
|
</albumList2>
|
|
</subsonic-response>
|
|
```
|
|
|
|
**Album List Types**:
|
|
- `random`: Random albums
|
|
- `newest`: Recently added albums
|
|
- `highest`: Highest rated albums
|
|
- `frequent`: Most frequently played albums
|
|
- `recent`: Recently played albums
|
|
- `alphabeticalByName`: Alphabetically by album name
|
|
- `alphabeticalByArtist`: Alphabetically by artist name
|
|
- `starred`: User-starred albums
|
|
- `byYear`: Albums by release year (requires `fromYear` and `toYear` parameters)
|
|
- `byGenre`: Albums by genre (requires `genre` parameter)
|
|
|
|
#### Streaming Endpoints
|
|
|
|
**Stream**
|
|
```
|
|
GET /rest/stream.view?id=420&maxBitRate=320&format=mp3
|
|
|
|
Response: Audio stream
|
|
```
|
|
|
|
**Download**
|
|
```
|
|
GET /rest/download.view?id=420
|
|
|
|
Response: Original audio file
|
|
```
|
|
|
|
The `download` endpoint returns the original file without transcoding, while `stream` applies transcoding based on `maxBitRate` and `format` parameters.
|
|
|
|
**Get Cover Art**
|
|
```
|
|
GET /rest/getCoverArt.view?id=al-42&size=300
|
|
|
|
Response: JPEG image
|
|
```
|
|
|
|
**Size Parameter**:
|
|
- Omitted: Original size
|
|
- Numeric: Resize to NxN pixels (e.g., `size=300` returns 300x300)
|
|
|
|
#### Playlist Endpoints
|
|
|
|
**Get Playlists**
|
|
```
|
|
GET /rest/getPlaylists.view
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<playlists>
|
|
<playlist id="5" name="Favorites" songCount="50" duration="12000" public="false" owner="user@example.com" created="2025-01-01T00:00:00Z" changed="2025-04-28T10:00:00Z" />
|
|
</playlists>
|
|
</subsonic-response>
|
|
```
|
|
|
|
**Get Playlist**
|
|
```
|
|
GET /rest/getPlaylist.view?id=5
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<playlist id="5" name="Favorites" songCount="50" duration="12000" public="false" owner="user@example.com" created="2025-01-01T00:00:00Z" changed="2025-04-28T10:00:00Z">
|
|
<entry id="420" parent="42" title="Airbag" ... />
|
|
</playlist>
|
|
</subsonic-response>
|
|
```
|
|
|
|
**Create Playlist**
|
|
```
|
|
GET /rest/createPlaylist.view?name=New+Playlist&songId=420&songId=421
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<playlist id="6" name="New Playlist" songCount="2" ... />
|
|
</subsonic-response>
|
|
```
|
|
|
|
**Update Playlist**
|
|
```
|
|
GET /rest/updatePlaylist.view?playlistId=5&songIdToAdd=422&songIndexToRemove=0
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1" />
|
|
```
|
|
|
|
**Delete Playlist**
|
|
```
|
|
GET /rest/deletePlaylist.view?id=5
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1" />
|
|
```
|
|
|
|
#### Search Endpoints
|
|
|
|
**Search 3**
|
|
```
|
|
GET /rest/search3.view?query=radiohead&artistCount=10&albumCount=20&songCount=50
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<searchResult3>
|
|
<artist id="20" name="Radiohead" albumCount="9" />
|
|
<album id="42" name="OK Computer" artist="Radiohead" ... />
|
|
<song id="420" title="Airbag" artist="Radiohead" ... />
|
|
</searchResult3>
|
|
</subsonic-response>
|
|
```
|
|
|
|
#### Scrobbling Endpoints
|
|
|
|
**Scrobble**
|
|
```
|
|
GET /rest/scrobble.view?id=420&time=1714301400000&submission=true
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1" />
|
|
```
|
|
|
|
**Parameters**:
|
|
- `id`: Track ID
|
|
- `time`: Unix timestamp in milliseconds (optional, defaults to current time)
|
|
- `submission`: `true` for scrobble, `false` for "now playing" update
|
|
|
|
#### User Endpoints
|
|
|
|
**Get User**
|
|
```
|
|
GET /rest/getUser.view?username=user
|
|
|
|
Response:
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<user username="user" email="user@example.com" scrobblingEnabled="true" adminRole="false" settingsRole="true" downloadRole="true" uploadRole="false" playlistRole="true" coverArtRole="true" commentRole="true" podcastRole="true" streamRole="true" jukeboxRole="false" shareRole="true" />
|
|
</subsonic-response>
|
|
```
|
|
|
|
OpenSubsonic defines granular roles for different capabilities. Melodee simplifies this to two roles (`admin` and `user`) and maps them to OpenSubsonic roles.
|
|
|
|
### Response Formats
|
|
|
|
**XML (default)**:
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<subsonic-response status="ok" version="1.16.1">
|
|
<album id="42" name="OK Computer" ... />
|
|
</subsonic-response>
|
|
```
|
|
|
|
**JSON** (with `f=json` parameter):
|
|
```json
|
|
{
|
|
"subsonic-response": {
|
|
"status": "ok",
|
|
"version": "1.16.1",
|
|
"album": {
|
|
"id": "42",
|
|
"name": "OK Computer"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error Response**:
|
|
```xml
|
|
<subsonic-response status="failed" version="1.16.1">
|
|
<error code="40" message="Wrong username or password" />
|
|
</subsonic-response>
|
|
```
|
|
|
|
**Error Codes**:
|
|
- `0`: Generic error
|
|
- `10`: Required parameter missing
|
|
- `20`: Incompatible Subsonic REST protocol version
|
|
- `30`: Incompatible Subsonic REST protocol version (server too old)
|
|
- `40`: Wrong username or password
|
|
- `41`: Token authentication not supported for LDAP users
|
|
- `50`: User not authorized for operation
|
|
- `60`: Trial period over
|
|
- `70`: Requested data not found
|
|
|
|
### Rate Limiting
|
|
|
|
OpenSubsonic endpoints inherit the native API rate limit (30 requests per 30 seconds). This may be too restrictive for some clients that make frequent requests for album art or metadata.
|
|
|
|
**Potential Optimization**:
|
|
- Increase rate limit for read-only endpoints (`getAlbum`, `getCoverArt`)
|
|
- Implement separate rate limits for streaming vs. metadata endpoints
|
|
- Use token bucket algorithm for burst tolerance
|
|
|
|
## Jellyfin API
|
|
|
|
### Protocol Overview
|
|
|
|
Jellyfin is a free, open-source media server (fork of Emby). Melodee implements a subset of the Jellyfin API to support Jellyfin clients:
|
|
- **Jellyfin Web** (Browser)
|
|
- **Jellyfin Mobile** (iOS, Android)
|
|
- **Jellyfin Desktop** (Windows, macOS, Linux)
|
|
- **Jellyfin for Kodi** (Kodi plugin)
|
|
|
|
### Authentication
|
|
|
|
**Authentication Flow**:
|
|
```
|
|
1. Client POSTs credentials to /api/jf/Users/AuthenticateByName
|
|
Request:
|
|
{
|
|
"Username": "user@example.com",
|
|
"Pw": "password"
|
|
}
|
|
|
|
2. Server validates credentials and generates token
|
|
Response:
|
|
{
|
|
"User": {
|
|
"Id": "42",
|
|
"Name": "user@example.com",
|
|
"ServerId": "melodee-1"
|
|
},
|
|
"AccessToken": "a1b2c3d4e5f6",
|
|
"SessionInfo": {
|
|
"Id": "session-123"
|
|
}
|
|
}
|
|
|
|
3. Client includes token in X-Emby-Token header
|
|
X-Emby-Token: a1b2c3d4e5f6
|
|
```
|
|
|
|
**Token Storage**:
|
|
Tokens are stored in the database with expiration (30 days). Unlike JWT, Jellyfin tokens require server-side storage for validation and revocation.
|
|
|
|
### Endpoints
|
|
|
|
#### System Endpoints
|
|
|
|
**Get System Info**
|
|
```
|
|
GET /api/jf/System/Info
|
|
|
|
Response:
|
|
{
|
|
"ServerName": "Melodee",
|
|
"Version": "1.8.0",
|
|
"Id": "melodee-1",
|
|
"OperatingSystem": "Linux",
|
|
"HasPendingRestart": false,
|
|
"IsShuttingDown": false
|
|
}
|
|
```
|
|
|
|
**Get Public System Info**
|
|
```
|
|
GET /api/jf/System/Info/Public
|
|
|
|
Response:
|
|
{
|
|
"ServerName": "Melodee",
|
|
"Version": "1.8.0",
|
|
"Id": "melodee-1"
|
|
}
|
|
```
|
|
|
|
#### Library Endpoints
|
|
|
|
**Get Items**
|
|
```
|
|
GET /api/jf/Items?ParentId=0&IncludeItemTypes=MusicAlbum&Limit=20&StartIndex=0
|
|
|
|
Response:
|
|
{
|
|
"Items": [
|
|
{
|
|
"Id": "42",
|
|
"Name": "OK Computer",
|
|
"Type": "MusicAlbum",
|
|
"AlbumArtist": "Radiohead",
|
|
"ProductionYear": 1997,
|
|
"Genres": ["Alternative Rock"],
|
|
"RunTimeTicks": 32130000000,
|
|
"ImageTags": {
|
|
"Primary": "al-42"
|
|
}
|
|
}
|
|
],
|
|
"TotalRecordCount": 150,
|
|
"StartIndex": 0
|
|
}
|
|
```
|
|
|
|
**Get Item**
|
|
```
|
|
GET /api/jf/Items/42
|
|
|
|
Response:
|
|
{
|
|
"Id": "42",
|
|
"Name": "OK Computer",
|
|
"Type": "MusicAlbum",
|
|
"AlbumArtist": "Radiohead",
|
|
"ProductionYear": 1997,
|
|
"Genres": ["Alternative Rock"],
|
|
"RunTimeTicks": 32130000000,
|
|
"ChildCount": 12,
|
|
"ImageTags": {
|
|
"Primary": "al-42"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Ticks Conversion**:
|
|
Jellyfin uses "ticks" for duration (1 tick = 100 nanoseconds).
|
|
```
|
|
Duration in seconds = Ticks / 10,000,000
|
|
3213 seconds = 32,130,000,000 ticks
|
|
```
|
|
|
|
#### Streaming Endpoints
|
|
|
|
**Get Audio Stream**
|
|
```
|
|
GET /api/jf/Audio/420/stream?Container=mp3&AudioBitrate=320000
|
|
|
|
Response: Audio stream
|
|
```
|
|
|
|
**Get Image**
|
|
```
|
|
GET /api/jf/Items/42/Images/Primary?maxWidth=300&maxHeight=300
|
|
|
|
Response: JPEG image
|
|
```
|
|
|
|
#### Playback Reporting
|
|
|
|
**Report Playback Start**
|
|
```
|
|
POST /api/jf/Sessions/Playing
|
|
{
|
|
"ItemId": "420",
|
|
"PositionTicks": 0,
|
|
"IsPaused": false
|
|
}
|
|
|
|
Response: 204 No Content
|
|
```
|
|
|
|
**Report Playback Progress**
|
|
```
|
|
POST /api/jf/Sessions/Playing/Progress
|
|
{
|
|
"ItemId": "420",
|
|
"PositionTicks": 50000000,
|
|
"IsPaused": false
|
|
}
|
|
|
|
Response: 204 No Content
|
|
```
|
|
|
|
**Report Playback Stopped**
|
|
```
|
|
POST /api/jf/Sessions/Playing/Stopped
|
|
{
|
|
"ItemId": "420",
|
|
"PositionTicks": 284000000
|
|
}
|
|
|
|
Response: 204 No Content
|
|
```
|
|
|
|
Jellyfin clients report playback progress frequently (every 10 seconds). This explains the higher rate limit (200 requests per 60 seconds) for Jellyfin endpoints.
|
|
|
|
### Rate Limiting
|
|
|
|
**Jellyfin API Rate Limit**: 200 requests per 60 seconds per user
|
|
|
|
The higher limit accommodates frequent playback progress updates. Without this, clients would hit rate limits during normal playback.
|
|
|
|
## API Comparison
|
|
|
|
| Feature | Native REST | OpenSubsonic | Jellyfin |
|
|
|---------|-------------|--------------|----------|
|
|
| **Authentication** | JWT | Token + Salt | Custom Token |
|
|
| **Request Format** | JSON body | Query params | JSON body |
|
|
| **Response Format** | JSON | XML/JSON | JSON |
|
|
| **Rate Limit** | 30/30s | 30/30s | 200/60s |
|
|
| **Versioning** | `/api/v1/` | Version param | No versioning |
|
|
| **Documentation** | Scalar | Community docs | Jellyfin docs |
|
|
| **Client Ecosystem** | Custom | Subsonic clients | Jellyfin clients |
|
|
| **Streaming** | Range requests | Basic | Advanced (HLS, DASH) |
|
|
| **Scrobbling** | Native | Native | Via plugins |
|
|
|
|
## API Design Tradeoffs
|
|
|
|
### Multi-Protocol Complexity
|
|
|
|
**Advantages**:
|
|
- Broad client compatibility without custom client development
|
|
- Users can choose clients based on platform and preferences
|
|
- Existing client ecosystems provide mature, tested implementations
|
|
|
|
**Disadvantages**:
|
|
- Three authentication systems to maintain and secure
|
|
- Three sets of endpoints with overlapping functionality
|
|
- Increased testing surface area
|
|
- Potential for inconsistencies between protocols
|
|
|
|
### REST vs. GraphQL
|
|
|
|
Melodee uses REST for all three protocols. GraphQL could reduce over-fetching and under-fetching:
|
|
|
|
**REST**:
|
|
```
|
|
GET /api/v1/albums/42 # Fetch album
|
|
GET /api/v1/albums/42/tracks # Fetch tracks separately
|
|
```
|
|
|
|
**GraphQL**:
|
|
```graphql
|
|
query {
|
|
album(id: 42) {
|
|
title
|
|
artist { name }
|
|
tracks {
|
|
title
|
|
duration
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
GraphQL would benefit the native API but complicate OpenSubsonic and Jellyfin compatibility.
|
|
|
|
### Pagination Strategies
|
|
|
|
**Offset-based** (current):
|
|
```
|
|
GET /api/v1/albums?page=5&pageSize=20
|
|
```
|
|
|
|
**Advantages**: Simple, stateless, random access
|
|
**Disadvantages**: Performance degrades with large offsets, inconsistent results if data changes
|
|
|
|
**Cursor-based** (alternative):
|
|
```
|
|
GET /api/v1/albums?after=cursor123&limit=20
|
|
```
|
|
|
|
**Advantages**: Consistent results, better performance
|
|
**Disadvantages**: No random access, more complex implementation
|
|
|
|
For music libraries (relatively static data), offset-based pagination is sufficient.
|
|
|
|
### Caching Headers
|
|
|
|
The API should include caching headers to reduce server load:
|
|
|
|
```
|
|
Cache-Control: public, max-age=3600
|
|
ETag: "abc123"
|
|
Last-Modified: Mon, 28 Apr 2025 10:30:00 GMT
|
|
```
|
|
|
|
Clients can use `If-None-Match` and `If-Modified-Since` headers to avoid re-downloading unchanged data.
|
|
|
|
## Conclusion
|
|
|
|
Melodee's multi-protocol API strategy maximizes client compatibility while maintaining a modern native API. The three protocols serve distinct purposes:
|
|
|
|
- **Native REST API**: Custom integrations, modern clients, full feature access
|
|
- **OpenSubsonic API**: Subsonic client ecosystem, broad platform support
|
|
- **Jellyfin API**: Jellyfin client ecosystem, advanced streaming features
|
|
|
|
The architecture demonstrates thoughtful API design with consistent error handling, comprehensive documentation, and appropriate rate limiting. The main tradeoff is increased complexity from maintaining three protocols, but this is justified by the broad client compatibility it enables.
|
|
|
|
Future improvements could include:
|
|
- GraphQL endpoint for flexible querying
|
|
- WebSocket API for real-time updates
|
|
- Cursor-based pagination for large result sets
|
|
- Enhanced caching with ETags and conditional requests
|
|
- API versioning strategy for breaking changes
|