feat: initial implementation of metadata aggregator
- 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
This commit is contained in:
@@ -0,0 +1,839 @@
|
||||
# MiniMediaMetadataAPI - API Interface Analysis
|
||||
|
||||
## API Surface Overview
|
||||
|
||||
**Base URL:** `http://localhost:56232` (production deployment)
|
||||
**API Prefix:** `/api`
|
||||
**Documentation:** `/swagger` (Swagger UI)
|
||||
**Metrics:** `/metrics` (Prometheus format)
|
||||
|
||||
## Controllers
|
||||
|
||||
### 1. SearchArtistController
|
||||
|
||||
**Path:** `/api/SearchArtist`
|
||||
**File:** `Controllers/SearchArtistController.cs`
|
||||
|
||||
#### GET /api/SearchArtist
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| Id | string | No | null | Artist ID (provider-specific format) |
|
||||
| Name | string | No | null | Artist name for fuzzy search |
|
||||
| Provider | ProviderType | No | Any | Target provider or Any for all |
|
||||
| Offset | int | No | 0 | Pagination offset |
|
||||
|
||||
**Provider Parameter Values:**
|
||||
- `Any` (default) - Search all 6 providers
|
||||
- `Spotify` - Spotify only
|
||||
- `Tidal` - Tidal only
|
||||
- `MusicBrainz` - MusicBrainz only
|
||||
- `Deezer` - Deezer only
|
||||
- `Discogs` - Discogs only
|
||||
- `SoundCloud` - SoundCloud only
|
||||
|
||||
**Response Model:** `SearchArtistResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"searchResultType": "Ok",
|
||||
"artists": [
|
||||
{
|
||||
"providerType": "Spotify",
|
||||
"id": "3WrFJ7ztbogyGnTHbHJFl2",
|
||||
"name": "The Beatles",
|
||||
"popularity": 100,
|
||||
"url": "https://open.spotify.com/artist/3WrFJ7ztbogyGnTHbHJFl2",
|
||||
"totalFollowers": 50000000,
|
||||
"genres": ["rock", "british invasion", "classic rock"],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://i.scdn.co/image/...",
|
||||
"height": 640,
|
||||
"width": 640
|
||||
}
|
||||
],
|
||||
"sortName": null,
|
||||
"lastSyncTime": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**SearchResultType Enum:**
|
||||
- `Ok` (0) - Results found
|
||||
- `NotFound` (1) - No results
|
||||
- `InQueueSync` (2) - Data sync in progress (unused in current implementation)
|
||||
|
||||
**SearchArtistEntity Fields:**
|
||||
|
||||
| Field | Type | Nullable | Providers | Description |
|
||||
|-------|------|----------|-----------|-------------|
|
||||
| providerType | ProviderType | No | All | Source provider |
|
||||
| id | string | No | All | Provider-specific artist ID |
|
||||
| name | string | No | All | Artist name |
|
||||
| popularity | int | Yes | Spotify, Deezer | Popularity score (0-100) |
|
||||
| url | string | Yes | All | Artist URL on provider platform |
|
||||
| totalFollowers | int | Yes | Spotify, Deezer | Follower count |
|
||||
| genres | string[] | Yes | Spotify, Deezer, MusicBrainz | Genre tags |
|
||||
| images | ArtistImageEntity[] | Yes | Spotify, Tidal, Deezer | Artist images |
|
||||
| sortName | string | Yes | MusicBrainz | MusicBrainz sort name |
|
||||
| lastSyncTime | DateTime | Yes | All | Last data sync timestamp |
|
||||
|
||||
**Example Requests:**
|
||||
|
||||
```bash
|
||||
# Search by name across all providers
|
||||
GET /api/SearchArtist?Name=Beatles&Provider=Any
|
||||
|
||||
# Search Spotify only
|
||||
GET /api/SearchArtist?Name=Beatles&Provider=Spotify
|
||||
|
||||
# Get artist by ID
|
||||
GET /api/SearchArtist?Id=3WrFJ7ztbogyGnTHbHJFl2&Provider=Spotify
|
||||
|
||||
# Paginated search
|
||||
GET /api/SearchArtist?Name=Beatles&Offset=20
|
||||
```
|
||||
|
||||
**Input Sanitization:**
|
||||
```csharp
|
||||
Name = StringHelper.RemoveControlChars(Name);
|
||||
```
|
||||
|
||||
Removes control characters (0x00-0x1F, 0x7F-0x9F) to prevent injection attacks.
|
||||
|
||||
### 2. SearchAlbumController
|
||||
|
||||
**Path:** `/api/SearchAlbum`
|
||||
**File:** `Controllers/SearchAlbumController.cs`
|
||||
|
||||
#### GET /api/SearchAlbum
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| AlbumId | string | No | null | Album ID (provider-specific) |
|
||||
| ArtistId | string | No | null | Filter by artist ID |
|
||||
| AlbumName | string | No | null | Album name for fuzzy search |
|
||||
| Provider | ProviderType | No | Any | Target provider |
|
||||
| Offset | int | No | 0 | Pagination offset |
|
||||
|
||||
**Response Model:** `SearchAlbumResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"searchResultType": "Ok",
|
||||
"albums": [
|
||||
{
|
||||
"providerType": "Spotify",
|
||||
"id": "1klALx0u4AavZNEvC4LrTL",
|
||||
"name": "Abbey Road",
|
||||
"popularity": 95,
|
||||
"url": "https://open.spotify.com/album/1klALx0u4AavZNEvC4LrTL",
|
||||
"label": "Apple Records",
|
||||
"releaseDate": "1969-09-26",
|
||||
"totalTracks": 17,
|
||||
"type": "album",
|
||||
"upc": "00602547437389",
|
||||
"copyright": "℗ 2009 Calderstone Productions Limited",
|
||||
"artistId": "3WrFJ7ztbogyGnTHbHJFl2",
|
||||
"images": [
|
||||
{
|
||||
"url": "https://i.scdn.co/image/...",
|
||||
"height": 640,
|
||||
"width": 640
|
||||
}
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"id": "3WrFJ7ztbogyGnTHbHJFl2",
|
||||
"name": "The Beatles"
|
||||
}
|
||||
],
|
||||
"lastSyncTime": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**SearchAlbumEntity Fields:**
|
||||
|
||||
| Field | Type | Nullable | Providers | Description |
|
||||
|-------|------|----------|-----------|-------------|
|
||||
| providerType | ProviderType | No | All | Source provider |
|
||||
| id | string | No | All | Provider-specific album ID |
|
||||
| name | string | No | All | Album name |
|
||||
| popularity | int | Yes | Spotify, Deezer | Popularity score |
|
||||
| url | string | Yes | All | Album URL |
|
||||
| label | string | Yes | Spotify, Tidal, MusicBrainz, Discogs | Record label |
|
||||
| releaseDate | string | Yes | All | Release date (ISO 8601) |
|
||||
| totalTracks | int | Yes | All | Track count |
|
||||
| type | string | Yes | Spotify, Tidal, MusicBrainz | Album type (album, single, compilation) |
|
||||
| upc | string | Yes | Spotify, Tidal, Discogs | Universal Product Code |
|
||||
| copyright | string | Yes | Spotify, Tidal | Copyright notice |
|
||||
| artistId | string | Yes | All | Primary artist ID |
|
||||
| images | AlbumImageEntity[] | Yes | Spotify, Tidal, Deezer | Album artwork |
|
||||
| artists | ArtistReference[] | Yes | All | Contributing artists |
|
||||
| lastSyncTime | DateTime | Yes | All | Last sync timestamp |
|
||||
|
||||
**Example Requests:**
|
||||
|
||||
```bash
|
||||
# Search by album name
|
||||
GET /api/SearchAlbum?AlbumName=Abbey%20Road&Provider=Any
|
||||
|
||||
# Search albums by artist
|
||||
GET /api/SearchAlbum?ArtistId=3WrFJ7ztbogyGnTHbHJFl2&Provider=Spotify
|
||||
|
||||
# Get album by ID
|
||||
GET /api/SearchAlbum?AlbumId=1klALx0u4AavZNEvC4LrTL&Provider=Spotify
|
||||
|
||||
# Combined search
|
||||
GET /api/SearchAlbum?AlbumName=Abbey&ArtistId=3WrFJ7ztbogyGnTHbHJFl2
|
||||
```
|
||||
|
||||
### 3. SearchTrackController
|
||||
|
||||
**Path:** `/api/SearchTrack`
|
||||
**File:** `Controllers/SearchTrackController.cs`
|
||||
|
||||
#### GET /api/SearchTrack
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| TrackId | string | No | null | Track ID (provider-specific) |
|
||||
| ArtistId | string | No | null | Filter by artist ID |
|
||||
| TrackName | string | No | null | Track name for fuzzy search |
|
||||
| Provider | ProviderType | No | Any | Target provider |
|
||||
| Offset | int | No | 0 | Pagination offset |
|
||||
|
||||
**Response Model:** `SearchTrackResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"searchResultType": "Ok",
|
||||
"tracks": [
|
||||
{
|
||||
"providerType": "Spotify",
|
||||
"id": "6dGnYIeXmHdcikdzNNDMm2",
|
||||
"name": "Here Comes The Sun",
|
||||
"popularity": 90,
|
||||
"url": "https://open.spotify.com/track/6dGnYIeXmHdcikdzNNDMm2",
|
||||
"duration": 185000,
|
||||
"explicit": false,
|
||||
"discNumber": 1,
|
||||
"trackNumber": 7,
|
||||
"label": "Apple Records",
|
||||
"isrc": "GBAYE0601715",
|
||||
"album": {
|
||||
"id": "1klALx0u4AavZNEvC4LrTL",
|
||||
"name": "Abbey Road",
|
||||
"releaseDate": "1969-09-26"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"id": "3WrFJ7ztbogyGnTHbHJFl2",
|
||||
"name": "The Beatles"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://i.scdn.co/image/...",
|
||||
"height": 640,
|
||||
"width": 640
|
||||
}
|
||||
],
|
||||
"lastSyncTime": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**SearchTrackEntity Fields:**
|
||||
|
||||
| Field | Type | Nullable | Providers | Description |
|
||||
|-------|------|----------|-----------|-------------|
|
||||
| providerType | ProviderType | No | All | Source provider |
|
||||
| id | string | No | All | Provider-specific track ID |
|
||||
| name | string | No | All | Track name |
|
||||
| popularity | int | Yes | Spotify, Deezer | Popularity score |
|
||||
| url | string | Yes | All | Track URL |
|
||||
| duration | int | Yes | All | Duration in milliseconds |
|
||||
| explicit | bool | Yes | Spotify, Tidal, Deezer | Explicit content flag |
|
||||
| discNumber | int | Yes | All | Disc number in multi-disc albums |
|
||||
| trackNumber | int | Yes | All | Track number on disc |
|
||||
| label | string | Yes | Spotify, Tidal | Record label |
|
||||
| isrc | string | Yes | Spotify, Tidal, MusicBrainz | International Standard Recording Code |
|
||||
| album | AlbumReference | Yes | All | Parent album info |
|
||||
| artists | ArtistReference[] | Yes | All | Contributing artists |
|
||||
| images | TrackImageEntity[] | Yes | Spotify, Tidal, Deezer | Track/album artwork |
|
||||
| lastSyncTime | DateTime | Yes | All | Last sync timestamp |
|
||||
|
||||
**Example Requests:**
|
||||
|
||||
```bash
|
||||
# Search by track name
|
||||
GET /api/SearchTrack?TrackName=Here%20Comes%20The%20Sun&Provider=Any
|
||||
|
||||
# Search tracks by artist
|
||||
GET /api/SearchTrack?ArtistId=3WrFJ7ztbogyGnTHbHJFl2&Provider=Spotify
|
||||
|
||||
# Get track by ID
|
||||
GET /api/SearchTrack?TrackId=6dGnYIeXmHdcikdzNNDMm2&Provider=Spotify
|
||||
|
||||
# Combined search
|
||||
GET /api/SearchTrack?TrackName=Sun&ArtistId=3WrFJ7ztbogyGnTHbHJFl2
|
||||
```
|
||||
|
||||
### 4. SearchController
|
||||
|
||||
**Path:** `/api/Search`
|
||||
**File:** `Controllers/SearchController.cs`
|
||||
|
||||
**Status:** Stub implementation (not functional)
|
||||
|
||||
#### GET /api/Search
|
||||
|
||||
**Current Implementation:**
|
||||
```csharp
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
return Ok("Search endpoint - not implemented");
|
||||
}
|
||||
```
|
||||
|
||||
**Intended Purpose:** Unified search across artists, albums, and tracks (speculation based on naming).
|
||||
|
||||
**Current State:** Returns placeholder string, no actual functionality.
|
||||
|
||||
## Swagger/OpenAPI Documentation
|
||||
|
||||
**Endpoint:** `/swagger`
|
||||
**Framework:** Swashbuckle.AspNetCore 10.1.7
|
||||
|
||||
**Configuration:**
|
||||
```csharp
|
||||
builder.Services.AddSwaggerGen();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Auto-generated from controller attributes
|
||||
- Interactive API testing
|
||||
- Request/response schema documentation
|
||||
- Enum value descriptions
|
||||
|
||||
**Access:** Available in all environments (no production disable).
|
||||
|
||||
**Example Swagger URL:** `http://localhost:56232/swagger/index.html`
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
**Endpoint:** `/metrics`
|
||||
**Format:** Prometheus text exposition format
|
||||
|
||||
**Metrics Exposed:**
|
||||
|
||||
### minimediametadataapi_request_total
|
||||
|
||||
**Type:** Counter
|
||||
**Description:** Total HTTP requests
|
||||
**Labels:**
|
||||
- `path` - Request path (e.g., `/api/SearchArtist`)
|
||||
- `method` - HTTP method (GET, POST, etc.)
|
||||
- `status` - HTTP status code (200, 404, 500, etc.)
|
||||
|
||||
**Example Output:**
|
||||
```
|
||||
# HELP minimediametadataapi_request_total Total HTTP requests
|
||||
# TYPE minimediametadataapi_request_total counter
|
||||
minimediametadataapi_request_total{path="/api/SearchArtist",method="GET",status="200"} 1523
|
||||
minimediametadataapi_request_total{path="/api/SearchAlbum",method="GET",status="200"} 892
|
||||
minimediametadataapi_request_total{path="/api/SearchTrack",method="GET",status="404"} 45
|
||||
minimediametadataapi_request_total{path="/metrics",method="GET",status="200"} 3200
|
||||
```
|
||||
|
||||
**No other metrics:**
|
||||
- No request duration histograms
|
||||
- No database query metrics
|
||||
- No error rate metrics
|
||||
- No active request gauges
|
||||
|
||||
## Security Analysis
|
||||
|
||||
### Authentication
|
||||
|
||||
**Status:** None
|
||||
**Implications:** Fully open API, no user identification
|
||||
|
||||
**Missing:**
|
||||
- API keys
|
||||
- OAuth 2.0
|
||||
- JWT tokens
|
||||
- Basic authentication
|
||||
- Client certificates
|
||||
|
||||
### Authorization
|
||||
|
||||
**Status:** None
|
||||
**Implications:** All endpoints accessible to all clients
|
||||
|
||||
**Missing:**
|
||||
- Role-based access control (RBAC)
|
||||
- Scope-based permissions
|
||||
- Rate limiting per user/key
|
||||
|
||||
### HTTPS
|
||||
|
||||
**Status:** Commented out in production
|
||||
|
||||
**Program.cs:**
|
||||
```csharp
|
||||
// app.UseHttpsRedirection(); // COMMENTED OUT
|
||||
```
|
||||
|
||||
**Implications:**
|
||||
- Traffic sent in plain text
|
||||
- Vulnerable to man-in-the-middle attacks
|
||||
- No encryption for sensitive data (if any)
|
||||
|
||||
**Deployment:** Expects reverse proxy (nginx, Traefik) to handle TLS termination.
|
||||
|
||||
### CORS
|
||||
|
||||
**Status:** Not configured
|
||||
|
||||
**Implications:**
|
||||
- Browser-based clients blocked by default
|
||||
- No cross-origin requests allowed
|
||||
- Must be same-origin or use proxy
|
||||
|
||||
**Missing Configuration:**
|
||||
```csharp
|
||||
// NOT PRESENT
|
||||
builder.Services.AddCors(options => { ... });
|
||||
app.UseCors();
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
|
||||
**Sanitization:** `StringHelper.RemoveControlChars()`
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
public static string RemoveControlChars(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
return Regex.Replace(input, @"[\x00-\x1F\x7F-\x9F]", string.Empty);
|
||||
}
|
||||
```
|
||||
|
||||
**Protects Against:**
|
||||
- Control character injection
|
||||
- Null byte attacks
|
||||
- Terminal escape sequences
|
||||
|
||||
**Does NOT Protect Against:**
|
||||
- SQL injection (mitigated by Dapper parameterization)
|
||||
- XSS (JSON serialization handles escaping)
|
||||
- Path traversal (no file operations)
|
||||
- Command injection (no shell execution)
|
||||
|
||||
**Additional Sanitization:** `StringHelper.RemoveEmojis()`
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
public static string RemoveEmojis(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
return Regex.Replace(input,
|
||||
@"\p{Cs}", // Surrogate pairs (emojis)
|
||||
string.Empty);
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** Applied to database queries, not API inputs.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Status:** None
|
||||
|
||||
**Implications:**
|
||||
- Vulnerable to abuse
|
||||
- No protection against DoS
|
||||
- Unlimited queries per client
|
||||
|
||||
**Missing:**
|
||||
- Request throttling
|
||||
- IP-based limits
|
||||
- API key quotas
|
||||
- Burst protection
|
||||
|
||||
### SQL Injection Protection
|
||||
|
||||
**Mechanism:** Dapper parameterized queries
|
||||
|
||||
**Example:**
|
||||
```csharp
|
||||
var sql = "SELECT * FROM spotify_artist WHERE name ILIKE @name";
|
||||
var results = await connection.QueryAsync<SpotifyArtist>(sql, new { name = $"%{searchTerm}%" });
|
||||
```
|
||||
|
||||
**Protection:** Parameters never concatenated into SQL strings.
|
||||
|
||||
**Risk Level:** Low (Dapper handles parameterization correctly).
|
||||
|
||||
## Response Formats
|
||||
|
||||
### Content Type
|
||||
|
||||
**Default:** `application/json`
|
||||
**Encoding:** UTF-8
|
||||
**Serialization:** System.Text.Json (ASP.NET Core default)
|
||||
|
||||
### Success Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"searchResultType": "Ok",
|
||||
"artists": [ /* array of entities */ ]
|
||||
}
|
||||
```
|
||||
|
||||
### Not Found Response (200 OK with NotFound type)
|
||||
|
||||
```json
|
||||
{
|
||||
"searchResultType": "NotFound",
|
||||
"artists": []
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Returns HTTP 200 even when no results found. Client must check `searchResultType`.
|
||||
|
||||
### Error Response (500 Internal Server Error)
|
||||
|
||||
**ASP.NET Core default error handling:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
|
||||
"title": "An error occurred while processing your request.",
|
||||
"status": 500,
|
||||
"traceId": "00-abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
**No custom error responses** - uses framework defaults.
|
||||
|
||||
**Error Details:** Hidden in production (no stack traces exposed).
|
||||
|
||||
## Pagination
|
||||
|
||||
### Offset-Based Pagination
|
||||
|
||||
**Parameter:** `Offset` (default: 0)
|
||||
**Limit:** Hardcoded to 20 results per request
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# First page (0-19)
|
||||
GET /api/SearchArtist?Name=Beatles&Offset=0
|
||||
|
||||
# Second page (20-39)
|
||||
GET /api/SearchArtist?Name=Beatles&Offset=20
|
||||
|
||||
# Third page (40-59)
|
||||
GET /api/SearchArtist?Name=Beatles&Offset=40
|
||||
```
|
||||
|
||||
**SQL Implementation:**
|
||||
```sql
|
||||
LIMIT 20 OFFSET @offset
|
||||
```
|
||||
|
||||
**Limitations:**
|
||||
- No configurable page size
|
||||
- No total count in response
|
||||
- No next/previous links
|
||||
- No cursor-based pagination
|
||||
- Performance degrades with large offsets
|
||||
|
||||
**Missing Metadata:**
|
||||
```json
|
||||
// NOT PRESENT
|
||||
{
|
||||
"pagination": {
|
||||
"offset": 20,
|
||||
"limit": 20,
|
||||
"total": 150,
|
||||
"hasMore": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Provider-Specific Behavior
|
||||
|
||||
### ID Format Differences
|
||||
|
||||
| Provider | ID Type | Example |
|
||||
|----------|---------|---------|
|
||||
| Spotify | string (base62) | `3WrFJ7ztbogyGnTHbHJFl2` |
|
||||
| Tidal | int | `12345678` |
|
||||
| MusicBrainz | Guid | `b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d` |
|
||||
| Deezer | long | `123456789` |
|
||||
| Discogs | int | `12345` |
|
||||
| SoundCloud | int/long | `123456789` |
|
||||
|
||||
**Implication:** ID parameter must match provider's format. Cross-provider ID lookups not possible.
|
||||
|
||||
### Field Availability
|
||||
|
||||
**Popularity:**
|
||||
- Available: Spotify, Deezer
|
||||
- Unavailable: Tidal, MusicBrainz, Discogs, SoundCloud
|
||||
- Returns: `null` when unavailable
|
||||
|
||||
**Genres:**
|
||||
- Available: Spotify, Deezer, MusicBrainz
|
||||
- Unavailable: Tidal, Discogs, SoundCloud
|
||||
- Returns: `null` or empty array
|
||||
|
||||
**Images:**
|
||||
- Available: Spotify, Tidal, Deezer
|
||||
- Limited: MusicBrainz (via relationships)
|
||||
- Unavailable: Discogs (URLs only), SoundCloud
|
||||
- Returns: `null` or empty array
|
||||
|
||||
**UPC/ISRC:**
|
||||
- Available: Spotify, Tidal, MusicBrainz
|
||||
- Limited: Discogs (identifiers table)
|
||||
- Unavailable: Deezer, SoundCloud
|
||||
- Returns: `null` when unavailable
|
||||
|
||||
### Provider Comparison Table
|
||||
|
||||
| Feature | Spotify | Tidal | MusicBrainz | Deezer | Discogs | SoundCloud |
|
||||
|---------|---------|-------|-------------|--------|---------|------------|
|
||||
| Artist Images | ✓ | ✓ | Limited | ✓ | ✗ | ✗ |
|
||||
| Album Images | ✓ | ✓ | Limited | ✓ | ✗ | ✗ |
|
||||
| Popularity Score | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ |
|
||||
| Follower Count | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ |
|
||||
| Genres | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||
| UPC | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ |
|
||||
| ISRC | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
|
||||
| Label Info | ✓ | ✓ | ✓ | ✗ | ✓ | ✗ |
|
||||
| Release Date | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Explicit Flag | ✓ | ✓ | ✗ | ✓ | ✗ | ✗ |
|
||||
| Track Duration | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
## API Versioning
|
||||
|
||||
**Status:** None
|
||||
|
||||
**Current Approach:** Single version at `/api/*`
|
||||
|
||||
**Implications:**
|
||||
- Breaking changes affect all clients
|
||||
- No gradual migration path
|
||||
- No deprecation strategy
|
||||
|
||||
**Missing:**
|
||||
- URL versioning (`/api/v1/`, `/api/v2/`)
|
||||
- Header versioning (`Accept: application/vnd.api+json;version=1`)
|
||||
- Query parameter versioning (`?api-version=1.0`)
|
||||
|
||||
## Health Checks
|
||||
|
||||
**Status:** None
|
||||
|
||||
**Missing Endpoints:**
|
||||
- `/health` - Overall health status
|
||||
- `/health/ready` - Readiness probe (Kubernetes)
|
||||
- `/health/live` - Liveness probe (Kubernetes)
|
||||
|
||||
**Implications:**
|
||||
- No automated health monitoring
|
||||
- Load balancers can't detect unhealthy instances
|
||||
- Kubernetes probes not supported
|
||||
|
||||
**Recommended Implementation:**
|
||||
```csharp
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(connectionString);
|
||||
|
||||
app.MapHealthChecks("/health");
|
||||
```
|
||||
|
||||
## API Design Evaluation
|
||||
|
||||
### Strengths
|
||||
|
||||
1. **Consistent Interface:** All search endpoints follow same pattern
|
||||
2. **Provider Abstraction:** `Provider=Any` enables cross-provider search
|
||||
3. **Fuzzy Search:** pg_trgm provides forgiving name matching
|
||||
4. **Swagger Docs:** Interactive documentation out of the box
|
||||
5. **Prometheus Metrics:** Basic observability
|
||||
6. **Input Sanitization:** Control character removal
|
||||
|
||||
### Weaknesses
|
||||
|
||||
1. **No Authentication:** Fully open API
|
||||
2. **No Rate Limiting:** Vulnerable to abuse
|
||||
3. **No HTTPS:** Plain text traffic
|
||||
4. **No CORS:** Browser clients blocked
|
||||
5. **No Versioning:** Breaking changes unavoidable
|
||||
6. **No Health Checks:** Monitoring gaps
|
||||
7. **Fixed Page Size:** No pagination control
|
||||
8. **No Total Counts:** Can't determine result set size
|
||||
9. **HTTP 200 for Not Found:** Should use 404
|
||||
10. **No Error Details:** Generic error responses
|
||||
|
||||
### Recommendations for Production
|
||||
|
||||
1. **Add API Key Authentication:**
|
||||
```csharp
|
||||
builder.Services.AddAuthentication("ApiKey")
|
||||
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", null);
|
||||
```
|
||||
|
||||
2. **Implement Rate Limiting:**
|
||||
```csharp
|
||||
builder.Services.AddRateLimiter(options => {
|
||||
options.AddFixedWindowLimiter("api", opt => {
|
||||
opt.Window = TimeSpan.FromMinutes(1);
|
||||
opt.PermitLimit = 100;
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
3. **Enable HTTPS:**
|
||||
```csharp
|
||||
app.UseHttpsRedirection();
|
||||
```
|
||||
|
||||
4. **Add CORS Policy:**
|
||||
```csharp
|
||||
builder.Services.AddCors(options => {
|
||||
options.AddDefaultPolicy(policy => {
|
||||
policy.WithOrigins("https://trusted-domain.com")
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
5. **Add Health Checks:**
|
||||
```csharp
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(connectionString);
|
||||
app.MapHealthChecks("/health");
|
||||
```
|
||||
|
||||
6. **Implement API Versioning:**
|
||||
```csharp
|
||||
builder.Services.AddApiVersioning(options => {
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||
options.ReportApiVersions = true;
|
||||
});
|
||||
```
|
||||
|
||||
7. **Add Pagination Metadata:**
|
||||
```json
|
||||
{
|
||||
"data": [ /* results */ ],
|
||||
"pagination": {
|
||||
"offset": 20,
|
||||
"limit": 20,
|
||||
"total": 150
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
8. **Use Proper HTTP Status Codes:**
|
||||
- 200 OK - Results found
|
||||
- 404 Not Found - No results
|
||||
- 400 Bad Request - Invalid parameters
|
||||
- 429 Too Many Requests - Rate limit exceeded
|
||||
- 500 Internal Server Error - Server errors
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# Search artists
|
||||
curl "http://localhost:56232/api/SearchArtist?Name=Beatles&Provider=Any"
|
||||
|
||||
# Get specific artist
|
||||
curl "http://localhost:56232/api/SearchArtist?Id=3WrFJ7ztbogyGnTHbHJFl2&Provider=Spotify"
|
||||
|
||||
# Search albums with pagination
|
||||
curl "http://localhost:56232/api/SearchAlbum?AlbumName=Abbey&Offset=20"
|
||||
```
|
||||
|
||||
### JavaScript (Fetch API)
|
||||
|
||||
```javascript
|
||||
async function searchArtist(name, provider = 'Any') {
|
||||
const params = new URLSearchParams({ Name: name, Provider: provider });
|
||||
const response = await fetch(`http://localhost:56232/api/SearchArtist?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.searchResultType === 'Ok') {
|
||||
return data.artists;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Python (requests)
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
def search_track(track_name, artist_id=None, provider='Any'):
|
||||
params = {
|
||||
'TrackName': track_name,
|
||||
'Provider': provider
|
||||
}
|
||||
if artist_id:
|
||||
params['ArtistId'] = artist_id
|
||||
|
||||
response = requests.get(
|
||||
'http://localhost:56232/api/SearchTrack',
|
||||
params=params
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
return data['tracks'] if data['searchResultType'] == 'Ok' else []
|
||||
```
|
||||
|
||||
### C# (HttpClient)
|
||||
|
||||
```csharp
|
||||
public async Task<List<SearchAlbumEntity>> SearchAlbum(string albumName, string provider = "Any")
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var url = $"http://localhost:56232/api/SearchAlbum?AlbumName={Uri.EscapeDataString(albumName)}&Provider={provider}";
|
||||
var response = await client.GetFromJsonAsync<SearchAlbumResponse>(url);
|
||||
|
||||
return response?.SearchResultType == SearchResultType.Ok
|
||||
? response.Albums
|
||||
: new List<SearchAlbumEntity>();
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,695 @@
|
||||
# MiniMediaMetadataAPI - Architecture Analysis
|
||||
|
||||
## Architectural Pattern
|
||||
|
||||
**Primary Pattern:** Repository Pattern with Service Layer
|
||||
**NOT Clean Architecture** - simpler layered approach without strict dependency inversion
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
MiniMediaMetadataAPI.sln
|
||||
├── MiniMediaMetadataAPI/ (Web API Layer)
|
||||
│ ├── Controllers/ (HTTP endpoints)
|
||||
│ ├── Middlewares/ (Request pipeline)
|
||||
│ ├── Options/ (Configuration models)
|
||||
│ └── Program.cs (Entry point, DI setup)
|
||||
├── MiniMediaMetadataAPI.Application/ (Business Logic Layer)
|
||||
│ ├── Configurations/ (Database config models)
|
||||
│ ├── Enums/ (Provider types, result types)
|
||||
│ ├── Helpers/ (Utility functions)
|
||||
│ ├── Models/
|
||||
│ │ ├── Database/ (Provider-specific DB models)
|
||||
│ │ │ ├── Deezer/
|
||||
│ │ │ ├── Discogs/
|
||||
│ │ │ ├── MusicBrainz/
|
||||
│ │ │ ├── SoundCloud/
|
||||
│ │ │ ├── Spotify/
|
||||
│ │ │ └── Tidal/
|
||||
│ │ └── Entities/ (API response models)
|
||||
│ ├── Repositories/ (Data access layer)
|
||||
│ └── Services/ (Business logic)
|
||||
└── MiniMediaMetadataAPI.Tests/ (Test project - empty)
|
||||
```
|
||||
|
||||
## Layer Responsibilities
|
||||
|
||||
### Web API Layer (MiniMediaMetadataAPI)
|
||||
|
||||
**Purpose:** HTTP interface and request handling
|
||||
|
||||
**Components:**
|
||||
- **Controllers (4):** SearchArtist, SearchAlbum, SearchTrack, Search
|
||||
- **Middleware (1):** RequestMiddleware (Prometheus metrics)
|
||||
- **Program.cs:** DI container configuration, middleware pipeline setup
|
||||
|
||||
**Dependencies:**
|
||||
- ASP.NET Core framework
|
||||
- Swashbuckle (Swagger/OpenAPI)
|
||||
- prometheus-net
|
||||
- References Application layer
|
||||
|
||||
**Responsibilities:**
|
||||
- HTTP request/response handling
|
||||
- Input validation and sanitization
|
||||
- Swagger documentation generation
|
||||
- Metrics collection
|
||||
- Dependency injection configuration
|
||||
|
||||
### Application Layer (MiniMediaMetadataAPI.Application)
|
||||
|
||||
**Purpose:** Business logic and data access
|
||||
|
||||
**Components:**
|
||||
|
||||
#### Repositories (7 implementations)
|
||||
1. `SpotifyRepository` - Spotify data access
|
||||
2. `TidalRepository` - Tidal data access
|
||||
3. `MusicBrainzRepository` - MusicBrainz data access
|
||||
4. `DeezerRepository` - Deezer data access
|
||||
5. `DiscogsRepository` - Discogs data access
|
||||
6. `SoundCloudRepository` - SoundCloud data access
|
||||
7. `JobRepository` - Job tracking (unused)
|
||||
|
||||
Each repository implements:
|
||||
- `SearchArtist(string name, int offset)`
|
||||
- `GetArtistById(string/int/Guid id)`
|
||||
- `SearchAlbum(string name, string artistId, int offset)`
|
||||
- `GetAlbumById(string/int/Guid id)`
|
||||
- `SearchTrack(string name, string artistId, int offset)`
|
||||
- `GetTrackById(string/int/Guid id)`
|
||||
|
||||
#### Services (3 implementations)
|
||||
1. `SearchArtistService` - Orchestrates artist search across providers
|
||||
2. `SearchAlbumService` - Orchestrates album search across providers
|
||||
3. `SearchTrackService` - Orchestrates track search across providers
|
||||
|
||||
**Dependencies:**
|
||||
- Dapper (SQL mapping)
|
||||
- Npgsql (PostgreSQL driver)
|
||||
- FuzzySharp (string similarity)
|
||||
- Polly (resilience)
|
||||
|
||||
**Responsibilities:**
|
||||
- SQL query execution via Dapper
|
||||
- Provider-specific data mapping
|
||||
- Fuzzy search logic
|
||||
- Error handling and logging
|
||||
- Cross-provider aggregation (in services)
|
||||
|
||||
### Test Layer (MiniMediaMetadataAPI.Tests)
|
||||
|
||||
**Purpose:** Automated testing (currently unused)
|
||||
|
||||
**Current State:**
|
||||
- xUnit framework configured
|
||||
- Single empty test stub: `Test1()`
|
||||
- 0% code coverage
|
||||
- Not executed in CI/CD pipeline
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Request Flow (Artist Search Example)
|
||||
|
||||
```
|
||||
HTTP GET /api/SearchArtist?Name=Beatles&Provider=Any
|
||||
↓
|
||||
SearchArtistController.Get()
|
||||
↓
|
||||
Input sanitization (StringHelper.RemoveControlChars)
|
||||
↓
|
||||
ISearchArtistService.SearchArtist()
|
||||
↓
|
||||
[Provider=Any] → Query all 6 repositories in parallel
|
||||
[Provider=Spotify] → Query SpotifyRepository only
|
||||
↓
|
||||
Repository.SearchArtist()
|
||||
↓
|
||||
Dapper SQL execution with pg_trgm fuzzy match
|
||||
↓
|
||||
Map database models → SearchArtistEntity
|
||||
↓
|
||||
Return SearchArtistResponse (SearchResultType + entities)
|
||||
↓
|
||||
JSON serialization → HTTP 200 OK
|
||||
```
|
||||
|
||||
### Database Query Flow
|
||||
|
||||
```
|
||||
Service Layer
|
||||
↓
|
||||
Repository Interface (ISpotifyRepository)
|
||||
↓
|
||||
Repository Implementation (SpotifyRepository)
|
||||
↓
|
||||
Dapper QueryAsync<T>()
|
||||
↓
|
||||
Npgsql Connection (from pool)
|
||||
↓
|
||||
PostgreSQL Database
|
||||
↓
|
||||
pg_trgm similarity search
|
||||
↓
|
||||
Result set → Dapper mapping → Database models
|
||||
↓
|
||||
Transform to Entity models
|
||||
↓
|
||||
Return to Service
|
||||
```
|
||||
|
||||
## Database Access Strategy
|
||||
|
||||
### ORM Choice: Dapper (NOT Entity Framework)
|
||||
|
||||
**Rationale:**
|
||||
- Lightweight, minimal overhead
|
||||
- Direct SQL control for complex queries
|
||||
- No change tracking (read-only workload)
|
||||
- Better performance for high-throughput reads
|
||||
- Simpler for multi-provider schema
|
||||
|
||||
**Trade-offs:**
|
||||
- No automatic migrations (schema owned externally anyway)
|
||||
- Manual SQL writing (more verbose)
|
||||
- No LINQ query translation
|
||||
- Type safety only at compile time for models
|
||||
|
||||
### Connection Management
|
||||
|
||||
**Pooling Configuration:**
|
||||
```
|
||||
MinPoolSize=5
|
||||
MaxPoolSize=100
|
||||
```
|
||||
|
||||
**Connection Lifecycle:**
|
||||
- Connections created per request
|
||||
- Returned to pool after query
|
||||
- No long-lived connections
|
||||
- No connection state management
|
||||
|
||||
**No DbContext:** Each repository method opens/closes connections independently.
|
||||
|
||||
### Query Patterns
|
||||
|
||||
**Fuzzy Search (pg_trgm):**
|
||||
```sql
|
||||
SET LOCAL pg_trgm.similarity_threshold = 0.5;
|
||||
SELECT * FROM spotify_artist
|
||||
WHERE lower(name) % lower(@searchTerm)
|
||||
ORDER BY similarity(lower(name), lower(@searchTerm)) DESC
|
||||
LIMIT 20 OFFSET @offset;
|
||||
```
|
||||
|
||||
**Exact ID Lookup:**
|
||||
```sql
|
||||
SELECT * FROM spotify_artist WHERE id = @id;
|
||||
```
|
||||
|
||||
**Join Queries (Album with Artists):**
|
||||
```sql
|
||||
SELECT a.*, ar.*
|
||||
FROM spotify_album a
|
||||
LEFT JOIN spotify_album_artist aa ON a.id = aa.album_id
|
||||
LEFT JOIN spotify_artist ar ON aa.artist_id = ar.id
|
||||
WHERE a.id = @albumId;
|
||||
```
|
||||
|
||||
## Schema Ownership Model
|
||||
|
||||
**Critical Design Decision:** This API does NOT own the database schema.
|
||||
|
||||
### Responsibilities Split
|
||||
|
||||
| Concern | Owner | Location |
|
||||
|---------|-------|----------|
|
||||
| Schema definition | MiniMediaScanner | External project |
|
||||
| Migrations | MiniMediaScanner | External project |
|
||||
| Data ingestion | MiniMediaScanner | External project |
|
||||
| Provider API calls | MiniMediaScanner | External project |
|
||||
| Data sync scheduling | MiniMediaScanner | External project |
|
||||
| Query optimization | MiniMediaMetadataAPI | This project |
|
||||
| Read-only queries | MiniMediaMetadataAPI | This project |
|
||||
| Response formatting | MiniMediaMetadataAPI | This project |
|
||||
|
||||
### Implications
|
||||
|
||||
**Pros:**
|
||||
- Clear separation of concerns
|
||||
- API doesn't need provider API credentials
|
||||
- Simpler deployment (no migration coordination)
|
||||
- Avoids dual-write complexity
|
||||
- Sync logic isolated from query logic
|
||||
|
||||
**Cons:**
|
||||
- Schema changes require coordination
|
||||
- No control over data freshness
|
||||
- Dependency on external project
|
||||
- Can't optimize schema for query patterns
|
||||
- Breaking schema changes break API
|
||||
|
||||
### Coupling Points
|
||||
|
||||
1. **Table names** - Hardcoded in repository SQL
|
||||
2. **Column names** - Hardcoded in Dapper mappings
|
||||
3. **Data types** - Must match C# model properties
|
||||
4. **Relationships** - Foreign keys assumed in joins
|
||||
|
||||
**No schema validation** - API assumes schema exists and matches expectations.
|
||||
|
||||
## Provider Isolation Strategy
|
||||
|
||||
### Repository Per Provider
|
||||
|
||||
Each provider has dedicated repository implementation:
|
||||
|
||||
```
|
||||
ISpotifyRepository → SpotifyRepository
|
||||
ITidalRepository → TidalRepository
|
||||
IMusicBrainzRepository → MusicBrainzRepository
|
||||
IDeezerRepository → DeezerRepository
|
||||
IDiscogsRepository → DiscogsRepository
|
||||
ISoundCloudRepository → SoundCloudRepository
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Provider-specific logic isolated
|
||||
- Schema differences handled independently
|
||||
- Easy to add/remove providers
|
||||
- Clear testing boundaries
|
||||
- No cross-provider contamination
|
||||
|
||||
**Shared Interface:**
|
||||
```csharp
|
||||
public interface IProviderRepository
|
||||
{
|
||||
Task<List<ArtistModel>> SearchArtist(string name, int offset);
|
||||
Task<ArtistModel> GetArtistById(string id);
|
||||
Task<List<AlbumModel>> SearchAlbum(string name, string artistId, int offset);
|
||||
Task<AlbumModel> GetAlbumById(string id);
|
||||
Task<List<TrackModel>> SearchTrack(string name, string artistId, int offset);
|
||||
Task<TrackModel> GetTrackById(string id);
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** ID types vary by provider (string, int, Guid, long), so actual interfaces use provider-specific types.
|
||||
|
||||
### Database Models Per Provider
|
||||
|
||||
**60+ database models** organized by provider:
|
||||
|
||||
```
|
||||
Models/Database/
|
||||
├── Spotify/
|
||||
│ ├── SpotifyArtist.cs
|
||||
│ ├── SpotifyArtistImage.cs
|
||||
│ ├── SpotifyAlbum.cs
|
||||
│ ├── SpotifyAlbumArtist.cs
|
||||
│ ├── SpotifyAlbumImage.cs
|
||||
│ ├── SpotifyAlbumExternalId.cs
|
||||
│ ├── SpotifyTrack.cs
|
||||
│ ├── SpotifyTrackArtist.cs
|
||||
│ └── SpotifyTrackExternalId.cs
|
||||
├── Tidal/
|
||||
│ ├── TidalArtist.cs
|
||||
│ ├── TidalArtistImageLink.cs
|
||||
│ ├── TidalAlbum.cs
|
||||
│ ├── TidalAlbumExternalLink.cs
|
||||
│ ├── TidalAlbumImage.cs
|
||||
│ ├── TidalTrack.cs
|
||||
│ ├── TidalTrackArtist.cs
|
||||
│ └── TidalTrackExternalLink.cs
|
||||
├── MusicBrainz/
|
||||
│ ├── MusicBrainzArtist.cs
|
||||
│ ├── MusicBrainzRelease.cs
|
||||
│ ├── MusicBrainzReleaseLabel.cs
|
||||
│ ├── MusicBrainzLabel.cs
|
||||
│ ├── MusicBrainzReleaseTrack.cs
|
||||
│ └── MusicBrainzReleaseTrackArtist.cs
|
||||
├── Deezer/
|
||||
│ ├── DeezerArtist.cs
|
||||
│ ├── DeezerArtistImageLink.cs
|
||||
│ ├── DeezerAlbum.cs
|
||||
│ ├── DeezerAlbumImageLink.cs
|
||||
│ ├── DeezerAlbumArtist.cs
|
||||
│ ├── DeezerTrack.cs
|
||||
│ └── DeezerTrackArtist.cs
|
||||
├── Discogs/
|
||||
│ ├── DiscogsArtist.cs
|
||||
│ ├── DiscogsArtistAlias.cs
|
||||
│ ├── DiscogsArtistUrl.cs
|
||||
│ ├── DiscogsRelease.cs
|
||||
│ ├── DiscogsReleaseArtist.cs
|
||||
│ ├── DiscogsReleaseIdentifier.cs
|
||||
│ ├── DiscogsReleaseTrack.cs
|
||||
│ ├── DiscogsLabel.cs
|
||||
│ ├── DiscogsLabelSublabel.cs
|
||||
│ └── DiscogsLabelUrl.cs
|
||||
└── SoundCloud/
|
||||
├── SoundCloudUser.cs
|
||||
├── SoundCloudPlaylist.cs
|
||||
├── SoundCloudTrack.cs
|
||||
└── SoundCloudTrackArtist.cs
|
||||
```
|
||||
|
||||
**Mapping Strategy:**
|
||||
- Database models map 1:1 to database tables
|
||||
- Dapper auto-maps columns to properties (case-insensitive)
|
||||
- Complex types (arrays, nested objects) handled manually
|
||||
- No navigation properties (manual joins)
|
||||
|
||||
### Unified Entity Models
|
||||
|
||||
**API response models** are provider-agnostic:
|
||||
|
||||
```
|
||||
Models/Entities/
|
||||
├── SearchArtistEntity.cs
|
||||
├── SearchAlbumEntity.cs
|
||||
├── SearchTrackEntity.cs
|
||||
├── ArtistImageEntity.cs
|
||||
├── AlbumImageEntity.cs
|
||||
└── TrackImageEntity.cs
|
||||
```
|
||||
|
||||
**Transformation happens in repositories:**
|
||||
```csharp
|
||||
// SpotifyRepository
|
||||
private SearchArtistEntity MapToEntity(SpotifyArtist dbModel)
|
||||
{
|
||||
return new SearchArtistEntity
|
||||
{
|
||||
ProviderType = ProviderType.Spotify,
|
||||
Id = dbModel.Id,
|
||||
Name = dbModel.Name,
|
||||
Popularity = dbModel.Popularity,
|
||||
Url = dbModel.ExternalUrl,
|
||||
TotalFollowers = dbModel.Followers,
|
||||
Genres = dbModel.Genres,
|
||||
Images = MapImages(dbModel.Images),
|
||||
LastSyncTime = dbModel.LastSyncTime
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Service Layer Orchestration
|
||||
|
||||
### Cross-Provider Search
|
||||
|
||||
Services aggregate results from multiple repositories:
|
||||
|
||||
```csharp
|
||||
public class SearchArtistService : ISearchArtistService
|
||||
{
|
||||
private readonly ISpotifyRepository _spotify;
|
||||
private readonly ITidalRepository _tidal;
|
||||
private readonly IMusicBrainzRepository _musicBrainz;
|
||||
private readonly IDeezerRepository _deezer;
|
||||
private readonly IDiscogsRepository _discogs;
|
||||
private readonly ISoundCloudRepository _soundCloud;
|
||||
|
||||
public async Task<SearchArtistResponse> SearchArtist(
|
||||
string name,
|
||||
ProviderType provider,
|
||||
int offset)
|
||||
{
|
||||
if (provider == ProviderType.Any)
|
||||
{
|
||||
// Query all providers in parallel
|
||||
var tasks = new[]
|
||||
{
|
||||
_spotify.SearchArtist(name, offset),
|
||||
_tidal.SearchArtist(name, offset),
|
||||
_musicBrainz.SearchArtist(name, offset),
|
||||
_deezer.SearchArtist(name, offset),
|
||||
_discogs.SearchArtist(name, offset),
|
||||
_soundCloud.SearchArtist(name, offset)
|
||||
};
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
var combined = results.SelectMany(r => r).ToList();
|
||||
|
||||
return new SearchArtistResponse
|
||||
{
|
||||
SearchResultType = combined.Any()
|
||||
? SearchResultType.Ok
|
||||
: SearchResultType.NotFound,
|
||||
Artists = combined
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Query single provider
|
||||
var repository = GetRepository(provider);
|
||||
var results = await repository.SearchArtist(name, offset);
|
||||
|
||||
return new SearchArtistResponse
|
||||
{
|
||||
SearchResultType = results.Any()
|
||||
? SearchResultType.Ok
|
||||
: SearchResultType.NotFound,
|
||||
Artists = results
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel Execution:** When `Provider=Any`, all 6 repositories queried simultaneously via `Task.WhenAll()`.
|
||||
|
||||
**No Result Deduplication:** If same artist exists in multiple providers, returned multiple times with different `ProviderType` values.
|
||||
|
||||
## Middleware Pipeline
|
||||
|
||||
**Single middleware:** RequestMiddleware
|
||||
|
||||
**Purpose:** Prometheus metrics collection
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
public class RequestMiddleware
|
||||
{
|
||||
private static readonly Counter RequestCounter = Metrics
|
||||
.CreateCounter(
|
||||
"minimediametadataapi_request_total",
|
||||
"Total HTTP requests",
|
||||
new CounterConfiguration
|
||||
{
|
||||
LabelNames = new[] { "path", "method", "status" }
|
||||
});
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
await next(context);
|
||||
|
||||
RequestCounter
|
||||
.WithLabels(
|
||||
context.Request.Path,
|
||||
context.Request.Method,
|
||||
context.Response.StatusCode.ToString())
|
||||
.Inc();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Registered in Program.cs:**
|
||||
```csharp
|
||||
app.UseMiddleware<RequestMiddleware>();
|
||||
```
|
||||
|
||||
**No other middleware:**
|
||||
- No authentication middleware
|
||||
- No rate limiting middleware
|
||||
- No CORS middleware
|
||||
- No exception handling middleware (uses ASP.NET Core default)
|
||||
|
||||
## Dependency Injection Setup
|
||||
|
||||
**Program.cs registration:**
|
||||
|
||||
```csharp
|
||||
// Database configuration
|
||||
builder.Services.Configure<DatabaseConfiguration>(
|
||||
builder.Configuration.GetSection("DatabaseConfiguration"));
|
||||
|
||||
// Repositories
|
||||
builder.Services.AddScoped<ISpotifyRepository, SpotifyRepository>();
|
||||
builder.Services.AddScoped<ITidalRepository, TidalRepository>();
|
||||
builder.Services.AddScoped<IMusicBrainzRepository, MusicBrainzRepository>();
|
||||
builder.Services.AddScoped<IDeezerRepository, DeezerRepository>();
|
||||
builder.Services.AddScoped<IDiscogsRepository, DiscogsRepository>();
|
||||
builder.Services.AddScoped<ISoundCloudRepository, SoundCloudRepository>();
|
||||
builder.Services.AddScoped<IJobRepository, JobRepository>();
|
||||
|
||||
// Services
|
||||
builder.Services.AddScoped<ISearchArtistService, SearchArtistService>();
|
||||
builder.Services.AddScoped<ISearchAlbumService, SearchAlbumService>();
|
||||
builder.Services.AddScoped<ISearchTrackService, SearchTrackService>();
|
||||
|
||||
// Swagger
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// Controllers
|
||||
builder.Services.AddControllers();
|
||||
```
|
||||
|
||||
**Lifetime:** All components use `Scoped` lifetime (per-request).
|
||||
|
||||
**No Singleton services** - each request gets fresh instances.
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
**Repository Level:**
|
||||
```csharp
|
||||
public async Task<List<SearchArtistEntity>> SearchArtist(string name, int offset)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = new NpgsqlConnection(_connectionString);
|
||||
var results = await connection.QueryAsync<SpotifyArtist>(sql, parameters);
|
||||
return results.Select(MapToEntity).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching Spotify artists");
|
||||
return new List<SearchArtistEntity>();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Strategy:** Catch all exceptions, log, return empty list.
|
||||
|
||||
**No custom exceptions** - generic Exception catch.
|
||||
|
||||
**No error propagation** - failures silently return empty results.
|
||||
|
||||
**Implications:**
|
||||
- Partial failures in multi-provider search go unnoticed
|
||||
- Client can't distinguish between "no results" and "provider error"
|
||||
- No retry logic (despite Polly dependency)
|
||||
|
||||
## Configuration Management
|
||||
|
||||
**appsettings.json structure:**
|
||||
```json
|
||||
{
|
||||
"DatabaseConfiguration": {
|
||||
"ConnectionString": "Host=localhost;Database=minimediametadata;Username=user;Password=pass;MinPoolSize=5;MaxPoolSize=100"
|
||||
},
|
||||
"Prometheus": {
|
||||
"MetricsUrl": "/metrics"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Environment-specific overrides:**
|
||||
- `appsettings.Development.json` - logging only
|
||||
- No production-specific config file
|
||||
- Environment variables supported (ASP.NET Core default)
|
||||
|
||||
**No secrets management:**
|
||||
- Database password in plain text
|
||||
- No Azure Key Vault integration
|
||||
- No environment variable requirements
|
||||
|
||||
## Unused Dependencies
|
||||
|
||||
**Quartz (3.17.0):** Job scheduling framework registered but no jobs defined.
|
||||
|
||||
**SpotifyAPI.Web.Auth (7.4.2):** Spotify authentication library present but unused (MiniMediaScanner handles auth).
|
||||
|
||||
**Polly (8.6.6):** Resilience library registered but no retry policies applied.
|
||||
|
||||
**Implications:** Dependency bloat, potential security vulnerabilities in unused packages.
|
||||
|
||||
## Scalability Considerations
|
||||
|
||||
**Horizontal Scaling:**
|
||||
- Stateless design (no in-memory state)
|
||||
- Connection pooling supports multiple instances
|
||||
- No distributed locking needed
|
||||
- No session affinity required
|
||||
|
||||
**Bottlenecks:**
|
||||
- Database connection pool (max 100 per instance)
|
||||
- PostgreSQL query performance
|
||||
- No caching layer (every request hits database)
|
||||
|
||||
**Missing Optimizations:**
|
||||
- No Redis/Memcached for result caching
|
||||
- No CDN for static responses
|
||||
- No query result pagination limits (unbounded result sets)
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
**Current State:** Non-existent
|
||||
|
||||
**Configured Framework:** xUnit
|
||||
|
||||
**Missing Test Types:**
|
||||
- Unit tests (repository logic, service orchestration)
|
||||
- Integration tests (database queries)
|
||||
- API tests (controller endpoints)
|
||||
- Performance tests (load testing)
|
||||
|
||||
**Testability Issues:**
|
||||
- Repositories tightly coupled to Npgsql (hard to mock)
|
||||
- No repository interfaces in some cases
|
||||
- No test database setup scripts
|
||||
- No test data fixtures
|
||||
|
||||
## File Organization
|
||||
|
||||
**99 C# files** organized as:
|
||||
|
||||
```
|
||||
Controllers/ 4 files
|
||||
Middlewares/ 1 file
|
||||
Options/ 1 file
|
||||
Configurations/ 1 file
|
||||
Enums/ 2 files
|
||||
Helpers/ 2 files
|
||||
Models/Database/ 60+ files (10 per provider average)
|
||||
Models/Entities/ 6 files
|
||||
Repositories/ 7 files
|
||||
Services/ 3 files
|
||||
Tests/ 1 file (stub)
|
||||
```
|
||||
|
||||
**Naming Conventions:**
|
||||
- PascalCase for all files
|
||||
- Suffix pattern: `*Repository.cs`, `*Service.cs`, `*Controller.cs`, `*Entity.cs`
|
||||
- Provider prefix for database models: `Spotify*.cs`, `Tidal*.cs`, etc.
|
||||
|
||||
## Architecture Evaluation
|
||||
|
||||
**Strengths:**
|
||||
- Clear layer separation
|
||||
- Provider isolation via repositories
|
||||
- Parallel query execution for multi-provider search
|
||||
- Lightweight (Dapper over EF)
|
||||
- Simple dependency graph
|
||||
|
||||
**Weaknesses:**
|
||||
- No caching layer
|
||||
- Error handling swallows failures
|
||||
- Unused dependencies
|
||||
- No testing
|
||||
- Tight coupling to external schema
|
||||
- No API versioning strategy
|
||||
- No health checks
|
||||
|
||||
**Suitability for Reference:**
|
||||
- Repository pattern implementation: **Excellent**
|
||||
- Multi-provider aggregation: **Good**
|
||||
- Service orchestration: **Good**
|
||||
- Error handling: **Poor**
|
||||
- Testing approach: **Non-existent**
|
||||
- Production readiness: **Needs work**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,980 @@
|
||||
# MiniMediaMetadataAPI - Data Layer Analysis
|
||||
|
||||
## Database Technology
|
||||
|
||||
**RDBMS:** PostgreSQL
|
||||
**Driver:** Npgsql 10.0.2
|
||||
**ORM:** Dapper 2.1.72 (micro-ORM)
|
||||
**Extensions:** pg_trgm (trigram similarity search)
|
||||
|
||||
## Schema Ownership
|
||||
|
||||
**Critical Constraint:** This API does NOT own the database schema.
|
||||
|
||||
**Schema Owner:** MiniMediaScanner (separate project)
|
||||
**API Role:** Read-only consumer
|
||||
**Migration Strategy:** None (schema managed externally)
|
||||
|
||||
### Implications
|
||||
|
||||
**Pros:**
|
||||
- Clear separation of concerns
|
||||
- API doesn't need provider API credentials
|
||||
- Simpler deployment (no migration coordination)
|
||||
- Sync complexity isolated in MiniMediaScanner
|
||||
|
||||
**Cons:**
|
||||
- No control over schema evolution
|
||||
- Breaking changes in MiniMediaScanner break API
|
||||
- Can't optimize schema for query patterns
|
||||
- Data freshness depends on external sync schedule
|
||||
|
||||
**Coupling Points:**
|
||||
- Table names hardcoded in SQL queries
|
||||
- Column names hardcoded in Dapper mappings
|
||||
- Foreign key relationships assumed in joins
|
||||
- Data types must match C# model properties
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
**Connection String Format:**
|
||||
```
|
||||
Host=localhost;
|
||||
Database=minimediametadata;
|
||||
Username=postgres;
|
||||
Password=password;
|
||||
MinPoolSize=5;
|
||||
MaxPoolSize=100;
|
||||
Timeout=30;
|
||||
CommandTimeout=30;
|
||||
```
|
||||
|
||||
**Pooling Settings:**
|
||||
- **MinPoolSize:** 5 connections kept alive
|
||||
- **MaxPoolSize:** 100 concurrent connections
|
||||
- **Timeout:** 30 seconds to acquire connection
|
||||
- **CommandTimeout:** 30 seconds for query execution
|
||||
|
||||
**Connection Lifecycle:**
|
||||
- Connections created per repository method call
|
||||
- Returned to pool after query completion
|
||||
- No long-lived connections
|
||||
- No transaction management (read-only)
|
||||
|
||||
## Fuzzy Search Implementation
|
||||
|
||||
### pg_trgm Extension
|
||||
|
||||
**Purpose:** Trigram-based similarity search for fuzzy text matching
|
||||
|
||||
**Configuration:**
|
||||
```sql
|
||||
SET LOCAL pg_trgm.similarity_threshold = 0.5;
|
||||
```
|
||||
|
||||
**Threshold:** 0.5 (50% similarity required)
|
||||
|
||||
**Operators:**
|
||||
- `%` - Similarity operator (returns true if similarity >= threshold)
|
||||
- `similarity(text, text)` - Returns similarity score (0.0 to 1.0)
|
||||
|
||||
### Search Query Pattern
|
||||
|
||||
**Example (Artist Search):**
|
||||
```sql
|
||||
SET LOCAL pg_trgm.similarity_threshold = 0.5;
|
||||
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
popularity,
|
||||
external_url,
|
||||
followers,
|
||||
genres,
|
||||
last_sync_time
|
||||
FROM spotify_artist
|
||||
WHERE lower(name) % lower(@searchTerm)
|
||||
ORDER BY similarity(lower(name), lower(@searchTerm)) DESC
|
||||
LIMIT 20 OFFSET @offset;
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Case-insensitive matching (`lower()`)
|
||||
- Similarity-based ordering (best matches first)
|
||||
- Pagination support (LIMIT/OFFSET)
|
||||
- Threshold filtering (only >= 50% similarity)
|
||||
|
||||
**Performance:**
|
||||
- Requires GIN or GiST index on name column
|
||||
- Index creation: `CREATE INDEX idx_artist_name_trgm ON spotify_artist USING gin(lower(name) gin_trgm_ops);`
|
||||
- Query time: O(log n) with index, O(n) without
|
||||
|
||||
### Similarity Scoring
|
||||
|
||||
**Algorithm:** Trigram overlap
|
||||
|
||||
**Example:**
|
||||
```
|
||||
"Beatles" vs "Beetles"
|
||||
Trigrams: ["bea", "eat", "atl", "tle", "les"] vs ["bee", "eet", "etl", "tle", "les"]
|
||||
Overlap: ["tle", "les"] = 2/5 = 0.4 (below threshold)
|
||||
|
||||
"Beatles" vs "The Beatles"
|
||||
Trigrams: ["bea", "eat", "atl", "tle", "les"] vs ["the", "he ", "e b", " be", "bea", "eat", "atl", "tle", "les"]
|
||||
Overlap: ["bea", "eat", "atl", "tle", "les"] = 5/9 = 0.56 (above threshold)
|
||||
```
|
||||
|
||||
**Tuning:**
|
||||
- Lower threshold (0.3) = more results, more false positives
|
||||
- Higher threshold (0.7) = fewer results, more precision
|
||||
- Current 0.5 = balanced approach
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Provider-Specific Tables
|
||||
|
||||
Each provider has isolated table structure. No cross-provider foreign keys.
|
||||
|
||||
### Spotify Schema
|
||||
|
||||
**Tables:**
|
||||
1. `spotify_artist` - Artist metadata
|
||||
2. `spotify_artist_image` - Artist images (1:N)
|
||||
3. `spotify_album` - Album metadata
|
||||
4. `spotify_album_artist` - Album-artist relationships (M:N)
|
||||
5. `spotify_album_image` - Album artwork (1:N)
|
||||
6. `spotify_album_externalid` - External identifiers (UPC, EAN) (1:N)
|
||||
7. `spotify_track` - Track metadata
|
||||
8. `spotify_track_artist` - Track-artist relationships (M:N)
|
||||
9. `spotify_track_externalid` - External identifiers (ISRC) (1:N)
|
||||
|
||||
**spotify_artist:**
|
||||
```sql
|
||||
CREATE TABLE spotify_artist (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
popularity INTEGER,
|
||||
external_url VARCHAR(500),
|
||||
followers INTEGER,
|
||||
genres TEXT[], -- PostgreSQL array
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_spotify_artist_name_trgm ON spotify_artist USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**spotify_artist_image:**
|
||||
```sql
|
||||
CREATE TABLE spotify_artist_image (
|
||||
id SERIAL PRIMARY KEY,
|
||||
artist_id VARCHAR(255) REFERENCES spotify_artist(id),
|
||||
url VARCHAR(1000) NOT NULL,
|
||||
height INTEGER,
|
||||
width INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX idx_spotify_artist_image_artist ON spotify_artist_image(artist_id);
|
||||
```
|
||||
|
||||
**spotify_album:**
|
||||
```sql
|
||||
CREATE TABLE spotify_album (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
popularity INTEGER,
|
||||
external_url VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
release_date VARCHAR(50), -- Stored as string (YYYY, YYYY-MM, or YYYY-MM-DD)
|
||||
total_tracks INTEGER,
|
||||
album_type VARCHAR(50), -- album, single, compilation
|
||||
copyright TEXT,
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_spotify_album_name_trgm ON spotify_album USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**spotify_album_artist (junction table):**
|
||||
```sql
|
||||
CREATE TABLE spotify_album_artist (
|
||||
id SERIAL PRIMARY KEY,
|
||||
album_id VARCHAR(255) REFERENCES spotify_album(id),
|
||||
artist_id VARCHAR(255) REFERENCES spotify_artist(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_spotify_album_artist_album ON spotify_album_artist(album_id);
|
||||
CREATE INDEX idx_spotify_album_artist_artist ON spotify_album_artist(artist_id);
|
||||
```
|
||||
|
||||
**spotify_track:**
|
||||
```sql
|
||||
CREATE TABLE spotify_track (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
album_id VARCHAR(255) REFERENCES spotify_album(id),
|
||||
popularity INTEGER,
|
||||
external_url VARCHAR(500),
|
||||
duration_ms INTEGER,
|
||||
explicit BOOLEAN,
|
||||
disc_number INTEGER,
|
||||
track_number INTEGER,
|
||||
label VARCHAR(500),
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_spotify_track_name_trgm ON spotify_track USING gin(lower(name) gin_trgm_ops);
|
||||
CREATE INDEX idx_spotify_track_album ON spotify_track(album_id);
|
||||
```
|
||||
|
||||
**spotify_album_externalid:**
|
||||
```sql
|
||||
CREATE TABLE spotify_album_externalid (
|
||||
id SERIAL PRIMARY KEY,
|
||||
album_id VARCHAR(255) REFERENCES spotify_album(id),
|
||||
type VARCHAR(50), -- upc, ean
|
||||
value VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_spotify_album_externalid_album ON spotify_album_externalid(album_id);
|
||||
CREATE INDEX idx_spotify_album_externalid_value ON spotify_album_externalid(value);
|
||||
```
|
||||
|
||||
**spotify_track_externalid:**
|
||||
```sql
|
||||
CREATE TABLE spotify_track_externalid (
|
||||
id SERIAL PRIMARY KEY,
|
||||
track_id VARCHAR(255) REFERENCES spotify_track(id),
|
||||
type VARCHAR(50), -- isrc
|
||||
value VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_spotify_track_externalid_track ON spotify_track_externalid(track_id);
|
||||
CREATE INDEX idx_spotify_track_externalid_value ON spotify_track_externalid(value);
|
||||
```
|
||||
|
||||
### Tidal Schema
|
||||
|
||||
**Tables:**
|
||||
1. `tidal_artist` - Artist metadata
|
||||
2. `tidal_artist_image_link` - Artist image URLs (1:N)
|
||||
3. `tidal_album` - Album metadata
|
||||
4. `tidal_album_external_link` - External URLs (1:N)
|
||||
5. `tidal_album_image` - Album artwork (1:N)
|
||||
6. `tidal_track` - Track metadata
|
||||
7. `tidal_track_artist` - Track-artist relationships (M:N)
|
||||
8. `tidal_track_external_link` - External URLs (1:N)
|
||||
|
||||
**Key Differences from Spotify:**
|
||||
- ID type: INTEGER instead of VARCHAR
|
||||
- No popularity field
|
||||
- No genres field
|
||||
- External links instead of external IDs
|
||||
- Image links stored as separate table
|
||||
|
||||
**tidal_artist:**
|
||||
```sql
|
||||
CREATE TABLE tidal_artist (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
url VARCHAR(500),
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tidal_artist_name_trgm ON tidal_artist USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**tidal_album:**
|
||||
```sql
|
||||
CREATE TABLE tidal_album (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
artist_id INTEGER REFERENCES tidal_artist(id),
|
||||
url VARCHAR(500),
|
||||
release_date VARCHAR(50),
|
||||
total_tracks INTEGER,
|
||||
duration INTEGER, -- Total duration in seconds
|
||||
explicit BOOLEAN,
|
||||
upc VARCHAR(255),
|
||||
copyright TEXT,
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tidal_album_name_trgm ON tidal_album USING gin(lower(name) gin_trgm_ops);
|
||||
CREATE INDEX idx_tidal_album_artist ON tidal_album(artist_id);
|
||||
```
|
||||
|
||||
### MusicBrainz Schema
|
||||
|
||||
**Tables:**
|
||||
1. `musicbrainz_artist` - Artist metadata
|
||||
2. `musicbrainz_release` - Release (album) metadata
|
||||
3. `musicbrainz_release_label` - Release-label relationships (M:N)
|
||||
4. `musicbrainz_label` - Label metadata
|
||||
5. `musicbrainz_release_track` - Track metadata
|
||||
6. `musicbrainz_release_track_artist` - Track-artist relationships (M:N)
|
||||
|
||||
**Key Differences:**
|
||||
- ID type: UUID (Guid)
|
||||
- "Release" instead of "Album"
|
||||
- Sort name field for artists
|
||||
- Label as separate entity
|
||||
- No popularity or follower counts
|
||||
- No images (stored externally via Cover Art Archive)
|
||||
|
||||
**musicbrainz_artist:**
|
||||
```sql
|
||||
CREATE TABLE musicbrainz_artist (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
sort_name VARCHAR(500), -- For alphabetical sorting (e.g., "Beatles, The")
|
||||
type VARCHAR(100), -- Person, Group, Orchestra, etc.
|
||||
country VARCHAR(2), -- ISO country code
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_musicbrainz_artist_name_trgm ON musicbrainz_artist USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**musicbrainz_release:**
|
||||
```sql
|
||||
CREATE TABLE musicbrainz_release (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
artist_id UUID REFERENCES musicbrainz_artist(id),
|
||||
release_date VARCHAR(50),
|
||||
country VARCHAR(2),
|
||||
barcode VARCHAR(255), -- Similar to UPC
|
||||
status VARCHAR(100), -- Official, Promotion, Bootleg, etc.
|
||||
packaging VARCHAR(100), -- Jewel Case, Digipak, etc.
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_musicbrainz_release_name_trgm ON musicbrainz_release USING gin(lower(name) gin_trgm_ops);
|
||||
CREATE INDEX idx_musicbrainz_release_artist ON musicbrainz_release(artist_id);
|
||||
```
|
||||
|
||||
**musicbrainz_label:**
|
||||
```sql
|
||||
CREATE TABLE musicbrainz_label (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
type VARCHAR(100), -- Original Production, Bootleg Production, etc.
|
||||
country VARCHAR(2),
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
```
|
||||
|
||||
**musicbrainz_release_label (junction table):**
|
||||
```sql
|
||||
CREATE TABLE musicbrainz_release_label (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id UUID REFERENCES musicbrainz_release(id),
|
||||
label_id UUID REFERENCES musicbrainz_label(id),
|
||||
catalog_number VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_musicbrainz_release_label_release ON musicbrainz_release_label(release_id);
|
||||
CREATE INDEX idx_musicbrainz_release_label_label ON musicbrainz_release_label(label_id);
|
||||
```
|
||||
|
||||
### Deezer Schema
|
||||
|
||||
**Tables:**
|
||||
1. `deezer_artist` - Artist metadata
|
||||
2. `deezer_artist_image_link` - Artist image URLs (1:N)
|
||||
3. `deezer_album` - Album metadata
|
||||
4. `deezer_album_image_link` - Album artwork URLs (1:N)
|
||||
5. `deezer_album_artist` - Album-artist relationships (M:N)
|
||||
6. `deezer_track` - Track metadata
|
||||
7. `deezer_track_artist` - Track-artist relationships (M:N)
|
||||
|
||||
**Key Differences:**
|
||||
- ID type: BIGINT
|
||||
- Has popularity (called "fans")
|
||||
- Has genres
|
||||
- No UPC/ISRC fields
|
||||
- No label information
|
||||
|
||||
**deezer_artist:**
|
||||
```sql
|
||||
CREATE TABLE deezer_artist (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
url VARCHAR(500),
|
||||
fans INTEGER, -- Similar to followers
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deezer_artist_name_trgm ON deezer_artist USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**deezer_album:**
|
||||
```sql
|
||||
CREATE TABLE deezer_album (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
url VARCHAR(500),
|
||||
release_date VARCHAR(50),
|
||||
total_tracks INTEGER,
|
||||
duration INTEGER, -- Total duration in seconds
|
||||
explicit BOOLEAN,
|
||||
fans INTEGER,
|
||||
genres TEXT[], -- PostgreSQL array
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deezer_album_name_trgm ON deezer_album USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
### Discogs Schema
|
||||
|
||||
**Tables:**
|
||||
1. `discogs_artist` - Artist metadata
|
||||
2. `discogs_artist_alias` - Artist aliases (1:N)
|
||||
3. `discogs_artist_url` - Artist URLs (1:N)
|
||||
4. `discogs_release` - Release metadata
|
||||
5. `discogs_release_artist` - Release-artist relationships (M:N)
|
||||
6. `discogs_release_identifier` - Barcodes/identifiers (1:N)
|
||||
7. `discogs_release_track` - Track metadata
|
||||
8. `discogs_label` - Label metadata
|
||||
9. `discogs_label_sublabel` - Label hierarchy (1:N)
|
||||
10. `discogs_label_url` - Label URLs (1:N)
|
||||
|
||||
**Key Differences:**
|
||||
- ID type: INTEGER
|
||||
- Most comprehensive label data
|
||||
- Artist aliases tracked
|
||||
- Multiple identifiers per release (Barcode, Matrix, etc.)
|
||||
- No popularity metrics
|
||||
- No image URLs (stored externally)
|
||||
|
||||
**discogs_artist:**
|
||||
```sql
|
||||
CREATE TABLE discogs_artist (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
real_name VARCHAR(500), -- For pseudonyms
|
||||
profile TEXT, -- Biography
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_discogs_artist_name_trgm ON discogs_artist USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**discogs_artist_alias:**
|
||||
```sql
|
||||
CREATE TABLE discogs_artist_alias (
|
||||
id SERIAL PRIMARY KEY,
|
||||
artist_id INTEGER REFERENCES discogs_artist(id),
|
||||
alias_name VARCHAR(500)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_discogs_artist_alias_artist ON discogs_artist_alias(artist_id);
|
||||
CREATE INDEX idx_discogs_artist_alias_name_trgm ON discogs_artist_alias USING gin(lower(alias_name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**discogs_release:**
|
||||
```sql
|
||||
CREATE TABLE discogs_release (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
released VARCHAR(50),
|
||||
country VARCHAR(100),
|
||||
notes TEXT,
|
||||
genres TEXT[],
|
||||
styles TEXT[], -- More specific than genres
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_discogs_release_name_trgm ON discogs_release USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**discogs_release_identifier:**
|
||||
```sql
|
||||
CREATE TABLE discogs_release_identifier (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER REFERENCES discogs_release(id),
|
||||
type VARCHAR(100), -- Barcode, Matrix/Runout, Label Code, etc.
|
||||
value VARCHAR(500),
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_discogs_release_identifier_release ON discogs_release_identifier(release_id);
|
||||
CREATE INDEX idx_discogs_release_identifier_value ON discogs_release_identifier(value);
|
||||
```
|
||||
|
||||
**discogs_label:**
|
||||
```sql
|
||||
CREATE TABLE discogs_label (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
contact_info TEXT,
|
||||
profile TEXT,
|
||||
parent_label_id INTEGER REFERENCES discogs_label(id),
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_discogs_label_name_trgm ON discogs_label USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
### SoundCloud Schema
|
||||
|
||||
**Tables:**
|
||||
1. `soundcloud_user` - User/artist metadata
|
||||
2. `soundcloud_playlist` - Playlist metadata
|
||||
3. `soundcloud_track` - Track metadata
|
||||
4. `soundcloud_track_artist` - Track-artist relationships (M:N)
|
||||
|
||||
**Key Differences:**
|
||||
- "User" instead of "Artist" (user-generated content platform)
|
||||
- Playlist as first-class entity
|
||||
- No album concept
|
||||
- Minimal metadata (no UPC, ISRC, labels)
|
||||
- ID type: BIGINT
|
||||
|
||||
**soundcloud_user:**
|
||||
```sql
|
||||
CREATE TABLE soundcloud_user (
|
||||
id BIGINT PRIMARY KEY,
|
||||
username VARCHAR(500) NOT NULL,
|
||||
full_name VARCHAR(500),
|
||||
url VARCHAR(500),
|
||||
avatar_url VARCHAR(1000),
|
||||
followers_count INTEGER,
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_soundcloud_user_username_trgm ON soundcloud_user USING gin(lower(username) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**soundcloud_playlist:**
|
||||
```sql
|
||||
CREATE TABLE soundcloud_playlist (
|
||||
id BIGINT PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
user_id BIGINT REFERENCES soundcloud_user(id),
|
||||
url VARCHAR(500),
|
||||
artwork_url VARCHAR(1000),
|
||||
duration INTEGER, -- Total duration in milliseconds
|
||||
track_count INTEGER,
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_soundcloud_playlist_title_trgm ON soundcloud_playlist USING gin(lower(title) gin_trgm_ops);
|
||||
CREATE INDEX idx_soundcloud_playlist_user ON soundcloud_playlist(user_id);
|
||||
```
|
||||
|
||||
**soundcloud_track:**
|
||||
```sql
|
||||
CREATE TABLE soundcloud_track (
|
||||
id BIGINT PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
user_id BIGINT REFERENCES soundcloud_user(id),
|
||||
url VARCHAR(500),
|
||||
artwork_url VARCHAR(1000),
|
||||
duration INTEGER, -- Duration in milliseconds
|
||||
genre VARCHAR(255),
|
||||
playback_count INTEGER,
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_soundcloud_track_title_trgm ON soundcloud_track USING gin(lower(title) gin_trgm_ops);
|
||||
CREATE INDEX idx_soundcloud_track_user ON soundcloud_track(user_id);
|
||||
```
|
||||
|
||||
## ID Type Comparison
|
||||
|
||||
| Provider | Artist ID | Album ID | Track ID | Notes |
|
||||
|----------|-----------|----------|----------|-------|
|
||||
| Spotify | VARCHAR(255) | VARCHAR(255) | VARCHAR(255) | Base62 encoded (22 chars) |
|
||||
| Tidal | INTEGER | INTEGER | INTEGER | Sequential integers |
|
||||
| MusicBrainz | UUID | UUID | UUID | RFC 4122 UUIDs |
|
||||
| Deezer | BIGINT | BIGINT | BIGINT | Large integers |
|
||||
| Discogs | INTEGER | INTEGER | INTEGER | Sequential integers |
|
||||
| SoundCloud | BIGINT | N/A | BIGINT | No album concept |
|
||||
|
||||
**Implications:**
|
||||
- Cross-provider ID lookups impossible
|
||||
- ID parameter must match provider type
|
||||
- C# models use provider-specific types
|
||||
- No universal identifier system
|
||||
|
||||
## Data Type Patterns
|
||||
|
||||
### Arrays (PostgreSQL Native)
|
||||
|
||||
**Usage:** Genres, styles, external IDs
|
||||
|
||||
**Example:**
|
||||
```sql
|
||||
genres TEXT[] -- ["rock", "pop", "alternative"]
|
||||
```
|
||||
|
||||
**Dapper Mapping:**
|
||||
```csharp
|
||||
public class SpotifyArtist
|
||||
{
|
||||
public string[] Genres { get; set; } // Dapper auto-maps PostgreSQL arrays
|
||||
}
|
||||
```
|
||||
|
||||
### Timestamps
|
||||
|
||||
**Type:** `TIMESTAMP WITH TIME ZONE`
|
||||
**Purpose:** Track last sync time from provider
|
||||
|
||||
**Example:**
|
||||
```sql
|
||||
last_sync_time TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
```
|
||||
|
||||
**C# Mapping:**
|
||||
```csharp
|
||||
public DateTime? LastSyncTime { get; set; }
|
||||
```
|
||||
|
||||
### Variable-Length Dates
|
||||
|
||||
**Type:** VARCHAR(50)
|
||||
**Formats:** YYYY, YYYY-MM, YYYY-MM-DD
|
||||
|
||||
**Rationale:** Providers return different precision levels
|
||||
|
||||
**Examples:**
|
||||
- `"1969"` - Year only
|
||||
- `"1969-09"` - Year and month
|
||||
- `"1969-09-26"` - Full date
|
||||
|
||||
**C# Mapping:**
|
||||
```csharp
|
||||
public string ReleaseDate { get; set; } // Stored as string, parsed in application
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Artist Search
|
||||
|
||||
```sql
|
||||
SET LOCAL pg_trgm.similarity_threshold = 0.5;
|
||||
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
a.popularity,
|
||||
a.external_url,
|
||||
a.followers,
|
||||
a.genres,
|
||||
a.last_sync_time,
|
||||
i.url AS image_url,
|
||||
i.height AS image_height,
|
||||
i.width AS image_width
|
||||
FROM spotify_artist a
|
||||
LEFT JOIN spotify_artist_image i ON a.id = i.artist_id
|
||||
WHERE lower(a.name) % lower(@searchTerm)
|
||||
ORDER BY similarity(lower(a.name), lower(@searchTerm)) DESC
|
||||
LIMIT 20 OFFSET @offset;
|
||||
```
|
||||
|
||||
**Dapper Mapping:**
|
||||
```csharp
|
||||
var artistDict = new Dictionary<string, SpotifyArtist>();
|
||||
|
||||
var results = await connection.QueryAsync<SpotifyArtist, SpotifyArtistImage, SpotifyArtist>(
|
||||
sql,
|
||||
(artist, image) =>
|
||||
{
|
||||
if (!artistDict.TryGetValue(artist.Id, out var existingArtist))
|
||||
{
|
||||
existingArtist = artist;
|
||||
existingArtist.Images = new List<SpotifyArtistImage>();
|
||||
artistDict.Add(artist.Id, existingArtist);
|
||||
}
|
||||
|
||||
if (image != null)
|
||||
{
|
||||
existingArtist.Images.Add(image);
|
||||
}
|
||||
|
||||
return existingArtist;
|
||||
},
|
||||
new { searchTerm, offset },
|
||||
splitOn: "image_url"
|
||||
);
|
||||
|
||||
return artistDict.Values.ToList();
|
||||
```
|
||||
|
||||
### Album with Artists
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
a.popularity,
|
||||
a.external_url,
|
||||
a.label,
|
||||
a.release_date,
|
||||
a.total_tracks,
|
||||
a.album_type,
|
||||
a.copyright,
|
||||
a.last_sync_time,
|
||||
ar.id AS artist_id,
|
||||
ar.name AS artist_name
|
||||
FROM spotify_album a
|
||||
LEFT JOIN spotify_album_artist aa ON a.id = aa.album_id
|
||||
LEFT JOIN spotify_artist ar ON aa.artist_id = ar.id
|
||||
WHERE a.id = @albumId;
|
||||
```
|
||||
|
||||
**Multi-Mapping:** Album with nested artist list.
|
||||
|
||||
### Track with Album and Artists
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.popularity,
|
||||
t.external_url,
|
||||
t.duration_ms,
|
||||
t.explicit,
|
||||
t.disc_number,
|
||||
t.track_number,
|
||||
t.label,
|
||||
t.last_sync_time,
|
||||
a.id AS album_id,
|
||||
a.name AS album_name,
|
||||
a.release_date AS album_release_date,
|
||||
ar.id AS artist_id,
|
||||
ar.name AS artist_name
|
||||
FROM spotify_track t
|
||||
LEFT JOIN spotify_album a ON t.album_id = a.id
|
||||
LEFT JOIN spotify_track_artist ta ON t.id = ta.track_id
|
||||
LEFT JOIN spotify_artist ar ON ta.artist_id = ar.id
|
||||
WHERE t.id = @trackId;
|
||||
```
|
||||
|
||||
**Multi-Mapping:** Track with nested album and artist list.
|
||||
|
||||
### External ID Lookup
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
a.popularity,
|
||||
a.external_url,
|
||||
a.label,
|
||||
a.release_date,
|
||||
a.total_tracks,
|
||||
a.album_type,
|
||||
a.last_sync_time
|
||||
FROM spotify_album a
|
||||
INNER JOIN spotify_album_externalid e ON a.id = e.album_id
|
||||
WHERE e.type = 'upc' AND e.value = @upc;
|
||||
```
|
||||
|
||||
**Use Case:** Find album by UPC barcode.
|
||||
|
||||
## Index Strategy
|
||||
|
||||
### Required Indexes
|
||||
|
||||
**Fuzzy Search (GIN trigram):**
|
||||
```sql
|
||||
CREATE INDEX idx_spotify_artist_name_trgm ON spotify_artist USING gin(lower(name) gin_trgm_ops);
|
||||
CREATE INDEX idx_spotify_album_name_trgm ON spotify_album USING gin(lower(name) gin_trgm_ops);
|
||||
CREATE INDEX idx_spotify_track_name_trgm ON spotify_track USING gin(lower(name) gin_trgm_ops);
|
||||
```
|
||||
|
||||
**Foreign Keys:**
|
||||
```sql
|
||||
CREATE INDEX idx_spotify_album_artist_album ON spotify_album_artist(album_id);
|
||||
CREATE INDEX idx_spotify_album_artist_artist ON spotify_album_artist(artist_id);
|
||||
CREATE INDEX idx_spotify_track_album ON spotify_track(album_id);
|
||||
CREATE INDEX idx_spotify_track_artist_track ON spotify_track_artist(track_id);
|
||||
CREATE INDEX idx_spotify_track_artist_artist ON spotify_track_artist(artist_id);
|
||||
```
|
||||
|
||||
**External IDs:**
|
||||
```sql
|
||||
CREATE INDEX idx_spotify_album_externalid_value ON spotify_album_externalid(value);
|
||||
CREATE INDEX idx_spotify_track_externalid_value ON spotify_track_externalid(value);
|
||||
```
|
||||
|
||||
### Index Maintenance
|
||||
|
||||
**Owned by:** MiniMediaScanner (schema owner)
|
||||
**API Responsibility:** None (read-only consumer)
|
||||
|
||||
**Performance Impact:**
|
||||
- GIN indexes: Large (2-3x table size), slow writes, fast reads
|
||||
- B-tree indexes: Moderate size, fast writes, fast reads
|
||||
- No index = full table scan (unacceptable for fuzzy search)
|
||||
|
||||
## Data Freshness
|
||||
|
||||
**Sync Mechanism:** MiniMediaScanner polls provider APIs
|
||||
|
||||
**Sync Frequency:** Unknown (configured in MiniMediaScanner)
|
||||
|
||||
**Staleness Indicator:** `last_sync_time` column
|
||||
|
||||
**API Behavior:**
|
||||
- Returns whatever data exists in database
|
||||
- No real-time provider API calls
|
||||
- No cache invalidation
|
||||
- No sync triggering
|
||||
|
||||
**Client Considerations:**
|
||||
- Check `lastSyncTime` in response
|
||||
- Stale data possible (hours to days old)
|
||||
- No guarantee of completeness
|
||||
- Provider outages affect sync, not queries
|
||||
|
||||
## Provider Feature Matrix
|
||||
|
||||
| Feature | Spotify | Tidal | MusicBrainz | Deezer | Discogs | SoundCloud |
|
||||
|---------|---------|-------|-------------|--------|---------|------------|
|
||||
| **Artist Data** |
|
||||
| Popularity | ✓ | ✗ | ✗ | ✓ (fans) | ✗ | ✗ |
|
||||
| Followers | ✓ | ✗ | ✗ | ✓ (fans) | ✗ | ✓ |
|
||||
| Genres | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||
| Images | ✓ | ✓ | ✗ | ✓ | ✗ | ✓ (avatar) |
|
||||
| Sort Name | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ |
|
||||
| Aliases | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
|
||||
| **Album Data** |
|
||||
| Popularity | ✓ | ✗ | ✗ | ✓ (fans) | ✗ | N/A |
|
||||
| Images | ✓ | ✓ | ✗ | ✓ | ✗ | N/A |
|
||||
| Label | ✓ | ✗ | ✓ | ✗ | ✓ | N/A |
|
||||
| UPC | ✓ | ✓ | ✗ | ✗ | ✓ | N/A |
|
||||
| Copyright | ✓ | ✓ | ✗ | ✗ | ✗ | N/A |
|
||||
| Album Type | ✓ | ✗ | ✓ | ✗ | ✗ | N/A |
|
||||
| **Track Data** |
|
||||
| Popularity | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ (playback_count) |
|
||||
| Duration | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Explicit | ✓ | ✓ | ✗ | ✓ | ✗ | ✗ |
|
||||
| ISRC | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
|
||||
| Disc/Track # | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ |
|
||||
|
||||
## Database Size Estimates
|
||||
|
||||
**Assumptions:**
|
||||
- 1 million artists
|
||||
- 10 million albums
|
||||
- 100 million tracks
|
||||
|
||||
**Spotify Tables:**
|
||||
- `spotify_artist`: ~500 MB
|
||||
- `spotify_artist_image`: ~200 MB
|
||||
- `spotify_album`: ~5 GB
|
||||
- `spotify_album_artist`: ~1 GB
|
||||
- `spotify_album_image`: ~2 GB
|
||||
- `spotify_track`: ~50 GB
|
||||
- `spotify_track_artist`: ~10 GB
|
||||
- **Total:** ~70 GB per provider
|
||||
|
||||
**All Providers:** ~420 GB (6 providers)
|
||||
|
||||
**Indexes:** ~200 GB (GIN indexes are large)
|
||||
|
||||
**Total Database:** ~620 GB for comprehensive catalog
|
||||
|
||||
**Implications:**
|
||||
- Requires substantial storage
|
||||
- Backup/restore time significant
|
||||
- Index rebuilds time-consuming
|
||||
- Connection pooling critical
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Query Performance
|
||||
|
||||
**Fuzzy Search:**
|
||||
- With GIN index: 10-50ms for 20 results
|
||||
- Without index: 5-30 seconds (full table scan)
|
||||
- Threshold tuning affects result count and speed
|
||||
|
||||
**ID Lookup:**
|
||||
- With primary key: <1ms
|
||||
- With foreign key index: 1-5ms
|
||||
|
||||
**Join Queries:**
|
||||
- Album with artists: 5-20ms
|
||||
- Track with album and artists: 10-30ms
|
||||
- Depends on relationship cardinality
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
**Implemented:**
|
||||
- GIN indexes for fuzzy search
|
||||
- B-tree indexes for foreign keys
|
||||
- Connection pooling
|
||||
- Parameterized queries (SQL injection prevention)
|
||||
|
||||
**Missing:**
|
||||
- Query result caching (Redis/Memcached)
|
||||
- Materialized views for complex joins
|
||||
- Partitioning for large tables
|
||||
- Read replicas for horizontal scaling
|
||||
|
||||
### Bottlenecks
|
||||
|
||||
1. **GIN Index Size:** Large memory footprint
|
||||
2. **Fuzzy Search:** CPU-intensive similarity calculations
|
||||
3. **Multi-Provider Queries:** 6 parallel database queries
|
||||
4. **No Caching:** Every request hits database
|
||||
5. **Connection Pool Limit:** 100 max connections per instance
|
||||
|
||||
## Data Integrity
|
||||
|
||||
**Constraints:**
|
||||
- Primary keys on all entity tables
|
||||
- Foreign keys for relationships
|
||||
- NOT NULL on critical fields (id, name)
|
||||
|
||||
**No Constraints:**
|
||||
- No unique constraints on names (duplicates allowed)
|
||||
- No check constraints on data ranges
|
||||
- No triggers for data validation
|
||||
|
||||
**Orphan Prevention:**
|
||||
- Foreign keys with CASCADE delete (assumed)
|
||||
- Junction tables maintain referential integrity
|
||||
|
||||
**Data Quality:**
|
||||
- Depends entirely on MiniMediaScanner sync quality
|
||||
- No validation in this API
|
||||
- Garbage in, garbage out
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
**Responsibility:** Database administrator (not API)
|
||||
|
||||
**Recommended Strategy:**
|
||||
- Daily full backups
|
||||
- Continuous WAL archiving
|
||||
- Point-in-time recovery capability
|
||||
- Backup retention: 30 days
|
||||
|
||||
**Recovery Time:**
|
||||
- Full restore: Hours (620 GB database)
|
||||
- Index rebuild: Hours (GIN indexes)
|
||||
- Sync from providers: Days to weeks
|
||||
|
||||
## Schema Evolution
|
||||
|
||||
**Change Process:**
|
||||
1. MiniMediaScanner updates schema
|
||||
2. MiniMediaScanner deploys migration
|
||||
3. MiniMediaMetadataAPI updates models
|
||||
4. MiniMediaMetadataAPI redeploys
|
||||
|
||||
**Risk:** Breaking changes require coordinated deployment.
|
||||
|
||||
**Mitigation:**
|
||||
- Additive changes only (new columns, tables)
|
||||
- Deprecation period for removals
|
||||
- Version compatibility checks
|
||||
|
||||
**No Automated Migration:** API has no migration framework.
|
||||
@@ -0,0 +1,939 @@
|
||||
# MiniMediaMetadataAPI - Deployment Analysis
|
||||
|
||||
## Containerization
|
||||
|
||||
### Dockerfile
|
||||
|
||||
**Location:** `Dockerfile` (project root)
|
||||
|
||||
**Strategy:** Multi-stage build
|
||||
|
||||
**Full Dockerfile:**
|
||||
```dockerfile
|
||||
# Stage 1: Base runtime image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
# Stage 2: Build environment
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
|
||||
# Copy project files
|
||||
COPY ["MiniMediaMetadataAPI/MiniMediaMetadataAPI.csproj", "MiniMediaMetadataAPI/"]
|
||||
COPY ["MiniMediaMetadataAPI.Application/MiniMediaMetadataAPI.Application.csproj", "MiniMediaMetadataAPI.Application/"]
|
||||
|
||||
# Restore dependencies
|
||||
RUN dotnet restore "MiniMediaMetadataAPI/MiniMediaMetadataAPI.csproj"
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build project
|
||||
WORKDIR "/src/MiniMediaMetadataAPI"
|
||||
RUN dotnet build "MiniMediaMetadataAPI.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
# Stage 3: Publish
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "MiniMediaMetadataAPI.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Stage 4: Final runtime image
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
# Run as non-root user
|
||||
USER $APP_UID
|
||||
|
||||
ENTRYPOINT ["dotnet", "MiniMediaMetadataAPI.dll"]
|
||||
```
|
||||
|
||||
**Image Layers:**
|
||||
1. **base:** ASP.NET Core 8.0 runtime (minimal)
|
||||
2. **build:** .NET SDK 8.0 (includes build tools)
|
||||
3. **publish:** Compiled application artifacts
|
||||
4. **final:** Runtime + published app (smallest)
|
||||
|
||||
**Port Exposure:**
|
||||
- **8080:** HTTP endpoint
|
||||
- **8081:** HTTPS endpoint (unused, HTTPS disabled)
|
||||
|
||||
**Security Features:**
|
||||
- Non-root user (`$APP_UID` from base image)
|
||||
- Multi-stage build (no SDK in final image)
|
||||
- Minimal attack surface
|
||||
|
||||
**Image Size:**
|
||||
- Base image: ~200 MB (aspnet:8.0)
|
||||
- Application layer: ~20 MB
|
||||
- **Total:** ~220 MB
|
||||
|
||||
**Build Time:**
|
||||
- Restore: 10-30 seconds
|
||||
- Build: 20-40 seconds
|
||||
- Publish: 5-10 seconds
|
||||
- **Total:** ~1 minute
|
||||
|
||||
### Docker Compose (Development)
|
||||
|
||||
**Location:** `compose.yaml` (project root)
|
||||
|
||||
**Minimal Configuration:**
|
||||
```yaml
|
||||
services:
|
||||
minimediametadataapi:
|
||||
image: minimediametadataapi
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Build only (no runtime configuration)
|
||||
- No port mapping
|
||||
- No environment variables
|
||||
- No volume mounts
|
||||
- No network configuration
|
||||
- No health checks
|
||||
|
||||
**Purpose:** Development build testing only
|
||||
|
||||
**Not Suitable For:** Running the application
|
||||
|
||||
### Docker Compose (Production)
|
||||
|
||||
**Location:** Not in repository (documented in README)
|
||||
|
||||
**Production Configuration:**
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
minimediametadataapi:
|
||||
image: musicmovearr/minimediametadataapi:latest
|
||||
container_name: minimediametadataapi
|
||||
ports:
|
||||
- "56232:8080"
|
||||
volumes:
|
||||
- ./appsettings.json:/app/appsettings.json:ro
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- media-network
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: minimediametadata-db
|
||||
environment:
|
||||
- POSTGRES_DB=minimediametadata
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- media-network
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
networks:
|
||||
media-network:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
**Key Configuration:**
|
||||
|
||||
| Setting | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| Port Mapping | 56232:8080 | External:Internal HTTP |
|
||||
| Memory Limit | 256M | Resource constraint |
|
||||
| Restart Policy | unless-stopped | Auto-restart on failure |
|
||||
| Volume Mount | appsettings.json | Configuration override |
|
||||
| Environment | Production | ASP.NET Core environment |
|
||||
| Network | media-network | Isolated network |
|
||||
|
||||
**Dependencies:**
|
||||
- PostgreSQL 16 (separate container)
|
||||
- Shared network for database connectivity
|
||||
|
||||
**Missing:**
|
||||
- Health checks
|
||||
- Logging configuration
|
||||
- Prometheus metrics port
|
||||
- HTTPS configuration
|
||||
- Resource CPU limits
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
**Location:** `.github/workflows/docker-image.yml`
|
||||
|
||||
**Workflow:**
|
||||
```yaml
|
||||
name: Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
musicmovearr/minimediametadataapi:latest
|
||||
musicmovearr/minimediametadataapi:${{ github.sha }}
|
||||
cache-from: type=registry,ref=musicmovearr/minimediametadataapi:buildcache
|
||||
cache-to: type=registry,ref=musicmovearr/minimediametadataapi:buildcache,mode=max
|
||||
```
|
||||
|
||||
**Triggers:**
|
||||
- Push to `main` branch
|
||||
- Pull request to `main` branch
|
||||
|
||||
**Steps:**
|
||||
1. Checkout code
|
||||
2. Set up Docker Buildx (multi-platform builds)
|
||||
3. Log in to Docker Hub
|
||||
4. Build Docker image
|
||||
5. Push to Docker Hub with tags
|
||||
|
||||
**Tags:**
|
||||
- `latest` - Most recent build
|
||||
- `<git-sha>` - Specific commit (e.g., `abc123def456`)
|
||||
|
||||
**Caching:**
|
||||
- Registry cache for faster builds
|
||||
- Reuses layers from previous builds
|
||||
|
||||
**Secrets Required:**
|
||||
- `DOCKER_USERNAME` - Docker Hub username
|
||||
- `DOCKER_PASSWORD` - Docker Hub password/token
|
||||
|
||||
**Build Time:** 2-5 minutes (with cache)
|
||||
|
||||
**Missing Steps:**
|
||||
- No test execution
|
||||
- No code quality checks
|
||||
- No security scanning
|
||||
- No deployment automation
|
||||
- No rollback mechanism
|
||||
|
||||
### Docker Hub
|
||||
|
||||
**Repository:** `musicmovearr/minimediametadataapi`
|
||||
|
||||
**Visibility:** Public
|
||||
|
||||
**Tags:**
|
||||
- `latest` - Latest main branch build
|
||||
- `<git-sha>` - Specific commit builds
|
||||
|
||||
**Image Pulls:** Unknown (public repository)
|
||||
|
||||
**Automated Builds:** Via GitHub Actions (not Docker Hub auto-build)
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### appsettings.json
|
||||
|
||||
**Location:** `MiniMediaMetadataAPI/appsettings.json`
|
||||
|
||||
**Default Configuration:**
|
||||
```json
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"DatabaseConfiguration": {
|
||||
"ConnectionString": "Host=localhost;Database=minimediametadata;Username=postgres;Password=postgres;MinPoolSize=5;MaxPoolSize=100"
|
||||
},
|
||||
"Prometheus": {
|
||||
"MetricsUrl": "/metrics"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Environment-Specific Overrides:**
|
||||
|
||||
**appsettings.Development.json:**
|
||||
```json
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**appsettings.Production.json:** Not included (use volume mount)
|
||||
|
||||
**Configuration Hierarchy:**
|
||||
1. `appsettings.json` (base)
|
||||
2. `appsettings.{Environment}.json` (override)
|
||||
3. Environment variables (override)
|
||||
4. Command-line arguments (override)
|
||||
|
||||
**Sensitive Data:**
|
||||
- Database password in plain text (NOT SECURE)
|
||||
- No secrets management
|
||||
- No encryption
|
||||
|
||||
**Recommended Approach:**
|
||||
```json
|
||||
{
|
||||
"DatabaseConfiguration": {
|
||||
"ConnectionString": "Host=${DB_HOST};Database=${DB_NAME};Username=${DB_USER};Password=${DB_PASSWORD};MinPoolSize=5;MaxPoolSize=100"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
```bash
|
||||
export DB_HOST=postgres
|
||||
export DB_NAME=minimediametadata
|
||||
export DB_USER=postgres
|
||||
export DB_PASSWORD=secure_password_here
|
||||
```
|
||||
|
||||
### Volume Mounts
|
||||
|
||||
**Production Pattern:**
|
||||
```yaml
|
||||
volumes:
|
||||
- ./appsettings.json:/app/appsettings.json:ro
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Configuration changes without rebuild
|
||||
- Environment-specific settings
|
||||
- Secrets outside image
|
||||
|
||||
**Limitations:**
|
||||
- Requires file on host
|
||||
- Manual synchronization
|
||||
- No version control for production config
|
||||
|
||||
**Alternative: Environment Variables**
|
||||
```yaml
|
||||
environment:
|
||||
- DatabaseConfiguration__ConnectionString=Host=postgres;Database=minimediametadata;Username=postgres;Password=${DB_PASSWORD}
|
||||
- Prometheus__MetricsUrl=/metrics
|
||||
```
|
||||
|
||||
**ASP.NET Core Syntax:** Double underscore (`__`) for nested properties.
|
||||
|
||||
## Deployment Environments
|
||||
|
||||
### Development
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/MusicMoveArr/MiniMediaMetadataAPI.git
|
||||
cd MiniMediaMetadataAPI
|
||||
|
||||
# Run with .NET CLI
|
||||
dotnet run --project MiniMediaMetadataAPI
|
||||
|
||||
# Or with Docker
|
||||
docker build -t minimediametadataapi .
|
||||
docker run -p 8080:8080 minimediametadataapi
|
||||
```
|
||||
|
||||
**Database:** Local PostgreSQL or Docker container
|
||||
|
||||
**Configuration:** `appsettings.Development.json`
|
||||
|
||||
**Logging:** Debug level
|
||||
|
||||
### Staging
|
||||
|
||||
**Not Documented:** No staging environment configuration
|
||||
|
||||
**Recommended Setup:**
|
||||
- Separate Docker Compose file
|
||||
- Staging database (copy of production schema)
|
||||
- Production-like resource limits
|
||||
- Monitoring and logging
|
||||
|
||||
### Production
|
||||
|
||||
**Deployment Method:** Docker Compose
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
# Pull latest image
|
||||
docker pull musicmovearr/minimediametadataapi:latest
|
||||
|
||||
# Create appsettings.json with production values
|
||||
cat > appsettings.json <<EOF
|
||||
{
|
||||
"DatabaseConfiguration": {
|
||||
"ConnectionString": "Host=postgres;Database=minimediametadata;Username=postgres;Password=${DB_PASSWORD};MinPoolSize=5;MaxPoolSize=100"
|
||||
},
|
||||
"Prometheus": {
|
||||
"MetricsUrl": "/metrics"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# Verify health
|
||||
curl http://localhost:56232/swagger
|
||||
curl http://localhost:56232/metrics
|
||||
```
|
||||
|
||||
**Database Setup:**
|
||||
- Managed by MiniMediaScanner (separate deployment)
|
||||
- Schema must exist before API starts
|
||||
- No migrations run by API
|
||||
|
||||
**Monitoring:**
|
||||
- Prometheus metrics at `/metrics`
|
||||
- Docker logs: `docker logs minimediametadataapi`
|
||||
- No APM (Application Performance Monitoring)
|
||||
|
||||
## Health Checks
|
||||
|
||||
### Docker Health Check
|
||||
|
||||
**Status:** Not configured
|
||||
|
||||
**Recommended Dockerfile Addition:**
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
```
|
||||
|
||||
**Requires:** Health endpoint implementation in API
|
||||
|
||||
### Kubernetes Probes
|
||||
|
||||
**Status:** Not configured
|
||||
|
||||
**Recommended Deployment:**
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: minimediametadataapi
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: musicmovearr/minimediametadataapi:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "500m"
|
||||
```
|
||||
|
||||
**Missing:** Health endpoint implementation
|
||||
|
||||
## Resource Management
|
||||
|
||||
### Memory
|
||||
|
||||
**Limit:** 256 MB (Docker Compose)
|
||||
|
||||
**Actual Usage:** <250 MB (documented)
|
||||
|
||||
**Breakdown:**
|
||||
- .NET Runtime: ~50 MB
|
||||
- Application Code: ~20 MB
|
||||
- Connection Pool: ~50 MB (100 connections)
|
||||
- Request Buffers: ~50 MB
|
||||
- Overhead: ~80 MB
|
||||
|
||||
**Tuning:**
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 128M
|
||||
```
|
||||
|
||||
**OOM Risk:** Low (usage below limit)
|
||||
|
||||
### CPU
|
||||
|
||||
**Limit:** Not configured
|
||||
|
||||
**Recommended:**
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1.0"
|
||||
reservations:
|
||||
cpu: "0.25"
|
||||
```
|
||||
|
||||
**CPU Usage:**
|
||||
- Idle: <5%
|
||||
- Light load (10 req/s): 10-20%
|
||||
- Heavy load (100 req/s): 50-80%
|
||||
|
||||
### Disk
|
||||
|
||||
**Image Size:** ~220 MB
|
||||
|
||||
**Runtime Disk Usage:**
|
||||
- Logs: Variable (depends on retention)
|
||||
- Temp files: Minimal
|
||||
- No persistent storage needed
|
||||
|
||||
**Volume Mounts:**
|
||||
- `appsettings.json`: <1 KB
|
||||
- Logs (optional): Variable
|
||||
|
||||
## Networking
|
||||
|
||||
### Ports
|
||||
|
||||
| Port | Protocol | Purpose | Exposed |
|
||||
|------|----------|---------|---------|
|
||||
| 8080 | HTTP | API endpoints | Yes (56232) |
|
||||
| 8081 | HTTPS | Secure API (unused) | No |
|
||||
|
||||
**Port Mapping:** `56232:8080` (host:container)
|
||||
|
||||
**Why 56232?** Arbitrary high port (avoids conflicts)
|
||||
|
||||
### Network Isolation
|
||||
|
||||
**Docker Network:** `media-network` (bridge)
|
||||
|
||||
**Connectivity:**
|
||||
- API → PostgreSQL (internal network)
|
||||
- External → API (port 56232)
|
||||
- API → Internet (not needed)
|
||||
|
||||
**Firewall Rules:**
|
||||
```bash
|
||||
# Allow API port
|
||||
ufw allow 56232/tcp
|
||||
|
||||
# Allow Prometheus scraping (if external)
|
||||
ufw allow 9090/tcp
|
||||
```
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
**Not Configured:** Direct port exposure
|
||||
|
||||
**Recommended: nginx**
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:56232;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /metrics {
|
||||
deny all; # Restrict Prometheus endpoint
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- HTTPS termination
|
||||
- Load balancing
|
||||
- Rate limiting
|
||||
- Access control
|
||||
|
||||
## Logging
|
||||
|
||||
### Console Logging
|
||||
|
||||
**Default:** ASP.NET Core console logger
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:** Docker logs
|
||||
|
||||
**Viewing:**
|
||||
```bash
|
||||
# Real-time logs
|
||||
docker logs -f minimediametadataapi
|
||||
|
||||
# Last 100 lines
|
||||
docker logs --tail 100 minimediametadataapi
|
||||
|
||||
# Since timestamp
|
||||
docker logs --since 2024-01-01T00:00:00 minimediametadataapi
|
||||
```
|
||||
|
||||
### Log Aggregation
|
||||
|
||||
**Status:** Not configured
|
||||
|
||||
**Recommended: ELK Stack**
|
||||
```yaml
|
||||
services:
|
||||
minimediametadataapi:
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
labels: "service=minimediametadataapi"
|
||||
```
|
||||
|
||||
**Alternatives:**
|
||||
- Loki + Grafana
|
||||
- Splunk
|
||||
- Datadog
|
||||
- CloudWatch (AWS)
|
||||
|
||||
### Structured Logging
|
||||
|
||||
**Status:** Not implemented
|
||||
|
||||
**Recommended: Serilog**
|
||||
```csharp
|
||||
builder.Host.UseSerilog((context, configuration) =>
|
||||
{
|
||||
configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Application", "MiniMediaMetadataAPI")
|
||||
.WriteTo.Console(new JsonFormatter())
|
||||
.WriteTo.File(
|
||||
new JsonFormatter(),
|
||||
"/app/logs/log-.json",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7);
|
||||
});
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
**Endpoint:** `/metrics`
|
||||
|
||||
**Exposed Metrics:**
|
||||
- `minimediametadataapi_request_total` (counter)
|
||||
|
||||
**Prometheus Configuration:**
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'minimediametadataapi'
|
||||
static_configs:
|
||||
- targets: ['minimediametadataapi:8080']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 15s
|
||||
```
|
||||
|
||||
**Grafana Dashboard:** Not provided
|
||||
|
||||
**Recommended Metrics:**
|
||||
- Request duration histogram
|
||||
- Database query duration
|
||||
- Error rate by provider
|
||||
- Active requests gauge
|
||||
- Connection pool usage
|
||||
|
||||
### Application Performance Monitoring
|
||||
|
||||
**Status:** Not configured
|
||||
|
||||
**Recommended: Application Insights**
|
||||
```csharp
|
||||
builder.Services.AddApplicationInsightsTelemetry(options =>
|
||||
{
|
||||
options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
|
||||
});
|
||||
```
|
||||
|
||||
**Alternatives:**
|
||||
- New Relic
|
||||
- Datadog APM
|
||||
- Elastic APM
|
||||
- Jaeger (distributed tracing)
|
||||
|
||||
## Scaling
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
**Docker Compose:**
|
||||
```yaml
|
||||
services:
|
||||
minimediametadataapi:
|
||||
image: musicmovearr/minimediametadataapi:latest
|
||||
deploy:
|
||||
replicas: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
```
|
||||
|
||||
**Kubernetes:**
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: minimediametadataapi
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: minimediametadataapi
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: minimediametadataapi
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: musicmovearr/minimediametadataapi:latest
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpu: 1
|
||||
```
|
||||
|
||||
**Load Balancer:**
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: minimediametadataapi
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
app: minimediametadataapi
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
```
|
||||
|
||||
**Considerations:**
|
||||
- Stateless design (scales easily)
|
||||
- Database connection pool per instance
|
||||
- No session affinity needed
|
||||
- No distributed cache (yet)
|
||||
|
||||
### Vertical Scaling
|
||||
|
||||
**Current:** 256 MB memory, no CPU limit
|
||||
|
||||
**Scaling Up:**
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpu: 2
|
||||
```
|
||||
|
||||
**Diminishing Returns:** Beyond 512 MB, horizontal scaling more effective.
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Application
|
||||
|
||||
**Backup:** Not needed (stateless)
|
||||
|
||||
**Recovery:** Redeploy from Docker Hub
|
||||
|
||||
**Rollback:**
|
||||
```bash
|
||||
# Deploy specific version
|
||||
docker pull musicmovearr/minimediametadataapi:<git-sha>
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
**Responsibility:** Database administrator (not API)
|
||||
|
||||
**Backup Strategy:**
|
||||
```bash
|
||||
# Backup
|
||||
docker exec minimediametadata-db pg_dump -U postgres minimediametadata > backup.sql
|
||||
|
||||
# Restore
|
||||
docker exec -i minimediametadata-db psql -U postgres minimediametadata < backup.sql
|
||||
```
|
||||
|
||||
**Automated Backups:**
|
||||
```yaml
|
||||
services:
|
||||
postgres-backup:
|
||||
image: prodrigestivill/postgres-backup-local
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_DB=minimediametadata
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- SCHEDULE=@daily
|
||||
- BACKUP_KEEP_DAYS=7
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Image Security
|
||||
|
||||
**Base Image:** `mcr.microsoft.com/dotnet/aspnet:8.0`
|
||||
|
||||
**Vulnerabilities:** Check with `docker scan`
|
||||
|
||||
**Updates:** Monthly .NET patch releases
|
||||
|
||||
**Non-Root User:** Yes (`$APP_UID`)
|
||||
|
||||
**Secrets in Image:** No (configuration via volume mount)
|
||||
|
||||
### Network Security
|
||||
|
||||
**HTTPS:** Disabled (expects reverse proxy)
|
||||
|
||||
**Firewall:** Host-level (not container-level)
|
||||
|
||||
**Network Isolation:** Docker bridge network
|
||||
|
||||
**Recommendations:**
|
||||
- Enable HTTPS in production
|
||||
- Use secrets management (Docker secrets, Kubernetes secrets)
|
||||
- Implement network policies (Kubernetes)
|
||||
- Regular security scanning
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
**Pre-Deployment:**
|
||||
- [ ] Database schema exists (via MiniMediaScanner)
|
||||
- [ ] PostgreSQL accessible from API container
|
||||
- [ ] `appsettings.json` configured with production values
|
||||
- [ ] Secrets stored securely (not in image)
|
||||
- [ ] Docker Hub credentials configured (CI/CD)
|
||||
|
||||
**Deployment:**
|
||||
- [ ] Pull latest image
|
||||
- [ ] Update `docker-compose.yml` if needed
|
||||
- [ ] Start containers: `docker-compose up -d`
|
||||
- [ ] Verify API responds: `curl http://localhost:56232/swagger`
|
||||
- [ ] Check metrics: `curl http://localhost:56232/metrics`
|
||||
- [ ] Review logs: `docker logs minimediametadataapi`
|
||||
|
||||
**Post-Deployment:**
|
||||
- [ ] Configure Prometheus scraping
|
||||
- [ ] Set up log aggregation
|
||||
- [ ] Configure alerts (uptime, errors)
|
||||
- [ ] Document deployment in runbook
|
||||
- [ ] Test rollback procedure
|
||||
|
||||
## Deployment Evaluation
|
||||
|
||||
**Strengths:**
|
||||
- Multi-stage Docker build (small image)
|
||||
- Non-root user (security)
|
||||
- CI/CD automation (GitHub Actions)
|
||||
- Resource limits (memory)
|
||||
- Restart policy (resilience)
|
||||
|
||||
**Weaknesses:**
|
||||
- No health checks
|
||||
- No staging environment
|
||||
- No automated tests in CI/CD
|
||||
- No security scanning
|
||||
- No deployment automation (manual docker-compose)
|
||||
- Secrets in plain text
|
||||
- No HTTPS configuration
|
||||
- No log aggregation
|
||||
- No APM integration
|
||||
|
||||
**Production Readiness:** 6/10
|
||||
|
||||
**Recommendations:**
|
||||
1. Implement health endpoints
|
||||
2. Add health checks to Dockerfile
|
||||
3. Configure HTTPS (reverse proxy or in-app)
|
||||
4. Use secrets management
|
||||
5. Add automated tests to CI/CD
|
||||
6. Implement log aggregation
|
||||
7. Set up APM monitoring
|
||||
8. Create staging environment
|
||||
9. Automate deployment (Kubernetes, Terraform)
|
||||
10. Regular security scanning
|
||||
@@ -0,0 +1,592 @@
|
||||
# MiniMediaMetadataAPI - Comprehensive Evaluation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Project:** MiniMediaMetadataAPI
|
||||
**Purpose:** Multi-provider music metadata aggregation API
|
||||
**Technology:** .NET 8.0, PostgreSQL, Dapper
|
||||
**Providers:** 6 (Spotify, Tidal, MusicBrainz, Deezer, Discogs, SoundCloud)
|
||||
**Architecture:** Repository Pattern with Service Layer
|
||||
**Maturity:** Early production / Advanced prototype
|
||||
|
||||
**Overall Assessment:** Solid foundation with significant gaps in production hardening.
|
||||
|
||||
## Strengths
|
||||
|
||||
### 1. Multi-Provider Aggregation
|
||||
|
||||
**Value:** Unified API across 6 music metadata providers
|
||||
|
||||
**Implementation:**
|
||||
- Provider-agnostic search with `Provider=Any`
|
||||
- Parallel query execution (all providers simultaneously)
|
||||
- Consistent response format regardless of provider
|
||||
- Provider-specific data preserved in unified schema
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Single request searches all 6 providers
|
||||
GET /api/SearchArtist?Name=Beatles&Provider=Any
|
||||
```
|
||||
|
||||
**Benefit:** Clients don't need to integrate with 6 different APIs.
|
||||
|
||||
### 2. Clean Architecture
|
||||
|
||||
**Separation of Concerns:**
|
||||
- Controllers: HTTP interface
|
||||
- Services: Business logic orchestration
|
||||
- Repositories: Data access
|
||||
- Models: Database and entity representations
|
||||
|
||||
**Provider Isolation:**
|
||||
- One repository per provider
|
||||
- Provider-specific logic contained
|
||||
- Easy to add/remove providers
|
||||
- No cross-provider contamination
|
||||
|
||||
**Testability:**
|
||||
- Clear boundaries (though tests missing)
|
||||
- Dependency injection throughout
|
||||
- Interface-based design
|
||||
|
||||
### 3. Performance Optimizations
|
||||
|
||||
**Fuzzy Search:**
|
||||
- PostgreSQL pg_trgm extension
|
||||
- GIN indexes for fast similarity matching
|
||||
- Configurable similarity threshold (0.5)
|
||||
- Case-insensitive matching
|
||||
|
||||
**Parallel Execution:**
|
||||
```csharp
|
||||
var tasks = new[] { /* 6 provider queries */ };
|
||||
var results = await Task.WhenAll(tasks);
|
||||
```
|
||||
- Multi-provider search in 20-50ms (not 120-300ms sequential)
|
||||
|
||||
**Connection Pooling:**
|
||||
- MinPoolSize: 5
|
||||
- MaxPoolSize: 100
|
||||
- Efficient connection reuse
|
||||
|
||||
**Lightweight:**
|
||||
- <250MB memory footprint
|
||||
- Dapper over Entity Framework (minimal overhead)
|
||||
- No change tracking (read-only)
|
||||
|
||||
### 4. Observability Foundation
|
||||
|
||||
**Prometheus Metrics:**
|
||||
- Request counter with labels (path, method, status)
|
||||
- `/metrics` endpoint for scraping
|
||||
- Ready for Grafana dashboards
|
||||
|
||||
**Logging:**
|
||||
- Structured error logging
|
||||
- Contextual information (search terms, providers)
|
||||
- ASP.NET Core integration
|
||||
|
||||
**Swagger Documentation:**
|
||||
- Interactive API testing
|
||||
- Auto-generated from code
|
||||
- Request/response schemas
|
||||
|
||||
### 5. Deployment Simplicity
|
||||
|
||||
**Docker:**
|
||||
- Multi-stage build (small image)
|
||||
- Non-root user (security)
|
||||
- ~220MB final image
|
||||
|
||||
**CI/CD:**
|
||||
- GitHub Actions automation
|
||||
- Docker Hub publishing
|
||||
- Commit-tagged images
|
||||
|
||||
**Resource Efficiency:**
|
||||
- 256MB memory limit
|
||||
- Suitable for containerized environments
|
||||
- Horizontal scaling ready (stateless)
|
||||
|
||||
### 6. Database Design
|
||||
|
||||
**Provider-Specific Tables:**
|
||||
- Clean separation (no cross-provider foreign keys)
|
||||
- Schema optimized per provider
|
||||
- Easy to sync independently
|
||||
|
||||
**Fuzzy Search:**
|
||||
- pg_trgm trigram matching
|
||||
- Handles typos and variations
|
||||
- Similarity-based ranking
|
||||
|
||||
**Comprehensive Metadata:**
|
||||
- Images, genres, popularity, followers
|
||||
- UPC, ISRC, labels, copyright
|
||||
- Release dates, track numbers, durations
|
||||
|
||||
## Weaknesses
|
||||
|
||||
### 1. Security Gaps
|
||||
|
||||
**No Authentication:**
|
||||
- Fully open API
|
||||
- No API keys
|
||||
- No OAuth
|
||||
- No user identification
|
||||
|
||||
**No Authorization:**
|
||||
- All endpoints accessible to all
|
||||
- No role-based access control
|
||||
- No rate limiting per user
|
||||
|
||||
**HTTPS Disabled:**
|
||||
```csharp
|
||||
// app.UseHttpsRedirection(); // COMMENTED OUT
|
||||
```
|
||||
- Plain text traffic
|
||||
- Vulnerable to MITM attacks
|
||||
- Expects reverse proxy (not documented)
|
||||
|
||||
**Secrets in Plain Text:**
|
||||
```json
|
||||
{
|
||||
"ConnectionString": "...Password=postgres..."
|
||||
}
|
||||
```
|
||||
- Database credentials exposed
|
||||
- No secrets management
|
||||
- Security risk in version control
|
||||
|
||||
**No CORS Configuration:**
|
||||
- Browser clients blocked
|
||||
- No cross-origin policy
|
||||
- Must use proxy or same-origin
|
||||
|
||||
**No Rate Limiting:**
|
||||
- Vulnerable to abuse
|
||||
- No DoS protection
|
||||
- Unlimited queries per client
|
||||
|
||||
**Security Score:** 2/10
|
||||
|
||||
### 2. Testing Gaps
|
||||
|
||||
**Zero Test Coverage:**
|
||||
```csharp
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
// Empty test
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Missing Test Types:**
|
||||
- Unit tests (repository logic, service orchestration)
|
||||
- Integration tests (database queries)
|
||||
- API tests (controller endpoints)
|
||||
- Performance tests (load, stress)
|
||||
|
||||
**CI/CD Impact:**
|
||||
- Tests not run in pipeline
|
||||
- No quality gate
|
||||
- Breaking changes undetected
|
||||
|
||||
**Implications:**
|
||||
- High regression risk
|
||||
- Difficult to refactor safely
|
||||
- No confidence in changes
|
||||
|
||||
**Testing Score:** 0/10
|
||||
|
||||
### 3. Production Hardening Gaps
|
||||
|
||||
**No Health Checks:**
|
||||
- No `/health` endpoint
|
||||
- No readiness probe
|
||||
- No liveness probe
|
||||
- Load balancers can't detect failures
|
||||
|
||||
**No API Versioning:**
|
||||
- Single version at `/api/*`
|
||||
- Breaking changes affect all clients
|
||||
- No deprecation strategy
|
||||
- No gradual migration path
|
||||
|
||||
**No Caching Layer:**
|
||||
- Every request hits database
|
||||
- No Redis/Memcached
|
||||
- No CDN for static responses
|
||||
- Unnecessary database load
|
||||
|
||||
**Fixed Pagination:**
|
||||
- Hardcoded 20 results per page
|
||||
- No configurable page size
|
||||
- No total count in response
|
||||
- No next/previous links
|
||||
|
||||
**Error Handling Issues:**
|
||||
```csharp
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error...");
|
||||
return new List<T>(); // Empty result
|
||||
}
|
||||
```
|
||||
- Errors swallowed
|
||||
- Client can't distinguish error from no results
|
||||
- No retry logic
|
||||
- No circuit breaker
|
||||
|
||||
**HTTP Status Code Issues:**
|
||||
- Returns 200 OK for not found (should be 404)
|
||||
- Returns 200 OK for errors (should be 500)
|
||||
- Client must check `searchResultType` field
|
||||
|
||||
**Production Readiness Score:** 5/10
|
||||
|
||||
### 4. Schema Coupling
|
||||
|
||||
**External Schema Ownership:**
|
||||
- MiniMediaScanner owns database schema
|
||||
- API has no control over schema evolution
|
||||
- Breaking changes in MiniMediaScanner break API
|
||||
- No schema validation
|
||||
|
||||
**Coordination Required:**
|
||||
- Schema changes need synchronized deployment
|
||||
- No migration framework in API
|
||||
- Tight coupling between projects
|
||||
|
||||
**Data Freshness:**
|
||||
- Depends on MiniMediaScanner sync schedule
|
||||
- No control over sync frequency
|
||||
- No real-time data
|
||||
- Stale data possible (hours to days)
|
||||
|
||||
**Risk:**
|
||||
- Single point of failure (MiniMediaScanner)
|
||||
- Schema drift possible
|
||||
- No versioning strategy
|
||||
|
||||
**Coupling Score:** 4/10
|
||||
|
||||
### 5. Unused Dependencies
|
||||
|
||||
**Dead Code:**
|
||||
- Quartz 3.17.0 (scheduler, no jobs defined)
|
||||
- Polly 8.6.6 (resilience, no policies applied)
|
||||
- FuzzySharp 2.0.2 (string matching, not used)
|
||||
- SpotifyAPI.Web.Auth 7.4.2 (auth, not needed)
|
||||
|
||||
**Implications:**
|
||||
- Dependency bloat
|
||||
- Security vulnerabilities in unused packages
|
||||
- Confusion for developers
|
||||
- Larger image size
|
||||
|
||||
**Recommendation:** Remove or implement.
|
||||
|
||||
### 6. Observability Gaps
|
||||
|
||||
**Limited Metrics:**
|
||||
- Only request counter
|
||||
- No request duration histogram
|
||||
- No database query metrics
|
||||
- No error rate by provider
|
||||
- No active request gauge
|
||||
|
||||
**No APM:**
|
||||
- No Application Insights
|
||||
- No New Relic
|
||||
- No Datadog
|
||||
- No distributed tracing
|
||||
|
||||
**No Structured Logging:**
|
||||
- Plain text logs
|
||||
- No JSON format
|
||||
- No correlation IDs
|
||||
- Difficult to parse/query
|
||||
|
||||
**No Log Aggregation:**
|
||||
- Docker logs only
|
||||
- No ELK stack
|
||||
- No Loki
|
||||
- No centralized logging
|
||||
|
||||
**Observability Score:** 4/10
|
||||
|
||||
## Integration Value
|
||||
|
||||
### Relevance to metadata-aggregator Project
|
||||
|
||||
**High Relevance:** This is the closest existing implementation to our goals.
|
||||
|
||||
**Direct Applicability:**
|
||||
|
||||
1. **Multi-Provider Aggregation Pattern**
|
||||
- Proven approach for 6 providers
|
||||
- Repository-per-provider scales well
|
||||
- Service layer orchestration works
|
||||
|
||||
2. **Database Schema Design**
|
||||
- Provider-specific tables
|
||||
- Fuzzy search implementation
|
||||
- Comprehensive metadata coverage
|
||||
|
||||
3. **API Design**
|
||||
- Provider-agnostic search
|
||||
- Unified response format
|
||||
- Pagination support
|
||||
|
||||
4. **Performance Patterns**
|
||||
- Parallel query execution
|
||||
- Connection pooling
|
||||
- Dapper for read-heavy workloads
|
||||
|
||||
**Learnings to Apply:**
|
||||
|
||||
1. **Repository Pattern:** Clean provider isolation
|
||||
2. **Fuzzy Search:** pg_trgm for forgiving name matching
|
||||
3. **Parallel Execution:** `Task.WhenAll()` for multi-provider queries
|
||||
4. **Provider Enum:** Simple but effective provider selection
|
||||
5. **Entity Models:** Provider-agnostic response format
|
||||
|
||||
**Gaps to Address:**
|
||||
|
||||
1. **Authentication:** Add API key or OAuth
|
||||
2. **Testing:** Comprehensive test suite
|
||||
3. **Caching:** Redis for frequently accessed data
|
||||
4. **Health Checks:** Kubernetes-ready probes
|
||||
5. **API Versioning:** Future-proof API evolution
|
||||
6. **Rate Limiting:** Abuse prevention
|
||||
7. **Error Handling:** Proper HTTP status codes
|
||||
8. **Observability:** Structured logging, APM
|
||||
|
||||
### Integration Strategies
|
||||
|
||||
**Option 1: Fork and Enhance**
|
||||
- Fork repository
|
||||
- Add missing features (auth, tests, caching)
|
||||
- Maintain as separate service
|
||||
- **Risk:** GPL-3.0 license (copyleft)
|
||||
|
||||
**Option 2: Clean-Room Implementation**
|
||||
- Study architecture and patterns
|
||||
- Implement from scratch
|
||||
- Avoid GPL license issues
|
||||
- Add production features from start
|
||||
|
||||
**Option 3: Use as Reference**
|
||||
- Learn from design decisions
|
||||
- Adopt proven patterns
|
||||
- Implement independently
|
||||
- No license concerns
|
||||
|
||||
**Recommendation:** Option 3 (reference implementation)
|
||||
|
||||
**Rationale:**
|
||||
- GPL-3.0 license incompatible with proprietary use
|
||||
- Missing features require significant work anyway
|
||||
- Clean implementation allows better architecture
|
||||
- Can cherry-pick best patterns
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
### vs. Direct Provider APIs
|
||||
|
||||
| Aspect | MiniMediaMetadataAPI | Direct Provider APIs |
|
||||
|--------|----------------------|----------------------|
|
||||
| Integration Effort | Single API | 6 separate integrations |
|
||||
| Authentication | None (open) | 6 different auth flows |
|
||||
| Rate Limiting | None | Per-provider limits |
|
||||
| Data Freshness | Hours to days | Real-time |
|
||||
| Response Format | Unified | Provider-specific |
|
||||
| Fuzzy Search | Built-in | Varies by provider |
|
||||
| Cost | Free (self-hosted) | API quotas/fees |
|
||||
| Reliability | Single point of failure | Distributed |
|
||||
|
||||
**Use Case:** MiniMediaMetadataAPI better for internal tools, prototypes, or when real-time data not critical.
|
||||
|
||||
### vs. Commercial Aggregators
|
||||
|
||||
| Aspect | MiniMediaMetadataAPI | Commercial (e.g., MusicBrainz API) |
|
||||
|--------|----------------------|-------------------------------------|
|
||||
| Cost | Free (self-hosted) | Subscription fees |
|
||||
| Customization | Full control | Limited |
|
||||
| Providers | 6 (fixed) | Varies |
|
||||
| SLA | None | Guaranteed uptime |
|
||||
| Support | Community | Professional |
|
||||
| Scalability | Self-managed | Managed |
|
||||
|
||||
**Use Case:** MiniMediaMetadataAPI better for cost-sensitive projects with technical resources.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Technical Risks
|
||||
|
||||
**High Risk:**
|
||||
- No authentication (security breach)
|
||||
- No tests (regression bugs)
|
||||
- Schema coupling (breaking changes)
|
||||
- Single maintainer (abandonment)
|
||||
|
||||
**Medium Risk:**
|
||||
- No caching (performance degradation)
|
||||
- No health checks (undetected failures)
|
||||
- Unused dependencies (security vulnerabilities)
|
||||
|
||||
**Low Risk:**
|
||||
- HTTPS disabled (mitigated by reverse proxy)
|
||||
- No API versioning (manageable with careful changes)
|
||||
|
||||
### Operational Risks
|
||||
|
||||
**High Risk:**
|
||||
- No monitoring (blind to issues)
|
||||
- No alerting (delayed incident response)
|
||||
- No runbook (difficult troubleshooting)
|
||||
|
||||
**Medium Risk:**
|
||||
- No staging environment (production testing)
|
||||
- No rollback strategy (recovery delays)
|
||||
- No backup documentation (data loss)
|
||||
|
||||
**Low Risk:**
|
||||
- Docker deployment (well-understood)
|
||||
- Resource limits (prevents runaway usage)
|
||||
|
||||
### Business Risks
|
||||
|
||||
**High Risk:**
|
||||
- GPL-3.0 license (copyleft requirements)
|
||||
- Single maintainer (project abandonment)
|
||||
- No SLA (unpredictable availability)
|
||||
|
||||
**Medium Risk:**
|
||||
- Data staleness (outdated metadata)
|
||||
- Provider coverage (missing providers)
|
||||
|
||||
**Low Risk:**
|
||||
- Technology stack (.NET 8.0 well-supported)
|
||||
- Database choice (PostgreSQL mature)
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Production Use
|
||||
|
||||
**Critical (Must Have):**
|
||||
1. Implement authentication (API keys minimum)
|
||||
2. Add comprehensive tests (unit, integration, API)
|
||||
3. Enable HTTPS (reverse proxy or in-app)
|
||||
4. Implement health checks (`/health`, `/health/ready`)
|
||||
5. Add proper error handling (HTTP status codes)
|
||||
6. Use secrets management (environment variables, vault)
|
||||
|
||||
**Important (Should Have):**
|
||||
7. Add caching layer (Redis)
|
||||
8. Implement rate limiting (per-client quotas)
|
||||
9. Add API versioning (`/api/v1/`)
|
||||
10. Structured logging (Serilog with JSON)
|
||||
11. Remove unused dependencies
|
||||
12. Add monitoring (APM, distributed tracing)
|
||||
|
||||
**Nice to Have:**
|
||||
13. CORS configuration (browser support)
|
||||
14. Pagination metadata (total counts, links)
|
||||
15. Result deduplication (cross-provider)
|
||||
16. Staging environment
|
||||
17. Automated deployment (Kubernetes)
|
||||
|
||||
### For Integration
|
||||
|
||||
**If Using as Reference:**
|
||||
1. Study repository pattern implementation
|
||||
2. Adopt fuzzy search approach (pg_trgm)
|
||||
3. Use parallel query execution pattern
|
||||
4. Learn from database schema design
|
||||
5. Understand provider-specific quirks (helpers)
|
||||
|
||||
**If Forking:**
|
||||
1. Address GPL-3.0 license implications
|
||||
2. Implement all critical recommendations above
|
||||
3. Add comprehensive test suite
|
||||
4. Document architecture and deployment
|
||||
5. Set up staging environment
|
||||
|
||||
**If Building Similar:**
|
||||
1. Use repository-per-provider pattern
|
||||
2. Implement service layer for orchestration
|
||||
3. Use Dapper for read-heavy workloads
|
||||
4. Add fuzzy search with pg_trgm
|
||||
5. Design provider-agnostic entity models
|
||||
6. Include production features from start
|
||||
|
||||
## Scoring Summary
|
||||
|
||||
| Category | Score | Weight | Weighted |
|
||||
|----------|-------|--------|----------|
|
||||
| Architecture | 8/10 | 20% | 1.6 |
|
||||
| Performance | 7/10 | 15% | 1.05 |
|
||||
| Security | 2/10 | 20% | 0.4 |
|
||||
| Testing | 0/10 | 15% | 0.0 |
|
||||
| Observability | 4/10 | 10% | 0.4 |
|
||||
| Production Readiness | 5/10 | 20% | 1.0 |
|
||||
| **Overall** | **4.45/10** | **100%** | **4.45** |
|
||||
|
||||
**Interpretation:**
|
||||
- **Architecture:** Excellent foundation
|
||||
- **Performance:** Good optimizations
|
||||
- **Security:** Critical gaps
|
||||
- **Testing:** Non-existent
|
||||
- **Observability:** Basic metrics only
|
||||
- **Production Readiness:** Needs hardening
|
||||
|
||||
## Final Verdict
|
||||
|
||||
### For Learning and Reference: ⭐⭐⭐⭐⭐ (5/5)
|
||||
|
||||
**Excellent resource for:**
|
||||
- Understanding multi-provider aggregation
|
||||
- Learning repository pattern implementation
|
||||
- Studying database schema design
|
||||
- Seeing fuzzy search in action
|
||||
- Understanding parallel query execution
|
||||
|
||||
### For Production Use: ⭐⭐ (2/5)
|
||||
|
||||
**Requires significant work:**
|
||||
- Add authentication and authorization
|
||||
- Implement comprehensive testing
|
||||
- Harden security (HTTPS, secrets, rate limiting)
|
||||
- Add production observability
|
||||
- Implement caching and health checks
|
||||
|
||||
### For Integration: ⭐⭐⭐ (3/5)
|
||||
|
||||
**Considerations:**
|
||||
- GPL-3.0 license (copyleft)
|
||||
- Schema coupling with MiniMediaScanner
|
||||
- Missing production features
|
||||
- Single maintainer risk
|
||||
|
||||
**Best Approach:** Use as reference, implement independently.
|
||||
|
||||
## Conclusion
|
||||
|
||||
MiniMediaMetadataAPI is a **well-architected prototype** that demonstrates effective multi-provider metadata aggregation. The repository pattern, fuzzy search implementation, and parallel query execution are production-quality. However, critical gaps in security, testing, and production hardening prevent immediate production use.
|
||||
|
||||
**For metadata-aggregator project:** This is the most relevant reference implementation available. Study the architecture, adopt proven patterns, but implement independently to avoid GPL license constraints and include production features from the start.
|
||||
|
||||
**Key Takeaways:**
|
||||
1. Repository-per-provider pattern scales well
|
||||
2. Fuzzy search with pg_trgm is effective
|
||||
3. Parallel execution critical for multi-provider queries
|
||||
4. Provider-agnostic entity models simplify client integration
|
||||
5. Production hardening (auth, tests, caching) non-negotiable
|
||||
|
||||
**Recommended Action:** Deep dive into repository implementations, database schema, and service orchestration. Use as blueprint for architecture, but build production-ready version with authentication, comprehensive tests, caching, and proper observability from day one.
|
||||
@@ -0,0 +1,850 @@
|
||||
# MiniMediaMetadataAPI - Integration Analysis
|
||||
|
||||
## Integration Philosophy
|
||||
|
||||
**Critical Distinction:** This API does NOT integrate with external provider APIs.
|
||||
|
||||
**Data Source:** Pre-populated PostgreSQL database
|
||||
**Sync Responsibility:** MiniMediaScanner (separate project)
|
||||
**API Role:** Query interface only
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
External Providers (Spotify, Tidal, etc.)
|
||||
↓
|
||||
MiniMediaScanner (separate project)
|
||||
↓ (writes)
|
||||
PostgreSQL Database
|
||||
↓ (reads)
|
||||
MiniMediaMetadataAPI (this project)
|
||||
↓
|
||||
API Clients
|
||||
```
|
||||
|
||||
**Separation of Concerns:**
|
||||
- **MiniMediaScanner:** Provider API integration, authentication, rate limiting, data sync
|
||||
- **MiniMediaMetadataAPI:** Database queries, response formatting, API serving
|
||||
|
||||
## Provider Integration Status
|
||||
|
||||
### Spotify
|
||||
|
||||
**Integration Type:** None (data pre-populated)
|
||||
**Dependency:** `SpotifyAPI.Web.Auth 7.4.2` (UNUSED)
|
||||
|
||||
**Why Dependency Exists:**
|
||||
- Likely copied from MiniMediaScanner
|
||||
- Not removed during project split
|
||||
- Dead code / dependency bloat
|
||||
|
||||
**Data Available:**
|
||||
- Artists (with images, genres, popularity, followers)
|
||||
- Albums (with images, UPC, label, copyright)
|
||||
- Tracks (with ISRC, explicit flag, duration)
|
||||
|
||||
**Data Sync:** Handled by MiniMediaScanner via Spotify Web API
|
||||
|
||||
**Authentication:** Not needed in this API (MiniMediaScanner handles OAuth)
|
||||
|
||||
### Tidal
|
||||
|
||||
**Integration Type:** None (data pre-populated)
|
||||
**Dependency:** None
|
||||
|
||||
**Data Available:**
|
||||
- Artists (with image links)
|
||||
- Albums (with UPC, copyright, explicit flag)
|
||||
- Tracks (with ISRC, duration)
|
||||
|
||||
**Data Sync:** Handled by MiniMediaScanner via Tidal API
|
||||
|
||||
**Authentication:** Not needed in this API
|
||||
|
||||
### MusicBrainz
|
||||
|
||||
**Integration Type:** None (data pre-populated)
|
||||
**Dependency:** None
|
||||
|
||||
**Data Available:**
|
||||
- Artists (with sort name, type, country)
|
||||
- Releases (with barcode, status, packaging)
|
||||
- Labels (with hierarchy)
|
||||
- Tracks (with ISRC)
|
||||
|
||||
**Data Sync:** Handled by MiniMediaScanner via MusicBrainz API
|
||||
|
||||
**Authentication:** Not needed (MusicBrainz is open)
|
||||
|
||||
### Deezer
|
||||
|
||||
**Integration Type:** None (data pre-populated)
|
||||
**Dependency:** None
|
||||
|
||||
**Data Available:**
|
||||
- Artists (with image links, fans)
|
||||
- Albums (with genres, fans)
|
||||
- Tracks (with duration, explicit flag)
|
||||
|
||||
**Data Sync:** Handled by MiniMediaScanner via Deezer API
|
||||
|
||||
**Authentication:** Not needed in this API
|
||||
|
||||
### Discogs
|
||||
|
||||
**Integration Type:** None (data pre-populated)
|
||||
**Dependency:** None
|
||||
|
||||
**Data Available:**
|
||||
- Artists (with aliases, real names, profiles)
|
||||
- Releases (with identifiers, genres, styles)
|
||||
- Labels (with hierarchy, contact info)
|
||||
- Tracks (with disc/track numbers)
|
||||
|
||||
**Data Sync:** Handled by MiniMediaScanner via Discogs API
|
||||
|
||||
**Authentication:** Not needed in this API
|
||||
|
||||
### SoundCloud
|
||||
|
||||
**Integration Type:** None (data pre-populated)
|
||||
**Dependency:** None
|
||||
|
||||
**Data Available:**
|
||||
- Users (with avatars, follower counts)
|
||||
- Playlists (with artwork, track counts)
|
||||
- Tracks (with artwork, playback counts, genre)
|
||||
|
||||
**Data Sync:** Handled by MiniMediaScanner via SoundCloud API
|
||||
|
||||
**Authentication:** Not needed in this API
|
||||
|
||||
## Repository Pattern Implementation
|
||||
|
||||
### Interface Design
|
||||
|
||||
Each provider has dedicated repository interface and implementation.
|
||||
|
||||
**Example: ISpotifyRepository**
|
||||
```csharp
|
||||
public interface ISpotifyRepository
|
||||
{
|
||||
Task<List<SearchArtistEntity>> SearchArtist(string name, int offset);
|
||||
Task<SearchArtistEntity> GetArtistById(string id);
|
||||
Task<List<SearchAlbumEntity>> SearchAlbum(string name, string artistId, int offset);
|
||||
Task<SearchAlbumEntity> GetAlbumById(string id);
|
||||
Task<List<SearchTrackEntity>> SearchTrack(string name, string artistId, int offset);
|
||||
Task<SearchTrackEntity> GetTrackById(string id);
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation: SpotifyRepository**
|
||||
```csharp
|
||||
public class SpotifyRepository : ISpotifyRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<SpotifyRepository> _logger;
|
||||
|
||||
public SpotifyRepository(
|
||||
IOptions<DatabaseConfiguration> config,
|
||||
ILogger<SpotifyRepository> logger)
|
||||
{
|
||||
_connectionString = config.Value.ConnectionString;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<SearchArtistEntity>> SearchArtist(string name, int offset)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = new NpgsqlConnection(_connectionString);
|
||||
|
||||
var sql = @"
|
||||
SET LOCAL pg_trgm.similarity_threshold = 0.5;
|
||||
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
a.popularity,
|
||||
a.external_url,
|
||||
a.followers,
|
||||
a.genres,
|
||||
a.last_sync_time,
|
||||
i.url AS image_url,
|
||||
i.height AS image_height,
|
||||
i.width AS image_width
|
||||
FROM spotify_artist a
|
||||
LEFT JOIN spotify_artist_image i ON a.id = i.artist_id
|
||||
WHERE lower(a.name) % lower(@searchTerm)
|
||||
ORDER BY similarity(lower(a.name), lower(@searchTerm)) DESC
|
||||
LIMIT 20 OFFSET @offset;
|
||||
";
|
||||
|
||||
var artistDict = new Dictionary<string, SearchArtistEntity>();
|
||||
|
||||
await connection.QueryAsync<SpotifyArtist, SpotifyArtistImage, SearchArtistEntity>(
|
||||
sql,
|
||||
(artist, image) =>
|
||||
{
|
||||
if (!artistDict.TryGetValue(artist.Id, out var entity))
|
||||
{
|
||||
entity = MapToEntity(artist);
|
||||
artistDict.Add(artist.Id, entity);
|
||||
}
|
||||
|
||||
if (image != null)
|
||||
{
|
||||
entity.Images.Add(MapImageToEntity(image));
|
||||
}
|
||||
|
||||
return entity;
|
||||
},
|
||||
new { searchTerm = name, offset },
|
||||
splitOn: "image_url"
|
||||
);
|
||||
|
||||
return artistDict.Values.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching Spotify artists for term: {SearchTerm}", name);
|
||||
return new List<SearchArtistEntity>();
|
||||
}
|
||||
}
|
||||
|
||||
private SearchArtistEntity MapToEntity(SpotifyArtist artist)
|
||||
{
|
||||
return new SearchArtistEntity
|
||||
{
|
||||
ProviderType = ProviderType.Spotify,
|
||||
Id = artist.Id,
|
||||
Name = artist.Name,
|
||||
Popularity = artist.Popularity,
|
||||
Url = artist.ExternalUrl,
|
||||
TotalFollowers = artist.Followers,
|
||||
Genres = artist.Genres,
|
||||
Images = new List<ArtistImageEntity>(),
|
||||
LastSyncTime = artist.LastSyncTime
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Variations
|
||||
|
||||
**ID Type Differences:**
|
||||
|
||||
| Repository | ID Type | C# Type |
|
||||
|------------|---------|---------|
|
||||
| SpotifyRepository | VARCHAR | string |
|
||||
| TidalRepository | INTEGER | int |
|
||||
| MusicBrainzRepository | UUID | Guid |
|
||||
| DeezerRepository | BIGINT | long |
|
||||
| DiscogsRepository | INTEGER | int |
|
||||
| SoundCloudRepository | BIGINT | long |
|
||||
|
||||
**Interface Adaptation:**
|
||||
```csharp
|
||||
// Spotify
|
||||
Task<SearchArtistEntity> GetArtistById(string id);
|
||||
|
||||
// Tidal
|
||||
Task<SearchArtistEntity> GetArtistById(int id);
|
||||
|
||||
// MusicBrainz
|
||||
Task<SearchArtistEntity> GetArtistById(Guid id);
|
||||
|
||||
// Deezer
|
||||
Task<SearchArtistEntity> GetArtistById(long id);
|
||||
```
|
||||
|
||||
**No Common Interface:** Each repository has provider-specific method signatures.
|
||||
|
||||
### Provider-Specific Logic
|
||||
|
||||
**Discogs Helper:**
|
||||
```csharp
|
||||
public static class DiscogsHelper
|
||||
{
|
||||
public static int GetDiscNumber(string position)
|
||||
{
|
||||
// Discogs stores position as "1-1", "2-3", etc.
|
||||
// Format: "disc-track"
|
||||
if (string.IsNullOrEmpty(position))
|
||||
return 1;
|
||||
|
||||
var parts = position.Split('-');
|
||||
return parts.Length > 0 && int.TryParse(parts[0], out var disc)
|
||||
? disc
|
||||
: 1;
|
||||
}
|
||||
|
||||
public static int GetTrackNumber(string position)
|
||||
{
|
||||
if (string.IsNullOrEmpty(position))
|
||||
return 0;
|
||||
|
||||
var parts = position.Split('-');
|
||||
return parts.Length > 1 && int.TryParse(parts[1], out var track)
|
||||
? track
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in DiscogsRepository:**
|
||||
```csharp
|
||||
var track = new SearchTrackEntity
|
||||
{
|
||||
DiscNumber = DiscogsHelper.GetDiscNumber(dbTrack.Position),
|
||||
TrackNumber = DiscogsHelper.GetTrackNumber(dbTrack.Position)
|
||||
};
|
||||
```
|
||||
|
||||
**MusicBrainz Sort Name:**
|
||||
```csharp
|
||||
// MusicBrainz stores "Beatles, The" for alphabetical sorting
|
||||
var artist = new SearchArtistEntity
|
||||
{
|
||||
Name = dbArtist.Name, // "The Beatles"
|
||||
SortName = dbArtist.SortName // "Beatles, The"
|
||||
};
|
||||
```
|
||||
|
||||
**SoundCloud User vs Artist:**
|
||||
```csharp
|
||||
// SoundCloud has "users" not "artists"
|
||||
var artist = new SearchArtistEntity
|
||||
{
|
||||
Name = dbUser.FullName ?? dbUser.Username,
|
||||
Url = dbUser.Url,
|
||||
TotalFollowers = dbUser.FollowersCount
|
||||
};
|
||||
```
|
||||
|
||||
## Service Layer Orchestration
|
||||
|
||||
### Cross-Provider Aggregation
|
||||
|
||||
**SearchArtistService:**
|
||||
```csharp
|
||||
public class SearchArtistService : ISearchArtistService
|
||||
{
|
||||
private readonly ISpotifyRepository _spotify;
|
||||
private readonly ITidalRepository _tidal;
|
||||
private readonly IMusicBrainzRepository _musicBrainz;
|
||||
private readonly IDeezerRepository _deezer;
|
||||
private readonly IDiscogsRepository _discogs;
|
||||
private readonly ISoundCloudRepository _soundCloud;
|
||||
private readonly ILogger<SearchArtistService> _logger;
|
||||
|
||||
public async Task<SearchArtistResponse> SearchArtist(
|
||||
string name,
|
||||
ProviderType provider,
|
||||
int offset)
|
||||
{
|
||||
if (provider == ProviderType.Any)
|
||||
{
|
||||
return await SearchAllProviders(name, offset);
|
||||
}
|
||||
else
|
||||
{
|
||||
return await SearchSingleProvider(name, provider, offset);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SearchArtistResponse> SearchAllProviders(string name, int offset)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tasks = new[]
|
||||
{
|
||||
_spotify.SearchArtist(name, offset),
|
||||
_tidal.SearchArtist(name, offset),
|
||||
_musicBrainz.SearchArtist(name, offset),
|
||||
_deezer.SearchArtist(name, offset),
|
||||
_discogs.SearchArtist(name, offset),
|
||||
_soundCloud.SearchArtist(name, offset)
|
||||
};
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
var combined = results.SelectMany(r => r).ToList();
|
||||
|
||||
return new SearchArtistResponse
|
||||
{
|
||||
SearchResultType = combined.Any()
|
||||
? SearchResultType.Ok
|
||||
: SearchResultType.NotFound,
|
||||
Artists = combined
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching all providers for artist: {Name}", name);
|
||||
return new SearchArtistResponse
|
||||
{
|
||||
SearchResultType = SearchResultType.NotFound,
|
||||
Artists = new List<SearchArtistEntity>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SearchArtistResponse> SearchSingleProvider(
|
||||
string name,
|
||||
ProviderType provider,
|
||||
int offset)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = provider switch
|
||||
{
|
||||
ProviderType.Spotify => await _spotify.SearchArtist(name, offset),
|
||||
ProviderType.Tidal => await _tidal.SearchArtist(name, offset),
|
||||
ProviderType.MusicBrainz => await _musicBrainz.SearchArtist(name, offset),
|
||||
ProviderType.Deezer => await _deezer.SearchArtist(name, offset),
|
||||
ProviderType.Discogs => await _discogs.SearchArtist(name, offset),
|
||||
ProviderType.SoundCloud => await _soundCloud.SearchArtist(name, offset),
|
||||
_ => new List<SearchArtistEntity>()
|
||||
};
|
||||
|
||||
return new SearchArtistResponse
|
||||
{
|
||||
SearchResultType = results.Any()
|
||||
? SearchResultType.Ok
|
||||
: SearchResultType.NotFound,
|
||||
Artists = results
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching {Provider} for artist: {Name}", provider, name);
|
||||
return new SearchArtistResponse
|
||||
{
|
||||
SearchResultType = SearchResultType.NotFound,
|
||||
Artists = new List<SearchArtistEntity>()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel Execution:**
|
||||
- `Task.WhenAll()` runs all 6 provider queries simultaneously
|
||||
- Total query time = slowest provider (not sum of all)
|
||||
- Typical: 20-50ms for all providers (with indexes)
|
||||
|
||||
**No Result Deduplication:**
|
||||
- Same artist from multiple providers returned multiple times
|
||||
- Each result has `ProviderType` field to distinguish
|
||||
- Client responsible for deduplication if needed
|
||||
|
||||
**Error Handling:**
|
||||
- Individual provider failures don't fail entire request
|
||||
- Empty list returned for failed providers
|
||||
- Logged but not exposed to client
|
||||
|
||||
## Helper Utilities
|
||||
|
||||
### StringHelper
|
||||
|
||||
**File:** `Helpers/StringHelper.cs`
|
||||
|
||||
**Methods:**
|
||||
|
||||
#### RemoveControlChars
|
||||
```csharp
|
||||
public static string RemoveControlChars(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F-0x9F)
|
||||
return Regex.Replace(input, @"[\x00-\x1F\x7F-\x9F]", string.Empty);
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** Sanitize user input before database queries
|
||||
|
||||
**Protects Against:**
|
||||
- Null byte injection
|
||||
- Terminal escape sequences
|
||||
- Control character exploits
|
||||
|
||||
#### RemoveEmojis
|
||||
```csharp
|
||||
public static string RemoveEmojis(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
// Remove surrogate pairs (emojis)
|
||||
return Regex.Replace(input, @"\p{Cs}", string.Empty);
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** Clean provider data before storage (in MiniMediaScanner)
|
||||
|
||||
**Not Used in API:** Data already cleaned during sync
|
||||
|
||||
### DiscogsHelper
|
||||
|
||||
**File:** `Helpers/DiscogsHelper.cs`
|
||||
|
||||
**Purpose:** Parse Discogs-specific position format
|
||||
|
||||
**Methods:**
|
||||
|
||||
#### GetDiscNumber
|
||||
```csharp
|
||||
public static int GetDiscNumber(string position)
|
||||
{
|
||||
// Input: "2-5" (disc 2, track 5)
|
||||
// Output: 2
|
||||
|
||||
if (string.IsNullOrEmpty(position))
|
||||
return 1;
|
||||
|
||||
var parts = position.Split('-');
|
||||
return parts.Length > 0 && int.TryParse(parts[0], out var disc)
|
||||
? disc
|
||||
: 1;
|
||||
}
|
||||
```
|
||||
|
||||
#### GetTrackNumber
|
||||
```csharp
|
||||
public static int GetTrackNumber(string position)
|
||||
{
|
||||
// Input: "2-5" (disc 2, track 5)
|
||||
// Output: 5
|
||||
|
||||
if (string.IsNullOrEmpty(position))
|
||||
return 0;
|
||||
|
||||
var parts = position.Split('-');
|
||||
return parts.Length > 1 && int.TryParse(parts[1], out var track)
|
||||
? track
|
||||
: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**Discogs Position Formats:**
|
||||
- `"1-1"` - Disc 1, Track 1
|
||||
- `"2-5"` - Disc 2, Track 5
|
||||
- `"A1"` - Vinyl side A, track 1 (not handled)
|
||||
- `"DVD1"` - DVD disc (not handled)
|
||||
|
||||
**Limitations:** Only handles numeric disc-track format.
|
||||
|
||||
## Job Repository
|
||||
|
||||
**File:** `Repositories/JobRepository.cs`
|
||||
|
||||
**Purpose:** Track background sync jobs (unused in current implementation)
|
||||
|
||||
**Interface:**
|
||||
```csharp
|
||||
public interface IJobRepository
|
||||
{
|
||||
Task<Job> GetJobById(int id);
|
||||
Task<List<Job>> GetPendingJobs();
|
||||
Task CreateJob(Job job);
|
||||
Task UpdateJobStatus(int id, JobStatus status);
|
||||
}
|
||||
```
|
||||
|
||||
**Job Model:**
|
||||
```csharp
|
||||
public class Job
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public ProviderType Provider { get; set; }
|
||||
public JobType Type { get; set; } // ArtistSync, AlbumSync, TrackSync
|
||||
public JobStatus Status { get; set; } // Pending, InProgress, Completed, Failed
|
||||
public string EntityId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Current Status:** Registered in DI but never used.
|
||||
|
||||
**Intended Use:** Track sync requests from API to MiniMediaScanner (not implemented).
|
||||
|
||||
**SearchResultType.InQueueSync:** Enum value exists but never returned.
|
||||
|
||||
## Quartz Scheduler Integration
|
||||
|
||||
**Dependency:** Quartz 3.17.0
|
||||
|
||||
**Configuration:** Registered in DI
|
||||
|
||||
**Jobs Defined:** None
|
||||
|
||||
**Current Status:** Dead code
|
||||
|
||||
**Intended Use:** Scheduled background tasks (speculation):
|
||||
- Periodic sync triggers
|
||||
- Stale data cleanup
|
||||
- Metrics aggregation
|
||||
|
||||
**Recommendation:** Remove dependency if not used.
|
||||
|
||||
## Polly Resilience Integration
|
||||
|
||||
**Dependency:** Polly 8.6.6
|
||||
|
||||
**Configuration:** Registered in DI
|
||||
|
||||
**Policies Defined:** None
|
||||
|
||||
**Current Status:** Dead code
|
||||
|
||||
**Intended Use:** Retry policies for database queries (speculation):
|
||||
```csharp
|
||||
// NOT IMPLEMENTED
|
||||
var retryPolicy = Policy
|
||||
.Handle<NpgsqlException>()
|
||||
.WaitAndRetryAsync(3, retryAttempt =>
|
||||
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
|
||||
|
||||
await retryPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
return await connection.QueryAsync<SpotifyArtist>(sql, parameters);
|
||||
});
|
||||
```
|
||||
|
||||
**Recommendation:** Implement retry policies or remove dependency.
|
||||
|
||||
## FuzzySharp Integration
|
||||
|
||||
**Dependency:** FuzzySharp 2.0.2
|
||||
|
||||
**Purpose:** String similarity matching (alternative to pg_trgm)
|
||||
|
||||
**Current Status:** Registered but not used
|
||||
|
||||
**Intended Use:** Client-side fuzzy matching (speculation):
|
||||
```csharp
|
||||
// NOT IMPLEMENTED
|
||||
var results = await _spotify.SearchArtist(name, offset);
|
||||
var scored = results.Select(r => new
|
||||
{
|
||||
Artist = r,
|
||||
Score = Fuzz.Ratio(name.ToLower(), r.Name.ToLower())
|
||||
});
|
||||
var filtered = scored.Where(s => s.Score >= 70).OrderByDescending(s => s.Score);
|
||||
```
|
||||
|
||||
**Why Not Used:** pg_trgm handles fuzzy search in database (more efficient).
|
||||
|
||||
**Recommendation:** Remove dependency if not needed.
|
||||
|
||||
## Prometheus Integration
|
||||
|
||||
**Dependency:** prometheus-net 8.2.1
|
||||
|
||||
**Metrics Exposed:**
|
||||
|
||||
### minimediametadataapi_request_total
|
||||
|
||||
**Type:** Counter
|
||||
**Labels:** path, method, status
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
public class RequestMiddleware
|
||||
{
|
||||
private static readonly Counter RequestCounter = Metrics
|
||||
.CreateCounter(
|
||||
"minimediametadataapi_request_total",
|
||||
"Total HTTP requests",
|
||||
new CounterConfiguration
|
||||
{
|
||||
LabelNames = new[] { "path", "method", "status" }
|
||||
});
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
await next(context);
|
||||
|
||||
RequestCounter
|
||||
.WithLabels(
|
||||
context.Request.Path,
|
||||
context.Request.Method,
|
||||
context.Response.StatusCode.ToString())
|
||||
.Inc();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Endpoint:** `/metrics`
|
||||
|
||||
**Format:** Prometheus text exposition
|
||||
|
||||
**Missing Metrics:**
|
||||
- Request duration histogram
|
||||
- Database query duration
|
||||
- Error rate by provider
|
||||
- Active requests gauge
|
||||
- Connection pool usage
|
||||
|
||||
## Swagger Integration
|
||||
|
||||
**Dependency:** Swashbuckle.AspNetCore 10.1.7
|
||||
|
||||
**Configuration:**
|
||||
```csharp
|
||||
builder.Services.AddSwaggerGen();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
```
|
||||
|
||||
**Endpoint:** `/swagger`
|
||||
|
||||
**Features:**
|
||||
- Auto-generated from controller attributes
|
||||
- Interactive API testing
|
||||
- Request/response schema documentation
|
||||
- Enum value descriptions
|
||||
|
||||
**Customization:** None (default configuration)
|
||||
|
||||
**Production Access:** Enabled (no environment check)
|
||||
|
||||
## Database Connection Management
|
||||
|
||||
**Pattern:** Connection-per-request
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.QueryAsync<T>(sql, parameters);
|
||||
// Connection automatically disposed and returned to pool
|
||||
```
|
||||
|
||||
**No DbContext:** Each repository method creates own connection.
|
||||
|
||||
**No Transactions:** Read-only queries don't need transactions.
|
||||
|
||||
**Connection Pooling:** Handled by Npgsql driver (configured in connection string).
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
**Repository Level:**
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
// Database query
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error message with context");
|
||||
return new List<T>(); // Empty result
|
||||
}
|
||||
```
|
||||
|
||||
**Service Level:**
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
// Orchestrate repositories
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error message with context");
|
||||
return new Response
|
||||
{
|
||||
SearchResultType = SearchResultType.NotFound,
|
||||
Results = new List<T>()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Controller Level:**
|
||||
```csharp
|
||||
// No try-catch - relies on ASP.NET Core default error handling
|
||||
var response = await _service.SearchArtist(name, provider, offset);
|
||||
return Ok(response);
|
||||
```
|
||||
|
||||
**Implications:**
|
||||
- Errors logged but not exposed to client
|
||||
- Client can't distinguish between "no results" and "error"
|
||||
- No retry logic
|
||||
- No circuit breaker pattern
|
||||
|
||||
## Integration Recommendations
|
||||
|
||||
### For Production Use
|
||||
|
||||
1. **Implement Retry Policies (Polly):**
|
||||
```csharp
|
||||
builder.Services.AddHttpClient<ISpotifyRepository, SpotifyRepository>()
|
||||
.AddTransientHttpErrorPolicy(policy =>
|
||||
policy.WaitAndRetryAsync(3, retryAttempt =>
|
||||
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))));
|
||||
```
|
||||
|
||||
2. **Add Circuit Breaker:**
|
||||
```csharp
|
||||
.AddTransientHttpErrorPolicy(policy =>
|
||||
policy.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
|
||||
```
|
||||
|
||||
3. **Implement Health Checks:**
|
||||
```csharp
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(_connectionString)
|
||||
.AddCheck<SpotifyRepositoryHealthCheck>("spotify_repository");
|
||||
```
|
||||
|
||||
4. **Add Result Caching:**
|
||||
```csharp
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddDistributedRedisCache(options =>
|
||||
{
|
||||
options.Configuration = "localhost:6379";
|
||||
});
|
||||
```
|
||||
|
||||
5. **Implement Request Deduplication:**
|
||||
```csharp
|
||||
// Combine results from multiple providers, remove duplicates by name similarity
|
||||
var deduplicated = DeduplicateArtists(combined, similarityThreshold: 0.9);
|
||||
```
|
||||
|
||||
### For Integration with MiniMediaScanner
|
||||
|
||||
**Potential Enhancements:**
|
||||
|
||||
1. **Sync Triggering:** API could request sync for missing data
|
||||
2. **Job Status Tracking:** Use JobRepository to track sync progress
|
||||
3. **Webhook Notifications:** MiniMediaScanner notifies API of sync completion
|
||||
4. **Shared Message Queue:** RabbitMQ/Kafka for async communication
|
||||
|
||||
**Current Limitation:** No communication channel between projects.
|
||||
|
||||
## Integration Evaluation
|
||||
|
||||
**Strengths:**
|
||||
- Clean separation from provider APIs
|
||||
- Repository pattern isolates provider logic
|
||||
- Parallel query execution for multi-provider search
|
||||
- Helper utilities for provider-specific quirks
|
||||
|
||||
**Weaknesses:**
|
||||
- Unused dependencies (Polly, Quartz, FuzzySharp, SpotifyAPI.Web.Auth)
|
||||
- No retry logic despite Polly dependency
|
||||
- No caching layer
|
||||
- Error handling swallows failures
|
||||
- No communication with MiniMediaScanner
|
||||
- Job tracking infrastructure unused
|
||||
|
||||
**Recommendations:**
|
||||
- Remove unused dependencies
|
||||
- Implement retry policies
|
||||
- Add caching layer (Redis)
|
||||
- Expose error details to clients
|
||||
- Consider message queue for MiniMediaScanner integration
|
||||
@@ -0,0 +1,275 @@
|
||||
# MiniMediaMetadataAPI - Project Overview
|
||||
|
||||
## Project Identity
|
||||
|
||||
**Name:** MiniMediaMetadataAPI
|
||||
**Repository:** https://github.com/MusicMoveArr/MiniMediaMetadataAPI
|
||||
**License:** GPL-3.0 (copyleft)
|
||||
**Maintainer:** Single maintainer (MusicMoveArr organization)
|
||||
**Status:** Active development
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Runtime & Language
|
||||
- **.NET 8.0** (SDK 8.0.0)
|
||||
- **C#** (modern language features)
|
||||
- **ASP.NET Core** web framework
|
||||
|
||||
### Database Layer
|
||||
- **PostgreSQL** as primary data store
|
||||
- **Dapper 2.1.72** micro-ORM (NOT Entity Framework)
|
||||
- **Npgsql 10.0.2** PostgreSQL driver for .NET
|
||||
- **pg_trgm extension** for fuzzy text search
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Dapper | 2.1.72 | Lightweight ORM, SQL mapping |
|
||||
| Npgsql | 10.0.2 | PostgreSQL connectivity |
|
||||
| FuzzySharp | 2.0.2 | String similarity matching |
|
||||
| Polly | 8.6.6 | Resilience and transient fault handling |
|
||||
| Quartz | 3.17.0 | Job scheduling framework |
|
||||
| SpotifyAPI.Web.Auth | 7.4.2 | Spotify authentication (unused in API) |
|
||||
| prometheus-net | 8.2.1 | Metrics collection and export |
|
||||
| Swashbuckle | 10.1.7 | OpenAPI/Swagger documentation |
|
||||
|
||||
## Provider Coverage
|
||||
|
||||
The API aggregates metadata from **6 music providers**:
|
||||
|
||||
1. **Spotify** - Streaming service with rich metadata
|
||||
2. **Tidal** - High-fidelity streaming platform
|
||||
3. **MusicBrainz** - Open music encyclopedia
|
||||
4. **Deezer** - European streaming service
|
||||
5. **Discogs** - Music database and marketplace
|
||||
6. **SoundCloud** - User-generated content platform
|
||||
|
||||
Each provider has dedicated database models and repository implementations.
|
||||
|
||||
## Solution Structure
|
||||
|
||||
The codebase is organized into **3 projects**:
|
||||
|
||||
### 1. MiniMediaMetadataAPI (Main API)
|
||||
- ASP.NET Core web application
|
||||
- Controllers for HTTP endpoints
|
||||
- Middleware for request processing
|
||||
- Configuration and dependency injection
|
||||
- Entry point: `Program.cs`
|
||||
|
||||
### 2. MiniMediaMetadataAPI.Application (Business Logic)
|
||||
- Repository pattern implementations
|
||||
- Service layer (SearchArtist, SearchAlbum, SearchTrack)
|
||||
- Database models for all 6 providers
|
||||
- Entity models for API responses
|
||||
- Helper utilities
|
||||
|
||||
### 3. MiniMediaMetadataAPI.Tests (Testing)
|
||||
- xUnit test framework
|
||||
- **Current state: Empty stub only (0% coverage)**
|
||||
|
||||
## Dependency Injection Configuration
|
||||
|
||||
`Program.cs` registers the following components:
|
||||
|
||||
### Repositories (7 total)
|
||||
- `ISpotifyRepository` → `SpotifyRepository`
|
||||
- `ITidalRepository` → `TidalRepository`
|
||||
- `IMusicBrainzRepository` → `MusicBrainzRepository`
|
||||
- `IDeezerRepository` → `DeezerRepository`
|
||||
- `IDiscogsRepository` → `DiscogsRepository`
|
||||
- `ISoundCloudRepository` → `SoundCloudRepository`
|
||||
- `IJobRepository` → `JobRepository`
|
||||
|
||||
### Services (3 total)
|
||||
- `ISearchArtistService` → `SearchArtistService`
|
||||
- `ISearchAlbumService` → `SearchAlbumService`
|
||||
- `ISearchTrackService` → `SearchTrackService`
|
||||
|
||||
## Resource Footprint
|
||||
|
||||
**Memory Usage:** <250MB
|
||||
**Connection Pooling:** MinPoolSize=5, MaxPoolSize=100
|
||||
|
||||
This lightweight footprint makes the API suitable for containerized deployments and resource-constrained environments.
|
||||
|
||||
## Database Relationship
|
||||
|
||||
**Critical architectural note:** This API does NOT own the database schema.
|
||||
|
||||
- **Schema Owner:** MiniMediaScanner (separate project)
|
||||
- **API Role:** Read-only consumer
|
||||
- **Data Sync:** Handled entirely by MiniMediaScanner
|
||||
- **No Migrations:** This project contains no database migration code
|
||||
|
||||
The API queries pre-populated tables. Data freshness depends on MiniMediaScanner's sync schedule.
|
||||
|
||||
## Codebase Metrics
|
||||
|
||||
- **Total C# files:** 99
|
||||
- **Database models:** 60+
|
||||
- **Controllers:** 4
|
||||
- **Repositories:** 7
|
||||
- **Services:** 3
|
||||
- **Middleware:** 1 (Prometheus request tracking)
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
### Why Dapper over Entity Framework?
|
||||
- Lightweight, minimal overhead
|
||||
- Direct SQL control for complex queries
|
||||
- Better performance for read-heavy workloads
|
||||
- No change tracking overhead (read-only API)
|
||||
|
||||
### Why Repository Pattern?
|
||||
- Clean separation between data access and business logic
|
||||
- Provider-specific implementations isolated
|
||||
- Easy to mock for testing (though tests are missing)
|
||||
- Consistent interface across all providers
|
||||
|
||||
### Why No Schema Ownership?
|
||||
- Separation of concerns: MiniMediaScanner handles sync complexity
|
||||
- API focuses on query optimization and response formatting
|
||||
- Avoids dual-write problems
|
||||
- Simpler deployment (no migration coordination)
|
||||
|
||||
## Integration Points
|
||||
|
||||
### External Dependencies
|
||||
- PostgreSQL database (shared with MiniMediaScanner)
|
||||
- Prometheus metrics collector (optional)
|
||||
|
||||
### Internal Dependencies
|
||||
- No inter-service communication
|
||||
- No message queues
|
||||
- No caching layer
|
||||
- No external API calls (data pre-populated)
|
||||
|
||||
## Configuration Surface
|
||||
|
||||
Primary configuration via `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"DatabaseConfiguration": {
|
||||
"ConnectionString": "Host=...;Database=...;Username=...;Password=..."
|
||||
},
|
||||
"Prometheus": {
|
||||
"MetricsUrl": "/metrics"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Artifacts
|
||||
|
||||
- **Dockerfile:** Multi-stage build, non-root user, ports 8080/8081
|
||||
- **compose.yaml:** Minimal build configuration
|
||||
- **Production compose:** Port mapping (56232:8080), memory limit (256M), volume mount for config
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
**GitHub Actions:** `docker-image.yml`
|
||||
|
||||
- **Trigger:** Push to main branch
|
||||
- **Steps:** Build Docker image → Push to Docker Hub
|
||||
- **Missing:** Test execution, deployment automation, health checks
|
||||
|
||||
## API Surface
|
||||
|
||||
**Base Path:** `/api`
|
||||
**Documentation:** `/swagger` (Swagger UI)
|
||||
**Metrics:** `/metrics` (Prometheus format)
|
||||
|
||||
### Endpoints
|
||||
- `GET /api/SearchArtist` - Search artists across providers
|
||||
- `GET /api/SearchAlbum` - Search albums across providers
|
||||
- `GET /api/SearchTrack` - Search tracks across providers
|
||||
- `GET /api/Search` - Stub endpoint (not implemented)
|
||||
|
||||
## Security Posture
|
||||
|
||||
**Authentication:** None (fully open API)
|
||||
**Authorization:** None
|
||||
**Rate Limiting:** None
|
||||
**CORS:** Not configured
|
||||
**HTTPS:** Commented out in production
|
||||
|
||||
This is a **trust-based deployment** suitable only for internal networks or behind authentication gateway.
|
||||
|
||||
## Observability
|
||||
|
||||
**Metrics:** Prometheus request counters (path, method, status labels)
|
||||
**Logging:** ASP.NET Core default (console output)
|
||||
**Tracing:** None
|
||||
**Health Checks:** None
|
||||
**Error Tracking:** None (no Sentry, no structured logging)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Current State:** No meaningful tests
|
||||
**Test Framework:** xUnit configured but unused
|
||||
**Coverage:** 0%
|
||||
**CI Integration:** Tests not run in pipeline
|
||||
|
||||
This is a significant gap for production readiness.
|
||||
|
||||
## License Implications
|
||||
|
||||
**GPL-3.0** is a copyleft license requiring:
|
||||
- Source code disclosure for derivative works
|
||||
- Same license for modifications
|
||||
- Patent grant to users
|
||||
|
||||
**Impact on integration:**
|
||||
- Cannot incorporate code into proprietary systems without GPL compliance
|
||||
- Can use as separate service (API boundary preserves license isolation)
|
||||
- Database schema and API patterns can inspire clean-room implementations
|
||||
|
||||
## Relevance to metadata-aggregator Project
|
||||
|
||||
**High relevance** - this is the closest existing implementation to our goals:
|
||||
|
||||
1. **Multi-provider aggregation** - exactly our use case
|
||||
2. **Unified search API** - provider-agnostic queries
|
||||
3. **Database schema design** - proven model for multi-provider storage
|
||||
4. **Provider isolation** - clean separation via repository pattern
|
||||
5. **Fuzzy search** - pg_trgm implementation reference
|
||||
|
||||
**Key learnings:**
|
||||
- Repository-per-provider scales well
|
||||
- Dapper performs well for read-heavy metadata queries
|
||||
- Separate sync process (MiniMediaScanner) simplifies API
|
||||
- Provider=Any pattern enables cross-provider search
|
||||
|
||||
**Gaps to address:**
|
||||
- Add comprehensive testing
|
||||
- Implement authentication/authorization
|
||||
- Add caching layer for performance
|
||||
- Health checks for production readiness
|
||||
- API versioning for evolution
|
||||
- Rate limiting for abuse prevention
|
||||
|
||||
## Project Maturity Assessment
|
||||
|
||||
**Strengths:**
|
||||
- Clean architecture
|
||||
- Multiple providers working
|
||||
- Lightweight and performant
|
||||
- Good separation of concerns
|
||||
|
||||
**Weaknesses:**
|
||||
- Single maintainer risk
|
||||
- No test coverage
|
||||
- Missing production hardening (auth, rate limiting, health checks)
|
||||
- Schema coupling with external project
|
||||
- Limited observability
|
||||
|
||||
**Maturity Level:** Early production / Advanced prototype
|
||||
|
||||
Suitable for internal use or as reference implementation. Needs hardening for public deployment.
|
||||
Reference in New Issue
Block a user