- 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
41 KiB
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 browsingRadzenChart: Visualizations for listening statisticsRadzenDialog: Modal dialogs for confirmations and formsRadzenNotification: 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:
// 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, queueLibraryState: Selected filters, sort order, paginationUserPreferences: 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 IDemail: User emailrole: 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:
- Client generates random salt
- Client computes token = MD5(password + salt)
- Client sends username, token, and salt
- 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:
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:
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:
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:
- MusicBrainz (priority 100): Authoritative music database
- Spotify (priority 80): Commercial metadata, album art
- Last.fm (priority 70): Social metadata, tags
- iTunes (priority 60): Commercial metadata
- Deezer (priority 50): European market data
- Brave Search (priority 10): Fallback web search
Aggregation Strategy:
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:
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:
- Download MusicBrainz database dump
- Parse XML or JSON data
- Update SQLite tables
- 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:
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:
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:
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:
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:
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:
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:
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:
#!/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:
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:
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:
- Metadata Jobs: Provider synchronization, cache updates
- Library Jobs: File scanning, metadata enrichment
- Maintenance Jobs: Database optimization, log rotation
- Scrobble Jobs: Batch submission to Last.fm
- Statistics Jobs: Chart calculation, analytics aggregation
- Podcast Jobs: Feed updates, episode downloads
Job Chaining Example:
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:
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:
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:
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:
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
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:
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
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:
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
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:
{"@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
# 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
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 librarydata: MusicBrainz cache, album art cachelogs: Application logsconfig: User settings, API keyspostgres-data: PostgreSQL database files
Entrypoint Script
#!/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:
- Sticky Sessions: Load balancer routes all requests from User A to Server 1
- Redis Backplane: SignalR uses Redis to synchronize state across servers
- Blazor WebAssembly: Migrate to client-side rendering (eliminates server-side state)
Redis Backplane Configuration:
services.AddSignalR()
.AddStackExchangeRedis(options =>
{
options.Configuration.EndPoints.Add("redis:6379");
});
Database Scaling
PostgreSQL 17 supports read replicas for scaling read-heavy workloads:
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
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
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:
- User submits credentials to
/api/v1/auth/login - Server validates credentials
- Server generates JWT with claims (user ID, email, role)
- Server returns JWT to client
- Client includes JWT in
Authorization: Bearer <token>header - Server validates JWT signature and expiration on each request
Google OAuth Flow:
- User clicks "Sign in with Google"
- Redirect to
https://accounts.google.com/o/oauth2/v2/auth - User consents to permissions
- Google redirects to
/api/v1/auth/google/callback?code=... - Server exchanges code for access token
- Server retrieves user profile from Google
- Server creates or updates local user account
- Server generates JWT for local session
- Client receives JWT and uses for subsequent requests
Authorization
Role-based access control (RBAC):
[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:
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.