a1f6701bac
- gRPC service with MusicBrainz provider - PostgreSQL schema with migrations - Service layer with database-first caching - Repository pattern for data access - YAML configuration support - Research documentation for 17 music metadata projects
1295 lines
41 KiB
Markdown
1295 lines
41 KiB
Markdown
# Melodee: Architecture Analysis
|
|
|
|
## System Architecture Overview
|
|
|
|
Melodee implements a layered architecture built on .NET 10, combining Blazor Server for the UI layer, Entity Framework Core for data access, and Quartz.NET for background processing. The system processes music files through a multi-stage pipeline, aggregates metadata from six external providers, and exposes three distinct API protocols for client compatibility.
|
|
|
|
The architecture balances several competing concerns:
|
|
- **Performance**: Background jobs, caching, and optimized queries
|
|
- **Compatibility**: Multiple API protocols and client support
|
|
- **Reliability**: Health checks, structured logging, and error handling
|
|
- **Extensibility**: Provider abstraction, plugin-ready design
|
|
- **Maintainability**: Clear separation of concerns, dependency injection
|
|
|
|
## High-Level Component Diagram
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Client Layer │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ Blazor UI │ Subsonic Clients │ Jellyfin Clients │ REST │
|
|
└──────┬──────┴──────────┬──────────┴──────────┬─────────┴────┬───┘
|
|
│ │ │ │
|
|
└─────────────────┴─────────────────────┴──────────────┘
|
|
│
|
|
┌────────────▼────────────┐
|
|
│ API Gateway Layer │
|
|
│ - Rate Limiting │
|
|
│ - Authentication │
|
|
│ - Protocol Routing │
|
|
└────────────┬────────────┘
|
|
│
|
|
┌─────────────────────────┼─────────────────────────┐
|
|
│ │ │
|
|
┌──────▼──────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
|
│ Blazor │ │ REST API │ │ OpenSubsonic │
|
|
│ Server │ │ /api/v1/ │ │ Jellyfin APIs │
|
|
│ Pages │ │ (JWT) │ │ /rest/ /api/jf/│
|
|
└──────┬──────┘ └────────┬────────┘ └────────┬────────┘
|
|
│ │ │
|
|
└─────────────────────────┼────────────────────────┘
|
|
│
|
|
┌────────────▼────────────┐
|
|
│ Business Logic Layer │
|
|
│ - Library Management │
|
|
│ - Metadata Aggregation │
|
|
│ - User Management │
|
|
│ - Playlist Logic │
|
|
│ - Scrobbling │
|
|
└────────────┬────────────┘
|
|
│
|
|
┌─────────────────────────┼─────────────────────────┐
|
|
│ │ │
|
|
┌──────▼──────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
|
│ Quartz.NET │ │ EF Core 10 │ │ File System │
|
|
│ Background │ │ PostgreSQL 17 │ │ Audio Files │
|
|
│ Jobs (17) │ │ SQLite Cache │ │ Album Art │
|
|
└──────┬──────┘ └────────┬────────┘ └────────┬────────┘
|
|
│ │ │
|
|
└─────────────────────────┼────────────────────────┘
|
|
│
|
|
┌────────────▼────────────┐
|
|
│ External Integrations │
|
|
│ - MusicBrainz (SQLite) │
|
|
│ - Last.fm │
|
|
│ - Spotify │
|
|
│ - iTunes, Deezer │
|
|
│ - Brave Search │
|
|
└─────────────────────────┘
|
|
```
|
|
|
|
## Layer Breakdown
|
|
|
|
### Presentation Layer
|
|
|
|
#### Blazor Server UI
|
|
|
|
Blazor Server renders UI components on the server and synchronizes DOM updates via SignalR. Each user session maintains a persistent WebSocket connection.
|
|
|
|
**Component Structure**:
|
|
- **Pages**: Routable components (`/library`, `/albums`, `/artists`, `/playlists`)
|
|
- **Shared Components**: Reusable UI elements (player controls, album cards, search bars)
|
|
- **Layouts**: Master page templates with navigation and footer
|
|
- **Services**: Client-side state management and API calls
|
|
|
|
**Radzen Blazor Components**:
|
|
Melodee uses Radzen for pre-built UI components:
|
|
- `RadzenDataGrid`: Sortable, filterable tables for library browsing
|
|
- `RadzenChart`: Visualizations for listening statistics
|
|
- `RadzenDialog`: Modal dialogs for confirmations and forms
|
|
- `RadzenNotification`: Toast notifications for user feedback
|
|
|
|
Radzen reduces custom component development but creates a dependency on third-party component library updates and licensing (Radzen is MIT-licensed).
|
|
|
|
**SignalR for Party Mode**:
|
|
Party Mode uses SignalR groups to broadcast playback state:
|
|
|
|
```csharp
|
|
// Simplified Party Mode hub
|
|
public class PartyModeHub : Hub
|
|
{
|
|
public async Task JoinParty(string partyId)
|
|
{
|
|
await Groups.AddToGroupAsync(Context.ConnectionId, partyId);
|
|
}
|
|
|
|
public async Task BroadcastPlaybackState(string partyId, PlaybackState state)
|
|
{
|
|
await Clients.Group(partyId).SendAsync("UpdatePlayback", state);
|
|
}
|
|
}
|
|
```
|
|
|
|
All clients in the party group receive playback updates in real-time. The Blazor Server architecture makes this natural since the SignalR connection already exists for UI updates.
|
|
|
|
**State Management**:
|
|
Blazor Server uses scoped services for per-user state:
|
|
- `PlayerState`: Current track, position, volume, queue
|
|
- `LibraryState`: Selected filters, sort order, pagination
|
|
- `UserPreferences`: Theme, language, playback settings
|
|
|
|
Scoped services live for the duration of the SignalR connection. When the connection drops, state is lost unless persisted to the database or browser storage.
|
|
|
|
#### API Layer
|
|
|
|
Three distinct API protocols serve different client ecosystems:
|
|
|
|
**1. Native REST API (`/api/v1/`)**
|
|
|
|
Modern RESTful design with JWT authentication:
|
|
|
|
```
|
|
GET /api/v1/albums # List albums
|
|
GET /api/v1/albums/{id} # Get album details
|
|
POST /api/v1/playlists # Create playlist
|
|
PUT /api/v1/playlists/{id} # Update playlist
|
|
DELETE /api/v1/playlists/{id} # Delete playlist
|
|
GET /api/v1/stream/{trackId} # Stream audio
|
|
POST /api/v1/scrobble # Submit scrobble
|
|
GET /api/v1/search?q=query # Search library
|
|
```
|
|
|
|
JWT tokens contain claims:
|
|
- `sub`: User ID
|
|
- `email`: User email
|
|
- `role`: User role (admin, user)
|
|
- `exp`: Expiration timestamp
|
|
|
|
Rate limit: 30 requests per 30 seconds per user.
|
|
|
|
**2. OpenSubsonic API (`/rest/`)**
|
|
|
|
Subsonic-compatible API using token and salt authentication:
|
|
|
|
```
|
|
GET /rest/ping.view?u=user&t=token&s=salt&v=1.16.1&c=client
|
|
GET /rest/getAlbumList2.view?type=recent&size=20
|
|
GET /rest/stream.view?id=123
|
|
GET /rest/scrobble.view?id=123&submission=true
|
|
```
|
|
|
|
Authentication flow:
|
|
1. Client generates random salt
|
|
2. Client computes token = MD5(password + salt)
|
|
3. Client sends username, token, and salt
|
|
4. Server recomputes token and compares
|
|
|
|
This avoids sending passwords in plaintext but requires HTTPS to prevent token replay attacks.
|
|
|
|
Rate limit: Inherits from native API (30/30s).
|
|
|
|
**3. Jellyfin API (`/api/jf/`)**
|
|
|
|
Jellyfin-compatible endpoints for Jellyfin clients:
|
|
|
|
```
|
|
POST /api/jf/Users/AuthenticateByName
|
|
GET /api/jf/Items
|
|
GET /api/jf/Audio/{id}/stream
|
|
POST /api/jf/PlayingItems/{id}/Progress
|
|
```
|
|
|
|
Custom token authentication using `X-Emby-Token` header (Jellyfin inherited Emby's authentication scheme).
|
|
|
|
Rate limit: 200 requests per 60 seconds (higher due to frequent progress updates).
|
|
|
|
**API Documentation with Scalar**:
|
|
Scalar generates interactive API documentation from OpenAPI specifications. Developers can test endpoints directly in the browser, view request/response schemas, and explore authentication flows.
|
|
|
|
### Business Logic Layer
|
|
|
|
#### Library Management
|
|
|
|
Core domain logic for organizing music files:
|
|
|
|
**Album Entity**:
|
|
```csharp
|
|
public class Album
|
|
{
|
|
public int Id { get; set; }
|
|
public string Title { get; set; }
|
|
public int ArtistId { get; set; }
|
|
public Artist Artist { get; set; }
|
|
public DateTime ReleaseDate { get; set; }
|
|
public string Genre { get; set; }
|
|
public List<Track> Tracks { get; set; }
|
|
public string CoverArtPath { get; set; }
|
|
public Dictionary<string, string> ExternalIds { get; set; } // MusicBrainz, Spotify, etc.
|
|
}
|
|
```
|
|
|
|
**Library Service**:
|
|
```csharp
|
|
public interface ILibraryService
|
|
{
|
|
Task<Album> GetAlbumAsync(int id);
|
|
Task<List<Album>> SearchAlbumsAsync(string query);
|
|
Task<List<Album>> GetRecentAlbumsAsync(int count);
|
|
Task<List<Album>> GetAlbumsByArtistAsync(int artistId);
|
|
Task UpdateAlbumMetadataAsync(int albumId);
|
|
}
|
|
```
|
|
|
|
The service layer abstracts data access and business rules. Controllers and Blazor pages depend on `ILibraryService`, not concrete implementations. This enables testing with mock services and swapping implementations without changing consumers.
|
|
|
|
#### Metadata Aggregation
|
|
|
|
Six providers contribute metadata through a unified abstraction:
|
|
|
|
```csharp
|
|
public interface IMetadataProvider
|
|
{
|
|
string Name { get; }
|
|
int Priority { get; }
|
|
Task<AlbumMetadata> GetAlbumMetadataAsync(string artist, string album);
|
|
Task<ArtistMetadata> GetArtistMetadataAsync(string artist);
|
|
Task<TrackMetadata> GetTrackMetadataAsync(string artist, string track);
|
|
}
|
|
```
|
|
|
|
**Provider Priority**:
|
|
1. MusicBrainz (priority 100): Authoritative music database
|
|
2. Spotify (priority 80): Commercial metadata, album art
|
|
3. Last.fm (priority 70): Social metadata, tags
|
|
4. iTunes (priority 60): Commercial metadata
|
|
5. Deezer (priority 50): European market data
|
|
6. Brave Search (priority 10): Fallback web search
|
|
|
|
**Aggregation Strategy**:
|
|
```csharp
|
|
public class MetadataAggregator
|
|
{
|
|
private readonly List<IMetadataProvider> _providers;
|
|
|
|
public async Task<AlbumMetadata> AggregateAlbumMetadataAsync(string artist, string album)
|
|
{
|
|
var results = new List<AlbumMetadata>();
|
|
|
|
foreach (var provider in _providers.OrderByDescending(p => p.Priority))
|
|
{
|
|
try
|
|
{
|
|
var metadata = await provider.GetAlbumMetadataAsync(artist, album);
|
|
if (metadata != null)
|
|
results.Add(metadata);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Provider {Provider} failed", provider.Name);
|
|
}
|
|
}
|
|
|
|
return MergeMetadata(results);
|
|
}
|
|
|
|
private AlbumMetadata MergeMetadata(List<AlbumMetadata> results)
|
|
{
|
|
// Merge logic: prefer higher-priority providers for conflicts
|
|
// Combine tags, genres, and external IDs from all providers
|
|
}
|
|
}
|
|
```
|
|
|
|
**MusicBrainz Cache**:
|
|
Local SQLite database mirrors MusicBrainz data:
|
|
|
|
```sql
|
|
CREATE TABLE mb_release (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
artist_credit TEXT,
|
|
release_date TEXT,
|
|
country TEXT,
|
|
barcode TEXT,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE mb_recording (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
artist_credit TEXT,
|
|
length INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
```
|
|
|
|
Monthly updates refresh the cache:
|
|
1. Download MusicBrainz database dump
|
|
2. Parse XML or JSON data
|
|
3. Update SQLite tables
|
|
4. Invalidate stale entries
|
|
|
|
This reduces API calls from thousands per library scan to zero (after initial cache population).
|
|
|
|
#### User Management
|
|
|
|
User accounts, authentication, and authorization:
|
|
|
|
```csharp
|
|
public class User
|
|
{
|
|
public int Id { get; set; }
|
|
public string Email { get; set; }
|
|
public string PasswordHash { get; set; }
|
|
public string Role { get; set; } // "admin" or "user"
|
|
public DateTime CreatedAt { get; set; }
|
|
public DateTime LastLoginAt { get; set; }
|
|
public UserSettings Settings { get; set; }
|
|
public List<Playlist> Playlists { get; set; }
|
|
public List<Scrobble> Scrobbles { get; set; }
|
|
}
|
|
|
|
public class UserSettings
|
|
{
|
|
public string Language { get; set; }
|
|
public string Theme { get; set; }
|
|
public int TranscodeBitrate { get; set; }
|
|
public bool ScrobbleEnabled { get; set; }
|
|
public string LastFmSessionKey { get; set; }
|
|
}
|
|
```
|
|
|
|
**Google OAuth Integration**:
|
|
```csharp
|
|
public class GoogleAuthService
|
|
{
|
|
public async Task<User> AuthenticateWithGoogleAsync(string authorizationCode)
|
|
{
|
|
// Exchange code for access token
|
|
var tokenResponse = await _httpClient.PostAsync(
|
|
"https://oauth2.googleapis.com/token",
|
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["code"] = authorizationCode,
|
|
["client_id"] = _config.GoogleClientId,
|
|
["client_secret"] = _config.GoogleClientSecret,
|
|
["redirect_uri"] = _config.GoogleRedirectUri,
|
|
["grant_type"] = "authorization_code"
|
|
})
|
|
);
|
|
|
|
var token = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>();
|
|
|
|
// Get user profile
|
|
var profileResponse = await _httpClient.GetAsync(
|
|
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={token.AccessToken}"
|
|
);
|
|
|
|
var profile = await profileResponse.Content.ReadFromJsonAsync<GoogleProfile>();
|
|
|
|
// Create or update user
|
|
var user = await _userRepository.GetByEmailAsync(profile.Email);
|
|
if (user == null)
|
|
{
|
|
user = new User
|
|
{
|
|
Email = profile.Email,
|
|
Role = "user",
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
await _userRepository.CreateAsync(user);
|
|
}
|
|
|
|
user.LastLoginAt = DateTime.UtcNow;
|
|
await _userRepository.UpdateAsync(user);
|
|
|
|
return user;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Playlist Logic
|
|
|
|
Playlists support manual curation and smart playlists:
|
|
|
|
```csharp
|
|
public class Playlist
|
|
{
|
|
public int Id { get; set; }
|
|
public string Name { get; set; }
|
|
public int UserId { get; set; }
|
|
public User User { get; set; }
|
|
public bool IsPublic { get; set; }
|
|
public bool IsSmart { get; set; }
|
|
public string SmartQuery { get; set; } // MQL query for smart playlists
|
|
public List<PlaylistTrack> Tracks { get; set; }
|
|
}
|
|
|
|
public class PlaylistTrack
|
|
{
|
|
public int PlaylistId { get; set; }
|
|
public int TrackId { get; set; }
|
|
public int Position { get; set; }
|
|
public DateTime AddedAt { get; set; }
|
|
}
|
|
```
|
|
|
|
**Smart Playlists with MQL**:
|
|
MQL (Melodee Query Language) enables dynamic playlists:
|
|
|
|
```
|
|
// Recently added tracks
|
|
added:>7d
|
|
|
|
// High-rated jazz
|
|
genre:Jazz AND rating:>=4
|
|
|
|
// Frequently played favorites
|
|
playcount:>10 AND favorite:true
|
|
|
|
// Discover new music
|
|
playcount:0 AND added:<30d
|
|
```
|
|
|
|
MQL parser converts queries to LINQ expressions:
|
|
|
|
```csharp
|
|
public class MqlParser
|
|
{
|
|
public Expression<Func<Track, bool>> Parse(string query)
|
|
{
|
|
var tokens = Tokenize(query);
|
|
var ast = BuildAst(tokens);
|
|
return CompileToLinq(ast);
|
|
}
|
|
}
|
|
```
|
|
|
|
This allows database-level filtering without loading all tracks into memory.
|
|
|
|
#### Scrobbling
|
|
|
|
Scrobbling submits play events to Last.fm and internal Melodee scrobbler:
|
|
|
|
```csharp
|
|
public interface IScrobbler
|
|
{
|
|
Task ScrobbleAsync(int userId, int trackId, DateTime playedAt);
|
|
}
|
|
|
|
public class LastFmScrobbler : IScrobbler
|
|
{
|
|
public async Task ScrobbleAsync(int userId, int trackId, DateTime playedAt)
|
|
{
|
|
var user = await _userRepository.GetByIdAsync(userId);
|
|
if (string.IsNullOrEmpty(user.Settings.LastFmSessionKey))
|
|
return;
|
|
|
|
var track = await _trackRepository.GetByIdAsync(trackId);
|
|
|
|
var signature = GenerateSignature(new Dictionary<string, string>
|
|
{
|
|
["method"] = "track.scrobble",
|
|
["artist"] = track.Artist.Name,
|
|
["track"] = track.Title,
|
|
["timestamp"] = ((DateTimeOffset)playedAt).ToUnixTimeSeconds().ToString(),
|
|
["sk"] = user.Settings.LastFmSessionKey
|
|
});
|
|
|
|
await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", ...);
|
|
}
|
|
}
|
|
|
|
public class MelodeeScrobbler : IScrobbler
|
|
{
|
|
public async Task ScrobbleAsync(int userId, int trackId, DateTime playedAt)
|
|
{
|
|
var scrobble = new Scrobble
|
|
{
|
|
UserId = userId,
|
|
TrackId = trackId,
|
|
PlayedAt = playedAt
|
|
};
|
|
|
|
await _scrobbleRepository.CreateAsync(scrobble);
|
|
}
|
|
}
|
|
```
|
|
|
|
Composite scrobbler submits to both:
|
|
|
|
```csharp
|
|
public class CompositeScrobbler : IScrobbler
|
|
{
|
|
private readonly List<IScrobbler> _scribblers;
|
|
|
|
public async Task ScrobbleAsync(int userId, int trackId, DateTime playedAt)
|
|
{
|
|
await Task.WhenAll(_scribblers.Select(s => s.ScrobbleAsync(userId, trackId, playedAt)));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Data Access Layer
|
|
|
|
#### Entity Framework Core 10
|
|
|
|
EF Core provides ORM capabilities with PostgreSQL and SQLite providers.
|
|
|
|
**DbContext**:
|
|
```csharp
|
|
public class MelodeeDbContext : DbContext
|
|
{
|
|
public DbSet<Album> Albums { get; set; }
|
|
public DbSet<Artist> Artists { get; set; }
|
|
public DbSet<Track> Tracks { get; set; }
|
|
public DbSet<User> Users { get; set; }
|
|
public DbSet<Playlist> Playlists { get; set; }
|
|
public DbSet<Scrobble> Scrobbles { get; set; }
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.Entity<Album>()
|
|
.HasOne(a => a.Artist)
|
|
.WithMany(a => a.Albums)
|
|
.HasForeignKey(a => a.ArtistId);
|
|
|
|
modelBuilder.Entity<Track>()
|
|
.HasOne(t => t.Album)
|
|
.WithMany(a => a.Tracks)
|
|
.HasForeignKey(t => t.AlbumId);
|
|
|
|
modelBuilder.Entity<PlaylistTrack>()
|
|
.HasKey(pt => new { pt.PlaylistId, pt.TrackId });
|
|
}
|
|
}
|
|
```
|
|
|
|
**Migration Strategy**:
|
|
100+ migrations suggest iterative schema evolution. Migrations are applied automatically on container startup via `entrypoint.sh`:
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
dotnet ef database update --project Melodee.Data
|
|
dotnet Melodee.Web.dll
|
|
```
|
|
|
|
This ensures the database schema matches the application version. Rollback requires manual intervention (restore database backup, downgrade application version).
|
|
|
|
**Repository Pattern**:
|
|
```csharp
|
|
public interface IRepository<T> where T : class
|
|
{
|
|
Task<T> GetByIdAsync(int id);
|
|
Task<List<T>> GetAllAsync();
|
|
Task<T> CreateAsync(T entity);
|
|
Task UpdateAsync(T entity);
|
|
Task DeleteAsync(int id);
|
|
}
|
|
|
|
public class EfRepository<T> : IRepository<T> where T : class
|
|
{
|
|
private readonly MelodeeDbContext _context;
|
|
private readonly DbSet<T> _dbSet;
|
|
|
|
public EfRepository(MelodeeDbContext context)
|
|
{
|
|
_context = context;
|
|
_dbSet = context.Set<T>();
|
|
}
|
|
|
|
public async Task<T> GetByIdAsync(int id)
|
|
{
|
|
return await _dbSet.FindAsync(id);
|
|
}
|
|
|
|
// ... other methods
|
|
}
|
|
```
|
|
|
|
Repository pattern abstracts EF Core details from business logic. This enables:
|
|
- Unit testing with in-memory repositories
|
|
- Swapping EF Core for Dapper or raw SQL
|
|
- Caching layer insertion without changing consumers
|
|
|
|
#### SQLite MusicBrainz Cache
|
|
|
|
Separate SQLite database for MusicBrainz data:
|
|
|
|
```csharp
|
|
public class MusicBrainzCache
|
|
{
|
|
private readonly SqliteConnection _connection;
|
|
|
|
public async Task<MbRelease> GetReleaseAsync(string mbid)
|
|
{
|
|
using var command = _connection.CreateCommand();
|
|
command.CommandText = "SELECT * FROM mb_release WHERE id = @id";
|
|
command.Parameters.AddWithValue("@id", mbid);
|
|
|
|
using var reader = await command.ExecuteReaderAsync();
|
|
if (await reader.ReadAsync())
|
|
{
|
|
return new MbRelease
|
|
{
|
|
Id = reader.GetString(0),
|
|
Title = reader.GetString(1),
|
|
ArtistCredit = reader.GetString(2),
|
|
ReleaseDate = reader.GetString(3)
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
```
|
|
|
|
Cache invalidation strategy:
|
|
- **Time-based**: Entries older than 30 days are refreshed
|
|
- **Event-based**: Manual library scans trigger cache updates
|
|
- **Full refresh**: Monthly database dump import
|
|
|
|
### Background Processing Layer
|
|
|
|
#### Quartz.NET Job Scheduling
|
|
|
|
17 background jobs handle asynchronous operations:
|
|
|
|
**Job Categories**:
|
|
1. **Metadata Jobs**: Provider synchronization, cache updates
|
|
2. **Library Jobs**: File scanning, metadata enrichment
|
|
3. **Maintenance Jobs**: Database optimization, log rotation
|
|
4. **Scrobble Jobs**: Batch submission to Last.fm
|
|
5. **Statistics Jobs**: Chart calculation, analytics aggregation
|
|
6. **Podcast Jobs**: Feed updates, episode downloads
|
|
|
|
**Job Chaining Example**:
|
|
```csharp
|
|
public class LibraryScanJob : IJob
|
|
{
|
|
public async Task Execute(IJobExecutionContext context)
|
|
{
|
|
// Scan file system for new/changed files
|
|
var changes = await _fileScanner.ScanAsync();
|
|
|
|
if (changes.Any())
|
|
{
|
|
// Trigger metadata enrichment job
|
|
await _scheduler.TriggerJob(new JobKey("MetadataEnrichment"));
|
|
}
|
|
}
|
|
}
|
|
|
|
public class MetadataEnrichmentJob : IJob
|
|
{
|
|
public async Task Execute(IJobExecutionContext context)
|
|
{
|
|
// Enrich metadata for new albums
|
|
var newAlbums = await _albumRepository.GetUnenrichedAsync();
|
|
|
|
foreach (var album in newAlbums)
|
|
{
|
|
await _metadataAggregator.EnrichAlbumAsync(album.Id);
|
|
}
|
|
|
|
// Trigger cache invalidation
|
|
await _scheduler.TriggerJob(new JobKey("CacheInvalidation"));
|
|
}
|
|
}
|
|
|
|
public class CacheInvalidationJob : IJob
|
|
{
|
|
public async Task Execute(IJobExecutionContext context)
|
|
{
|
|
// Invalidate caches
|
|
await _cacheService.InvalidateAsync("albums");
|
|
await _cacheService.InvalidateAsync("artists");
|
|
|
|
// Trigger statistics recalculation
|
|
await _scheduler.TriggerJob(new JobKey("StatisticsCalculation"));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Job Configuration**:
|
|
```csharp
|
|
public class JobConfiguration
|
|
{
|
|
public static void ConfigureJobs(IServiceCollectionQuartzConfigurator quartz)
|
|
{
|
|
// Library scan every 6 hours
|
|
quartz.AddJob<LibraryScanJob>(opts => opts.WithIdentity("LibraryScan"));
|
|
quartz.AddTrigger(opts => opts
|
|
.ForJob("LibraryScan")
|
|
.WithCronSchedule("0 0 */6 * * ?"));
|
|
|
|
// MusicBrainz cache update monthly
|
|
quartz.AddJob<MusicBrainzCacheUpdateJob>(opts => opts.WithIdentity("MbCacheUpdate"));
|
|
quartz.AddTrigger(opts => opts
|
|
.ForJob("MbCacheUpdate")
|
|
.WithCronSchedule("0 0 0 1 * ?")); // First day of month
|
|
|
|
// Statistics calculation daily at 3 AM
|
|
quartz.AddJob<StatisticsCalculationJob>(opts => opts.WithIdentity("StatisticsCalculation"));
|
|
quartz.AddTrigger(opts => opts
|
|
.ForJob("StatisticsCalculation")
|
|
.WithCronSchedule("0 0 3 * * ?"));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Infrastructure Layer
|
|
|
|
#### Audio Processing
|
|
|
|
**FFmpeg Transcoding**:
|
|
```csharp
|
|
public class TranscodingService
|
|
{
|
|
public async Task<Stream> TranscodeAsync(string inputPath, int bitrate, string format)
|
|
{
|
|
var outputPath = Path.GetTempFileName();
|
|
|
|
var process = new Process
|
|
{
|
|
StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "ffmpeg",
|
|
Arguments = $"-i \"{inputPath}\" -b:a {bitrate}k -f {format} \"{outputPath}\"",
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
await process.WaitForExitAsync();
|
|
|
|
if (process.ExitCode != 0)
|
|
{
|
|
var error = await process.StandardError.ReadToEndAsync();
|
|
throw new TranscodingException($"FFmpeg failed: {error}");
|
|
}
|
|
|
|
return File.OpenRead(outputPath);
|
|
}
|
|
}
|
|
```
|
|
|
|
Transcoding happens on-demand during streaming. The service checks user settings for preferred bitrate and format, then transcodes if the source file doesn't match.
|
|
|
|
**ImageSharp Processing**:
|
|
```csharp
|
|
public class ImageService
|
|
{
|
|
public async Task<byte[]> ResizeAlbumArtAsync(string inputPath, int width, int height)
|
|
{
|
|
using var image = await Image.LoadAsync(inputPath);
|
|
|
|
image.Mutate(x => x.Resize(new ResizeOptions
|
|
{
|
|
Size = new Size(width, height),
|
|
Mode = ResizeMode.Crop
|
|
}));
|
|
|
|
using var ms = new MemoryStream();
|
|
await image.SaveAsJpegAsync(ms, new JpegEncoder { Quality = 85 });
|
|
return ms.ToArray();
|
|
}
|
|
}
|
|
```
|
|
|
|
Album art is resized to multiple sizes:
|
|
- **Thumbnail**: 150x150 for grid views
|
|
- **Medium**: 300x300 for detail views
|
|
- **Large**: 600x600 for full-screen player
|
|
- **Original**: Preserved for archival
|
|
|
|
**Audio Tagging**:
|
|
```csharp
|
|
public class TaggingService
|
|
{
|
|
public async Task<AudioMetadata> ReadTagsAsync(string filePath)
|
|
{
|
|
try
|
|
{
|
|
// Try ATL first
|
|
var track = new Track(filePath);
|
|
return new AudioMetadata
|
|
{
|
|
Title = track.Title,
|
|
Artist = track.Artist,
|
|
Album = track.Album,
|
|
Year = track.Year,
|
|
Genre = track.Genre,
|
|
Duration = TimeSpan.FromSeconds(track.Duration)
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
// Fallback to IdSharp for ID3v2 edge cases
|
|
using var file = TagLib.File.Create(filePath);
|
|
return new AudioMetadata
|
|
{
|
|
Title = file.Tag.Title,
|
|
Artist = file.Tag.FirstPerformer,
|
|
Album = file.Tag.Album,
|
|
Year = (int)file.Tag.Year,
|
|
Genre = file.Tag.FirstGenre,
|
|
Duration = file.Properties.Duration
|
|
};
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Rate Limiting
|
|
|
|
```csharp
|
|
public class RateLimitMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly IMemoryCache _cache;
|
|
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
var endpoint = context.GetEndpoint();
|
|
var rateLimitAttribute = endpoint?.Metadata.GetMetadata<RateLimitAttribute>();
|
|
|
|
if (rateLimitAttribute != null)
|
|
{
|
|
var key = $"ratelimit:{context.User.Identity.Name}:{endpoint.DisplayName}";
|
|
var requests = _cache.GetOrCreate(key, entry =>
|
|
{
|
|
entry.AbsoluteExpirationRelativeToNow = rateLimitAttribute.Window;
|
|
return new List<DateTime>();
|
|
});
|
|
|
|
requests.RemoveAll(r => r < DateTime.UtcNow - rateLimitAttribute.Window);
|
|
|
|
if (requests.Count >= rateLimitAttribute.MaxRequests)
|
|
{
|
|
context.Response.StatusCode = 429;
|
|
await context.Response.WriteAsync("Rate limit exceeded");
|
|
return;
|
|
}
|
|
|
|
requests.Add(DateTime.UtcNow);
|
|
}
|
|
|
|
await _next(context);
|
|
}
|
|
}
|
|
|
|
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
|
public class RateLimitAttribute : Attribute
|
|
{
|
|
public int MaxRequests { get; set; }
|
|
public TimeSpan Window { get; set; }
|
|
}
|
|
|
|
// Usage
|
|
[RateLimit(MaxRequests = 30, Window = TimeSpan.FromSeconds(30))]
|
|
public class AlbumsController : ControllerBase
|
|
{
|
|
// ...
|
|
}
|
|
```
|
|
|
|
For distributed deployments, replace `IMemoryCache` with Redis:
|
|
|
|
```csharp
|
|
public class RedisRateLimiter
|
|
{
|
|
private readonly IConnectionMultiplexer _redis;
|
|
|
|
public async Task<bool> AllowRequestAsync(string key, int maxRequests, TimeSpan window)
|
|
{
|
|
var db = _redis.GetDatabase();
|
|
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
var windowStart = now - (long)window.TotalMilliseconds;
|
|
|
|
// Remove old entries
|
|
await db.SortedSetRemoveRangeByScoreAsync(key, 0, windowStart);
|
|
|
|
// Count current requests
|
|
var count = await db.SortedSetLengthAsync(key);
|
|
|
|
if (count >= maxRequests)
|
|
return false;
|
|
|
|
// Add current request
|
|
await db.SortedSetAddAsync(key, now, now);
|
|
await db.KeyExpireAsync(key, window);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Health Checks
|
|
|
|
```csharp
|
|
public class MelodeeHealthCheck : IHealthCheck
|
|
{
|
|
private readonly MelodeeDbContext _dbContext;
|
|
private readonly IMetadataProvider[] _providers;
|
|
|
|
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken)
|
|
{
|
|
var data = new Dictionary<string, object>();
|
|
|
|
// Check database connectivity
|
|
try
|
|
{
|
|
await _dbContext.Database.CanConnectAsync(cancellationToken);
|
|
data["database"] = "healthy";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return HealthCheckResult.Unhealthy("Database unreachable", ex, data);
|
|
}
|
|
|
|
// Check metadata providers
|
|
foreach (var provider in _providers)
|
|
{
|
|
try
|
|
{
|
|
await provider.GetArtistMetadataAsync("test");
|
|
data[$"provider_{provider.Name}"] = "healthy";
|
|
}
|
|
catch
|
|
{
|
|
data[$"provider_{provider.Name}"] = "degraded";
|
|
}
|
|
}
|
|
|
|
// Check disk space
|
|
var drive = new DriveInfo(Path.GetPathRoot(_config.LibraryPath));
|
|
var freeSpacePercent = (double)drive.AvailableFreeSpace / drive.TotalSize * 100;
|
|
data["disk_free_percent"] = freeSpacePercent;
|
|
|
|
if (freeSpacePercent < 10)
|
|
return HealthCheckResult.Degraded("Low disk space", null, data);
|
|
|
|
return HealthCheckResult.Healthy("All systems operational", data);
|
|
}
|
|
}
|
|
```
|
|
|
|
Health check endpoint configuration:
|
|
|
|
```csharp
|
|
app.MapHealthChecks("/health", new HealthCheckOptions
|
|
{
|
|
ResponseWriter = async (context, report) =>
|
|
{
|
|
context.Response.ContentType = "application/json";
|
|
var result = JsonSerializer.Serialize(new
|
|
{
|
|
status = report.Status.ToString(),
|
|
checks = report.Entries.Select(e => new
|
|
{
|
|
name = e.Key,
|
|
status = e.Value.Status.ToString(),
|
|
description = e.Value.Description,
|
|
data = e.Value.Data
|
|
})
|
|
});
|
|
await context.Response.WriteAsync(result);
|
|
}
|
|
});
|
|
```
|
|
|
|
#### Logging with Serilog
|
|
|
|
```csharp
|
|
Log.Logger = new LoggerConfiguration()
|
|
.MinimumLevel.Information()
|
|
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
|
.Enrich.FromLogContext()
|
|
.Enrich.WithProperty("Application", "Melodee")
|
|
.WriteTo.Console(new CompactJsonFormatter())
|
|
.WriteTo.File(
|
|
new CompactJsonFormatter(),
|
|
"/var/log/melodee/log.clef",
|
|
rollingInterval: RollingInterval.Day,
|
|
retainedFileCountLimit: 30
|
|
)
|
|
.CreateLogger();
|
|
```
|
|
|
|
CLEF (Compact Log Event Format) example:
|
|
|
|
```json
|
|
{"@t":"2025-04-28T10:30:00.123Z","@mt":"Album {AlbumId} metadata enriched from {Provider}","AlbumId":42,"Provider":"MusicBrainz","Application":"Melodee"}
|
|
```
|
|
|
|
Structured logging enables queries like "show all metadata enrichment events for album 42" or "count errors by provider".
|
|
|
|
## Deployment Architecture
|
|
|
|
### Docker Multi-Stage Build
|
|
|
|
```dockerfile
|
|
# Build stage
|
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|
WORKDIR /src
|
|
COPY ["Melodee.Web/Melodee.Web.csproj", "Melodee.Web/"]
|
|
COPY ["Melodee.Data/Melodee.Data.csproj", "Melodee.Data/"]
|
|
RUN dotnet restore "Melodee.Web/Melodee.Web.csproj"
|
|
COPY . .
|
|
RUN dotnet build "Melodee.Web/Melodee.Web.csproj" -c Release -o /app/build
|
|
RUN dotnet publish "Melodee.Web/Melodee.Web.csproj" -c Release -o /app/publish
|
|
|
|
# Runtime stage
|
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
|
RUN apt-get update && apt-get install -y ffmpeg
|
|
WORKDIR /app
|
|
COPY --from=build /app/publish .
|
|
COPY entrypoint.sh .
|
|
RUN chmod +x entrypoint.sh
|
|
ENTRYPOINT ["./entrypoint.sh"]
|
|
```
|
|
|
|
Multi-stage build reduces image size by excluding SDK and build artifacts from the runtime image.
|
|
|
|
### Docker Compose
|
|
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
services:
|
|
melodee:
|
|
image: melodee:1.8.0
|
|
ports:
|
|
- "5000:5000"
|
|
environment:
|
|
- ConnectionStrings__DefaultConnection=Host=postgres;Database=melodee;Username=melodee;Password=melodee
|
|
- MusicBrainz__CachePath=/data/mb-cache.db
|
|
- Library__Path=/music
|
|
volumes:
|
|
- music:/music
|
|
- data:/data
|
|
- logs:/var/log/melodee
|
|
- config:/app/config
|
|
depends_on:
|
|
- postgres
|
|
|
|
postgres:
|
|
image: postgres:17
|
|
environment:
|
|
- POSTGRES_DB=melodee
|
|
- POSTGRES_USER=melodee
|
|
- POSTGRES_PASSWORD=melodee
|
|
volumes:
|
|
- postgres-data:/var/lib/postgresql/data
|
|
|
|
volumes:
|
|
music:
|
|
data:
|
|
logs:
|
|
config:
|
|
postgres-data:
|
|
```
|
|
|
|
12 volumes provide persistence:
|
|
- `music`: User's music library
|
|
- `data`: MusicBrainz cache, album art cache
|
|
- `logs`: Application logs
|
|
- `config`: User settings, API keys
|
|
- `postgres-data`: PostgreSQL database files
|
|
|
|
### Entrypoint Script
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
set -e
|
|
|
|
# Run migrations
|
|
dotnet ef database update --project Melodee.Data --no-build
|
|
|
|
# Start application
|
|
exec dotnet Melodee.Web.dll
|
|
```
|
|
|
|
The `exec` command replaces the shell process with the .NET process, ensuring signals (SIGTERM, SIGINT) reach the application for graceful shutdown.
|
|
|
|
## Scalability Considerations
|
|
|
|
### Horizontal Scaling Challenges
|
|
|
|
Blazor Server's persistent SignalR connections complicate horizontal scaling:
|
|
|
|
**Problem**: User A connects to Server 1, then load balancer routes next request to Server 2. Server 2 doesn't have User A's session state.
|
|
|
|
**Solutions**:
|
|
1. **Sticky Sessions**: Load balancer routes all requests from User A to Server 1
|
|
2. **Redis Backplane**: SignalR uses Redis to synchronize state across servers
|
|
3. **Blazor WebAssembly**: Migrate to client-side rendering (eliminates server-side state)
|
|
|
|
**Redis Backplane Configuration**:
|
|
```csharp
|
|
services.AddSignalR()
|
|
.AddStackExchangeRedis(options =>
|
|
{
|
|
options.Configuration.EndPoints.Add("redis:6379");
|
|
});
|
|
```
|
|
|
|
### Database Scaling
|
|
|
|
PostgreSQL 17 supports read replicas for scaling read-heavy workloads:
|
|
|
|
```csharp
|
|
services.AddDbContext<MelodeeDbContext>(options =>
|
|
{
|
|
var connectionString = context.Request.Method == "GET"
|
|
? _config.GetConnectionString("ReadReplica")
|
|
: _config.GetConnectionString("Primary");
|
|
|
|
options.UseNpgsql(connectionString);
|
|
});
|
|
```
|
|
|
|
Write operations go to primary, reads go to replicas. This requires application-level routing or connection pooling middleware.
|
|
|
|
### Caching Strategy
|
|
|
|
Multi-layer caching reduces database load:
|
|
|
|
**Layer 1: In-Memory Cache**
|
|
```csharp
|
|
public class CachedAlbumRepository : IAlbumRepository
|
|
{
|
|
private readonly IAlbumRepository _inner;
|
|
private readonly IMemoryCache _cache;
|
|
|
|
public async Task<Album> GetByIdAsync(int id)
|
|
{
|
|
return await _cache.GetOrCreateAsync($"album:{id}", async entry =>
|
|
{
|
|
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15);
|
|
return await _inner.GetByIdAsync(id);
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
**Layer 2: Redis Cache**
|
|
```csharp
|
|
public class RedisAlbumRepository : IAlbumRepository
|
|
{
|
|
private readonly IAlbumRepository _inner;
|
|
private readonly IConnectionMultiplexer _redis;
|
|
|
|
public async Task<Album> GetByIdAsync(int id)
|
|
{
|
|
var db = _redis.GetDatabase();
|
|
var cached = await db.StringGetAsync($"album:{id}");
|
|
|
|
if (cached.HasValue)
|
|
return JsonSerializer.Deserialize<Album>(cached);
|
|
|
|
var album = await _inner.GetByIdAsync(id);
|
|
await db.StringSetAsync($"album:{id}", JsonSerializer.Serialize(album), TimeSpan.FromHours(1));
|
|
|
|
return album;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Layer 3: CDN for Static Assets**
|
|
Album art, audio files, and static UI assets can be served from a CDN (CloudFlare, AWS CloudFront) to reduce server load and improve global latency.
|
|
|
|
## Security Architecture
|
|
|
|
### Authentication Flow
|
|
|
|
**JWT Authentication**:
|
|
1. User submits credentials to `/api/v1/auth/login`
|
|
2. Server validates credentials
|
|
3. Server generates JWT with claims (user ID, email, role)
|
|
4. Server returns JWT to client
|
|
5. Client includes JWT in `Authorization: Bearer <token>` header
|
|
6. Server validates JWT signature and expiration on each request
|
|
|
|
**Google OAuth Flow**:
|
|
1. User clicks "Sign in with Google"
|
|
2. Redirect to `https://accounts.google.com/o/oauth2/v2/auth`
|
|
3. User consents to permissions
|
|
4. Google redirects to `/api/v1/auth/google/callback?code=...`
|
|
5. Server exchanges code for access token
|
|
6. Server retrieves user profile from Google
|
|
7. Server creates or updates local user account
|
|
8. Server generates JWT for local session
|
|
9. Client receives JWT and uses for subsequent requests
|
|
|
|
### Authorization
|
|
|
|
Role-based access control (RBAC):
|
|
|
|
```csharp
|
|
[Authorize(Roles = "admin")]
|
|
public class AdminController : ControllerBase
|
|
{
|
|
[HttpPost("users")]
|
|
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest request)
|
|
{
|
|
// Only admins can create users
|
|
}
|
|
}
|
|
|
|
[Authorize]
|
|
public class PlaylistsController : ControllerBase
|
|
{
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreatePlaylist([FromBody] CreatePlaylistRequest request)
|
|
{
|
|
// Any authenticated user can create playlists
|
|
}
|
|
|
|
[HttpPut("{id}")]
|
|
public async Task<IActionResult> UpdatePlaylist(int id, [FromBody] UpdatePlaylistRequest request)
|
|
{
|
|
var playlist = await _playlistRepository.GetByIdAsync(id);
|
|
|
|
// Users can only update their own playlists
|
|
if (playlist.UserId != User.GetUserId() && !User.IsInRole("admin"))
|
|
return Forbid();
|
|
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
### Data Protection
|
|
|
|
Sensitive data encryption:
|
|
|
|
```csharp
|
|
public class UserService
|
|
{
|
|
private readonly IDataProtector _protector;
|
|
|
|
public UserService(IDataProtectionProvider provider)
|
|
{
|
|
_protector = provider.CreateProtector("UserSecrets");
|
|
}
|
|
|
|
public async Task SaveLastFmSessionKeyAsync(int userId, string sessionKey)
|
|
{
|
|
var encrypted = _protector.Protect(sessionKey);
|
|
await _userRepository.UpdateSettingsAsync(userId, settings =>
|
|
{
|
|
settings.LastFmSessionKey = encrypted;
|
|
});
|
|
}
|
|
|
|
public async Task<string> GetLastFmSessionKeyAsync(int userId)
|
|
{
|
|
var user = await _userRepository.GetByIdAsync(userId);
|
|
return _protector.Unprotect(user.Settings.LastFmSessionKey);
|
|
}
|
|
}
|
|
```
|
|
|
|
ASP.NET Core Data Protection uses machine-specific keys by default. For distributed deployments, keys must be stored in a shared location (Redis, Azure Key Vault, file share).
|
|
|
|
## Conclusion
|
|
|
|
Melodee's architecture demonstrates thoughtful design decisions balancing performance, compatibility, and maintainability. The multi-protocol API support, six-provider metadata aggregation, and Blazor Server UI create a cohesive system for music library management.
|
|
|
|
Key architectural strengths:
|
|
- **Layered design**: Clear separation between presentation, business logic, and data access
|
|
- **Extensibility**: Provider abstraction, repository pattern, dependency injection
|
|
- **Observability**: Structured logging, health checks, admin UI
|
|
- **Compatibility**: Three API protocols, Raspberry Pi support, Podman compatibility
|
|
|
|
Key architectural challenges:
|
|
- **Blazor Server scaling**: Persistent connections complicate horizontal scaling
|
|
- **Migration complexity**: 100+ migrations require careful upgrade management
|
|
- **Provider dependencies**: Six external APIs create multiple failure points
|
|
|
|
The architecture positions Melodee as a technically sophisticated music server suitable for power users who value metadata quality and extensibility over simplicity.
|