# 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 Tracks { get; set; } public string CoverArtPath { get; set; } public Dictionary ExternalIds { get; set; } // MusicBrainz, Spotify, etc. } ``` **Library Service**: ```csharp public interface ILibraryService { Task GetAlbumAsync(int id); Task> SearchAlbumsAsync(string query); Task> GetRecentAlbumsAsync(int count); Task> 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 GetAlbumMetadataAsync(string artist, string album); Task GetArtistMetadataAsync(string artist); Task 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 _providers; public async Task AggregateAlbumMetadataAsync(string artist, string album) { var results = new List(); 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 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 Playlists { get; set; } public List 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 AuthenticateWithGoogleAsync(string authorizationCode) { // Exchange code for access token var tokenResponse = await _httpClient.PostAsync( "https://oauth2.googleapis.com/token", new FormUrlEncodedContent(new Dictionary { ["code"] = authorizationCode, ["client_id"] = _config.GoogleClientId, ["client_secret"] = _config.GoogleClientSecret, ["redirect_uri"] = _config.GoogleRedirectUri, ["grant_type"] = "authorization_code" }) ); var token = await tokenResponse.Content.ReadFromJsonAsync(); // 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(); // 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 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> 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 { ["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 _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 Albums { get; set; } public DbSet Artists { get; set; } public DbSet Tracks { get; set; } public DbSet Users { get; set; } public DbSet Playlists { get; set; } public DbSet Scrobbles { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasOne(a => a.Artist) .WithMany(a => a.Albums) .HasForeignKey(a => a.ArtistId); modelBuilder.Entity() .HasOne(t => t.Album) .WithMany(a => a.Tracks) .HasForeignKey(t => t.AlbumId); modelBuilder.Entity() .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 where T : class { Task GetByIdAsync(int id); Task> GetAllAsync(); Task CreateAsync(T entity); Task UpdateAsync(T entity); Task DeleteAsync(int id); } public class EfRepository : IRepository where T : class { private readonly MelodeeDbContext _context; private readonly DbSet _dbSet; public EfRepository(MelodeeDbContext context) { _context = context; _dbSet = context.Set(); } public async Task 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 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(opts => opts.WithIdentity("LibraryScan")); quartz.AddTrigger(opts => opts .ForJob("LibraryScan") .WithCronSchedule("0 0 */6 * * ?")); // MusicBrainz cache update monthly quartz.AddJob(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(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 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 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 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(); 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(); }); 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 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 CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken) { var data = new Dictionary(); // 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(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 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 GetByIdAsync(int id) { var db = _redis.GetDatabase(); var cached = await db.StringGetAsync($"album:{id}"); if (cached.HasValue) return JsonSerializer.Deserialize(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 ` 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 CreateUser([FromBody] CreateUserRequest request) { // Only admins can create users } } [Authorize] public class PlaylistsController : ControllerBase { [HttpPost] public async Task CreatePlaylist([FromBody] CreatePlaylistRequest request) { // Any authenticated user can create playlists } [HttpPut("{id}")] public async Task 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 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.