- 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
39 KiB
Melodee: Codebase Analysis
Project Structure Overview
Melodee follows a layered architecture with clear separation of concerns across multiple projects. The solution structure reflects .NET best practices with distinct assemblies for web, data access, business logic, and testing.
Estimated Project Structure:
melodee/
├── src/
│ ├── Melodee.Web/ # Blazor Server UI and API controllers
│ ├── Melodee.Core/ # Business logic and domain models
│ ├── Melodee.Data/ # EF Core, repositories, migrations
│ ├── Melodee.Providers/ # Metadata provider implementations
│ └── Melodee.Jobs/ # Quartz.NET background jobs
├── tests/
│ ├── Melodee.Tests.Unit/ # xUnit unit tests
│ ├── Melodee.Tests.Integration/# Integration tests
│ ├── Melodee.Tests.Blazor/ # bUnit component tests
│ └── Melodee.Tests.Performance/# NBomber load tests
├── docker/
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── entrypoint.sh
├── docs/
│ ├── api/ # API documentation
│ └── architecture/ # Architecture diagrams
├── .github/
│ └── workflows/ # CI/CD pipelines
├── biome.json # Biome linter configuration
├── .gitignore
├── LICENSE
└── README.md
Core Projects
Melodee.Web
Responsibilities:
- Blazor Server UI components and pages
- API controllers (Native REST, OpenSubsonic, Jellyfin)
- Authentication and authorization
- SignalR hubs (Party Mode)
- Middleware (rate limiting, error handling)
- Startup configuration
Key Files:
Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddControllers();
// Database
builder.Services.AddDbContext<MelodeeDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
};
})
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Google:ClientId"];
options.ClientSecret = builder.Configuration["Google:ClientSecret"];
});
// Authorization
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
});
// SignalR
builder.Services.AddSignalR();
// Background jobs
builder.Services.AddQuartz(q =>
{
q.UseMicrosoftDependencyInjectionJobFactory();
JobConfiguration.ConfigureJobs(q);
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
// Repositories and services
builder.Services.AddScoped<IAlbumRepository, AlbumRepository>();
builder.Services.AddScoped<ILibraryService, LibraryService>();
builder.Services.AddScoped<IMetadataAggregator, MetadataAggregator>();
// Metadata providers
builder.Services.AddSingleton<MusicBrainzProvider>();
builder.Services.AddSingleton<SpotifyProvider>();
builder.Services.AddSingleton<LastFmProvider>();
builder.Services.AddSingleton<ItunesProvider>();
builder.Services.AddSingleton<DeezerProvider>();
builder.Services.AddSingleton<BraveSearchProvider>();
// Scrobblers
builder.Services.AddScoped<IScrobbler, CompositeScrobbler>();
// Logging
builder.Host.UseSerilog((context, config) =>
{
config.ReadFrom.Configuration(context.Configuration);
});
// Health checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<MelodeeDbContext>()
.AddCheck<MelodeeHealthCheck>("melodee");
var app = builder.Build();
// Middleware pipeline
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
endpoints.MapControllers();
endpoints.MapHub<PartyModeHub>("/hubs/party");
endpoints.MapHealthChecks("/health");
});
app.Run();
Controllers/AlbumsController.cs:
[ApiController]
[Route("api/v1/albums")]
[Authorize]
[RateLimit(MaxRequests = 30, Window = TimeSpan.FromSeconds(30))]
public class AlbumsController : ControllerBase
{
private readonly ILibraryService _libraryService;
private readonly ILogger<AlbumsController> _logger;
public AlbumsController(ILibraryService libraryService, ILogger<AlbumsController> logger)
{
_libraryService = libraryService;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<PagedResult<AlbumDto>>> GetAlbums(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string sort = "title",
[FromQuery] string order = "asc",
[FromQuery] string genre = null)
{
var result = await _libraryService.GetAlbumsAsync(page, pageSize, sort, order, genre);
return Ok(result);
}
[HttpGet("{id}")]
public async Task<ActionResult<AlbumDto>> GetAlbum(int id)
{
var album = await _libraryService.GetAlbumAsync(id);
if (album == null)
return NotFound(new { error = "NotFound", message = $"Album with ID {id} not found" });
return Ok(album);
}
[HttpGet("{id}/cover")]
[AllowAnonymous]
public async Task<IActionResult> GetCoverArt(int id, [FromQuery] int? size = null)
{
var album = await _libraryService.GetAlbumAsync(id);
if (album == null)
return NotFound();
var imagePath = size.HasValue
? await _imageService.GetResizedCoverArtAsync(album.CoverArtPath, size.Value)
: album.CoverArtPath;
var imageBytes = await System.IO.File.ReadAllBytesAsync(imagePath);
return File(imageBytes, "image/jpeg");
}
}
Pages/Albums.razor:
@page "/albums"
@inject ILibraryService LibraryService
@inject NavigationManager Navigation
<PageTitle>Albums - Melodee</PageTitle>
<h1>Albums</h1>
<div class="filters">
<RadzenTextBox @bind-Value="searchQuery" Placeholder="Search albums..." />
<RadzenDropDown @bind-Value="selectedGenre" Data="@genres" Placeholder="All Genres" />
<RadzenButton Text="Search" Click="@LoadAlbums" />
</div>
<RadzenDataGrid Data="@albums" TItem="AlbumDto" AllowPaging="true" PageSize="20"
AllowSorting="true" @ref="grid">
<Columns>
<RadzenDataGridColumn TItem="AlbumDto" Property="CoverArtUrl" Title="Cover">
<Template Context="album">
<img src="@album.CoverArtUrl" alt="@album.Title" width="50" height="50" />
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="AlbumDto" Property="Title" Title="Title" />
<RadzenDataGridColumn TItem="AlbumDto" Property="ArtistName" Title="Artist" />
<RadzenDataGridColumn TItem="AlbumDto" Property="ReleaseDate" Title="Year">
<Template Context="album">
@album.ReleaseDate?.Year
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="AlbumDto" Property="Genre" Title="Genre" />
<RadzenDataGridColumn TItem="AlbumDto" Title="Actions">
<Template Context="album">
<RadzenButton Text="View" Click="@(() => ViewAlbum(album.Id))" />
<RadzenButton Text="Play" Click="@(() => PlayAlbum(album.Id))" />
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid>
@code {
private List<AlbumDto> albums = new();
private List<string> genres = new();
private string searchQuery;
private string selectedGenre;
private RadzenDataGrid<AlbumDto> grid;
protected override async Task OnInitializedAsync()
{
await LoadGenres();
await LoadAlbums();
}
private async Task LoadGenres()
{
genres = await LibraryService.GetGenresAsync();
}
private async Task LoadAlbums()
{
albums = await LibraryService.SearchAlbumsAsync(searchQuery, selectedGenre);
}
private void ViewAlbum(int id)
{
Navigation.NavigateTo($"/albums/{id}");
}
private async Task PlayAlbum(int id)
{
await PlayerService.PlayAlbumAsync(id);
}
}
Hubs/PartyModeHub.cs:
public class PartyModeHub : Hub
{
private readonly ILogger<PartyModeHub> _logger;
public PartyModeHub(ILogger<PartyModeHub> logger)
{
_logger = logger;
}
public async Task JoinParty(string partyId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, partyId);
_logger.LogInformation("User {ConnectionId} joined party {PartyId}",
Context.ConnectionId, partyId);
}
public async Task LeaveParty(string partyId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, partyId);
_logger.LogInformation("User {ConnectionId} left party {PartyId}",
Context.ConnectionId, partyId);
}
public async Task BroadcastPlaybackState(string partyId, PlaybackState state)
{
await Clients.Group(partyId).SendAsync("UpdatePlayback", state);
_logger.LogDebug("Broadcast playback state to party {PartyId}: {State}",
partyId, state);
}
public async Task BroadcastQueueUpdate(string partyId, List<TrackDto> queue)
{
await Clients.Group(partyId).SendAsync("UpdateQueue", queue);
}
}
Melodee.Core
Responsibilities:
- Domain models and entities
- Business logic services
- DTOs and view models
- Interfaces for repositories and services
- Domain exceptions
Key Files:
Models/Album.cs:
public class Album
{
public int Id { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
public int ArtistId { get; set; }
public Artist Artist { get; set; }
public DateTime? ReleaseDate { get; set; }
public string ReleaseType { get; set; }
public string Country { get; set; }
public string Label { get; set; }
public string Barcode { get; set; }
public string CoverArtPath { get; set; }
public Dictionary<string, string> ExternalIds { get; set; }
public List<string> Genres { get; set; }
public int TrackCount { get; set; }
public int Duration { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public List<Track> Tracks { get; set; }
public List<PlaylistAlbum> PlaylistAlbums { get; set; }
}
Services/LibraryService.cs:
public class LibraryService : ILibraryService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMetadataAggregator _metadataAggregator;
private readonly ILogger<LibraryService> _logger;
public LibraryService(
IUnitOfWork unitOfWork,
IMetadataAggregator metadataAggregator,
ILogger<LibraryService> logger)
{
_unitOfWork = unitOfWork;
_metadataAggregator = metadataAggregator;
_logger = logger;
}
public async Task<AlbumDto> GetAlbumAsync(int id)
{
var album = await _unitOfWork.Albums.GetByIdAsync(id);
if (album == null)
return null;
return MapToDto(album);
}
public async Task<PagedResult<AlbumDto>> GetAlbumsAsync(
int page, int pageSize, string sort, string order, string genre)
{
var albums = await _unitOfWork.Albums.GetPagedAsync(page, pageSize, sort, order, genre);
return new PagedResult<AlbumDto>
{
Items = albums.Items.Select(MapToDto).ToList(),
TotalCount = albums.TotalCount,
Page = page,
PageSize = pageSize
};
}
public async Task EnrichAlbumMetadataAsync(int albumId)
{
var album = await _unitOfWork.Albums.GetByIdAsync(albumId);
if (album == null)
throw new NotFoundException($"Album {albumId} not found");
_logger.LogInformation("Enriching metadata for album {AlbumId}: {Artist} - {Title}",
albumId, album.Artist.Name, album.Title);
var metadata = await _metadataAggregator.AggregateAlbumMetadataAsync(
album.Artist.Name, album.Title);
if (metadata != null)
{
album.ReleaseDate = metadata.ReleaseDate ?? album.ReleaseDate;
album.Genres = metadata.Genres ?? album.Genres;
album.ExternalIds = metadata.ExternalIds ?? album.ExternalIds;
if (!string.IsNullOrEmpty(metadata.CoverArtUrl))
{
album.CoverArtPath = await DownloadCoverArtAsync(metadata.CoverArtUrl, albumId);
}
album.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Metadata enriched for album {AlbumId}", albumId);
}
}
private AlbumDto MapToDto(Album album)
{
return new AlbumDto
{
Id = album.Id,
Title = album.Title,
ArtistId = album.ArtistId,
ArtistName = album.Artist.Name,
ReleaseDate = album.ReleaseDate,
Genre = album.Genres?.FirstOrDefault(),
TrackCount = album.TrackCount,
Duration = album.Duration,
CoverArtUrl = $"/api/v1/albums/{album.Id}/cover",
ExternalIds = album.ExternalIds
};
}
}
Services/MetadataAggregator.cs:
public class MetadataAggregator : IMetadataAggregator
{
private readonly List<IMetadataProvider> _providers;
private readonly ILogger<MetadataAggregator> _logger;
public MetadataAggregator(
IEnumerable<IMetadataProvider> providers,
ILogger<MetadataAggregator> logger)
{
_providers = providers.OrderByDescending(p => p.Priority).ToList();
_logger = logger;
}
public async Task<AlbumMetadata> AggregateAlbumMetadataAsync(string artist, string album)
{
var results = new List<AlbumMetadata>();
foreach (var provider in _providers)
{
if (!provider.IsEnabled)
continue;
try
{
_logger.LogDebug("Querying {Provider} for {Artist} - {Album}",
provider.Name, artist, album);
var metadata = await provider.GetAlbumMetadataAsync(artist, album);
if (metadata != null)
{
metadata.Source = provider.Name;
results.Add(metadata);
_logger.LogDebug("{Provider} returned metadata for {Artist} - {Album}",
provider.Name, artist, album);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Provider} failed for {Artist} - {Album}",
provider.Name, artist, album);
}
}
if (!results.Any())
{
_logger.LogWarning("No metadata found for {Artist} - {Album}", artist, album);
return null;
}
return MergeMetadata(results);
}
private AlbumMetadata MergeMetadata(List<AlbumMetadata> results)
{
var merged = new AlbumMetadata();
// Title: prefer highest-priority provider
merged.Title = results.FirstOrDefault(r => !string.IsNullOrEmpty(r.Title))?.Title;
// Artist: prefer highest-priority provider
merged.Artist = results.FirstOrDefault(r => !string.IsNullOrEmpty(r.Artist))?.Artist;
// Release date: prefer most specific date
merged.ReleaseDate = results
.Where(r => r.ReleaseDate.HasValue)
.OrderByDescending(r => r.ReleaseDate.Value)
.FirstOrDefault()?.ReleaseDate;
// Genres: combine from all providers, deduplicate
merged.Genres = results
.SelectMany(r => r.Genres ?? new List<string>())
.Distinct()
.ToList();
// Cover art: prefer highest resolution
merged.CoverArtUrl = results
.Where(r => !string.IsNullOrEmpty(r.CoverArtUrl))
.OrderByDescending(r => EstimateImageResolution(r.CoverArtUrl))
.FirstOrDefault()?.CoverArtUrl;
// External IDs: combine from all providers
merged.ExternalIds = results
.SelectMany(r => r.ExternalIds ?? new Dictionary<string, string>())
.GroupBy(kvp => kvp.Key)
.ToDictionary(g => g.Key, g => g.First().Value);
_logger.LogInformation("Merged metadata from {Count} providers: {Providers}",
results.Count, string.Join(", ", results.Select(r => r.Source)));
return merged;
}
private int EstimateImageResolution(string url)
{
// Extract resolution from URL patterns
var match = Regex.Match(url, @"(\d+)x(\d+)");
if (match.Success)
{
return int.Parse(match.Groups[1].Value) * int.Parse(match.Groups[2].Value);
}
// Default resolution estimates
if (url.Contains("large") || url.Contains("xl"))
return 600 * 600;
if (url.Contains("medium"))
return 300 * 300;
if (url.Contains("small") || url.Contains("thumb"))
return 150 * 150;
return 300 * 300; // Default
}
}
Melodee.Data
Responsibilities:
- EF Core DbContext
- Entity configurations
- Migrations
- Repository implementations
- Database seeding
Key Files:
MelodeeDbContext.cs:
public class MelodeeDbContext : DbContext
{
public MelodeeDbContext(DbContextOptions<MelodeeDbContext> options)
: base(options)
{
}
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<UserSettings> UserSettings { get; set; }
public DbSet<Playlist> Playlists { get; set; }
public DbSet<PlaylistTrack> PlaylistTracks { get; set; }
public DbSet<Scrobble> Scrobbles { get; set; }
public DbSet<Genre> Genres { get; set; }
public DbSet<MetadataProvider> MetadataProviders { get; set; }
public DbSet<MetadataCache> MetadataCache { get; set; }
public DbSet<Job> Jobs { get; set; }
public DbSet<HealthCheck> HealthChecks { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply configurations
modelBuilder.ApplyConfiguration(new AlbumConfiguration());
modelBuilder.ApplyConfiguration(new ArtistConfiguration());
modelBuilder.ApplyConfiguration(new TrackConfiguration());
modelBuilder.ApplyConfiguration(new UserConfiguration());
modelBuilder.ApplyConfiguration(new PlaylistConfiguration());
modelBuilder.ApplyConfiguration(new ScrobbleConfiguration());
}
}
Configurations/AlbumConfiguration.cs:
public class AlbumConfiguration : IEntityTypeConfiguration<Album>
{
public void Configure(EntityTypeBuilder<Album> builder)
{
builder.ToTable("albums");
builder.HasKey(a => a.Id);
builder.Property(a => a.Title)
.IsRequired()
.HasMaxLength(500);
builder.Property(a => a.SortTitle)
.HasMaxLength(500);
builder.Property(a => a.ExternalIds)
.HasColumnType("jsonb");
builder.Property(a => a.Genres)
.HasColumnType("jsonb");
builder.HasOne(a => a.Artist)
.WithMany(a => a.Albums)
.HasForeignKey(a => a.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(a => a.Tracks)
.WithOne(t => t.Album)
.HasForeignKey(t => t.AlbumId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(a => a.Title);
builder.HasIndex(a => a.ArtistId);
builder.HasIndex(a => a.ReleaseDate);
builder.HasIndex(a => a.ExternalIds)
.HasMethod("gin");
builder.HasIndex(a => a.Genres)
.HasMethod("gin");
}
}
Repositories/AlbumRepository.cs:
public class AlbumRepository : IAlbumRepository
{
private readonly MelodeeDbContext _context;
public AlbumRepository(MelodeeDbContext context)
{
_context = context;
}
public async Task<Album> GetByIdAsync(int id)
{
return await _context.Albums
.Include(a => a.Artist)
.Include(a => a.Tracks)
.FirstOrDefaultAsync(a => a.Id == id);
}
public async Task<PagedResult<Album>> GetPagedAsync(
int page, int pageSize, string sort, string order, string genre)
{
var query = _context.Albums
.Include(a => a.Artist)
.AsQueryable();
if (!string.IsNullOrEmpty(genre))
{
query = query.Where(a => a.Genres.Contains(genre));
}
// Apply sorting
query = sort.ToLower() switch
{
"title" => order == "desc"
? query.OrderByDescending(a => a.Title)
: query.OrderBy(a => a.Title),
"artist" => order == "desc"
? query.OrderByDescending(a => a.Artist.Name)
: query.OrderBy(a => a.Artist.Name),
"releasedate" => order == "desc"
? query.OrderByDescending(a => a.ReleaseDate)
: query.OrderBy(a => a.ReleaseDate),
_ => query.OrderBy(a => a.Title)
};
var totalCount = await query.CountAsync();
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<Album>
{
Items = items,
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
public async Task<List<Album>> SearchAsync(string query)
{
return await _context.Albums
.Include(a => a.Artist)
.Where(a => EF.Functions.ILike(a.Title, $"%{query}%") ||
EF.Functions.ILike(a.Artist.Name, $"%{query}%"))
.OrderBy(a => a.Title)
.ToListAsync();
}
public async Task<Album> CreateAsync(Album album)
{
_context.Albums.Add(album);
await _context.SaveChangesAsync();
return album;
}
public async Task UpdateAsync(Album album)
{
album.UpdatedAt = DateTime.UtcNow;
_context.Albums.Update(album);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var album = await _context.Albums.FindAsync(id);
if (album != null)
{
_context.Albums.Remove(album);
await _context.SaveChangesAsync();
}
}
}
Melodee.Providers
Responsibilities:
- Metadata provider implementations
- Provider-specific API clients
- Response parsing and mapping
- Rate limiting and caching
Key Files:
MusicBrainzProvider.cs:
public class MusicBrainzProvider : IMetadataProvider
{
private readonly MusicBrainzCache _cache;
private readonly HttpClient _httpClient;
private readonly ILogger<MusicBrainzProvider> _logger;
private readonly SemaphoreSlim _rateLimiter = new SemaphoreSlim(1, 1);
public string Name => "MusicBrainz";
public int Priority => 100;
public bool IsEnabled => true;
public async Task<AlbumMetadata> GetAlbumMetadataAsync(string artist, string album)
{
// Try local cache first
var cached = await _cache.SearchReleasesAsync(artist, album);
if (cached.Any())
{
_logger.LogDebug("MusicBrainz cache hit for {Artist} - {Album}", artist, album);
return MapToMetadata(cached.First());
}
// Fallback to API
await _rateLimiter.WaitAsync();
try
{
await Task.Delay(1000); // Rate limit: 1 request per second
var query = $"artist:{artist} AND release:{album}";
var encodedQuery = Uri.EscapeDataString(query);
var response = await _httpClient.GetAsync(
$"https://musicbrainz.org/ws/2/release?query={encodedQuery}&fmt=json&limit=1"
);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MusicBrainz API returned {StatusCode}", response.StatusCode);
return null;
}
var data = await response.Content.ReadFromJsonAsync<MbSearchResponse>();
if (data.Releases.Any())
{
var release = data.Releases.First();
// Cache for future lookups
await _cache.InsertReleaseAsync(release);
return MapToMetadata(release);
}
return null;
}
finally
{
_rateLimiter.Release();
}
}
private AlbumMetadata MapToMetadata(MbRelease release)
{
return new AlbumMetadata
{
Title = release.Title,
Artist = release.ArtistCredit,
ReleaseDate = ParseDate(release.ReleaseDate),
Country = release.Country,
Barcode = release.Barcode,
ExternalIds = new Dictionary<string, string>
{
["musicbrainz"] = release.Id,
["musicbrainz_release_group"] = release.ReleaseGroupId
}
};
}
}
SpotifyProvider.cs:
public class SpotifyProvider : IMetadataProvider
{
private readonly SpotifyAuthService _authService;
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly ILogger<SpotifyProvider> _logger;
public string Name => "Spotify";
public int Priority => 80;
public bool IsEnabled { get; private set; }
public SpotifyProvider(
SpotifyAuthService authService,
HttpClient httpClient,
IMemoryCache cache,
IConfiguration configuration,
ILogger<SpotifyProvider> logger)
{
_authService = authService;
_httpClient = httpClient;
_cache = cache;
_logger = logger;
IsEnabled = !string.IsNullOrEmpty(configuration["Spotify:ClientId"]);
}
public async Task<AlbumMetadata> GetAlbumMetadataAsync(string artist, string album)
{
if (!IsEnabled)
return null;
var cacheKey = $"spotify:{artist}:{album}";
if (_cache.TryGetValue(cacheKey, out AlbumMetadata cached))
{
_logger.LogDebug("Spotify cache hit for {Artist} - {Album}", artist, album);
return cached;
}
var token = await _authService.GetAccessTokenAsync();
var query = $"artist:{artist} album:{album}";
var encodedQuery = Uri.EscapeDataString(query);
var request = new HttpRequestMessage(
HttpMethod.Get,
$"https://api.spotify.com/v1/search?q={encodedQuery}&type=album&limit=1"
);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Spotify API returned {StatusCode}", response.StatusCode);
return null;
}
var data = await response.Content.ReadFromJsonAsync<SpotifySearchResponse>();
if (data.Albums.Items.Any())
{
var spotifyAlbum = data.Albums.Items.First();
var metadata = MapToMetadata(spotifyAlbum);
_cache.Set(cacheKey, metadata, TimeSpan.FromHours(1));
return metadata;
}
return null;
}
private AlbumMetadata MapToMetadata(SpotifyAlbum album)
{
return new AlbumMetadata
{
Title = album.Name,
Artist = album.Artists.First().Name,
ReleaseDate = DateTime.Parse(album.ReleaseDate),
Genres = album.Genres,
CoverArtUrl = album.Images.FirstOrDefault()?.Url,
ExternalIds = new Dictionary<string, string>
{
["spotify"] = album.Id,
["spotify_uri"] = album.Uri
},
Popularity = album.Popularity
};
}
}
Melodee.Jobs
Responsibilities:
- Quartz.NET job implementations
- Job scheduling configuration
- Background task orchestration
Key Files:
LibraryScanJob.cs:
public class LibraryScanJob : IJob
{
private readonly ILibraryService _libraryService;
private readonly IFileScanner _fileScanner;
private readonly ILogger<LibraryScanJob> _logger;
public async Task Execute(IJobExecutionContext context)
{
_logger.LogInformation("Starting library scan");
var libraryPath = context.MergedJobDataMap.GetString("LibraryPath");
var changes = await _fileScanner.ScanAsync(libraryPath);
_logger.LogInformation("Found {NewFiles} new files, {ChangedFiles} changed files, {DeletedFiles} deleted files",
changes.NewFiles.Count, changes.ChangedFiles.Count, changes.DeletedFiles.Count);
foreach (var file in changes.NewFiles)
{
await _libraryService.ImportFileAsync(file);
}
foreach (var file in changes.ChangedFiles)
{
await _libraryService.UpdateFileAsync(file);
}
foreach (var file in changes.DeletedFiles)
{
await _libraryService.RemoveFileAsync(file);
}
_logger.LogInformation("Library scan completed");
// Trigger metadata enrichment job
if (changes.NewFiles.Any())
{
await context.Scheduler.TriggerJob(new JobKey("MetadataEnrichment"));
}
}
}
JobConfiguration.cs:
public static 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")
.WithIdentity("LibraryScanTrigger")
.WithCronSchedule("0 0 */6 * * ?"));
// Metadata enrichment (triggered by library scan)
quartz.AddJob<MetadataEnrichmentJob>(opts => opts.WithIdentity("MetadataEnrichment"));
// MusicBrainz cache update monthly
quartz.AddJob<MusicBrainzCacheUpdateJob>(opts => opts.WithIdentity("MbCacheUpdate"));
quartz.AddTrigger(opts => opts
.ForJob("MbCacheUpdate")
.WithIdentity("MbCacheUpdateTrigger")
.WithCronSchedule("0 0 0 1 * ?")); // First day of month at midnight
// Scrobble submission every 5 minutes
quartz.AddJob<ScrobbleSubmissionJob>(opts => opts.WithIdentity("ScrobbleSubmission"));
quartz.AddTrigger(opts => opts
.ForJob("ScrobbleSubmission")
.WithIdentity("ScrobbleSubmissionTrigger")
.WithSimpleSchedule(x => x.WithIntervalInMinutes(5).RepeatForever()));
// Statistics calculation daily at 3 AM
quartz.AddJob<StatisticsCalculationJob>(opts => opts.WithIdentity("StatisticsCalculation"));
quartz.AddTrigger(opts => opts
.ForJob("StatisticsCalculation")
.WithIdentity("StatisticsCalculationTrigger")
.WithCronSchedule("0 0 3 * * ?"));
// Database maintenance weekly
quartz.AddJob<DatabaseMaintenanceJob>(opts => opts.WithIdentity("DatabaseMaintenance"));
quartz.AddTrigger(opts => opts
.ForJob("DatabaseMaintenance")
.WithIdentity("DatabaseMaintenanceTrigger")
.WithCronSchedule("0 0 2 ? * SUN")); // Sundays at 2 AM
}
}
Testing Strategy
Unit Tests (xUnit)
Melodee.Tests.Unit/Services/LibraryServiceTests.cs:
public class LibraryServiceTests
{
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly Mock<IMetadataAggregator> _metadataAggregatorMock;
private readonly Mock<ILogger<LibraryService>> _loggerMock;
private readonly LibraryService _service;
public LibraryServiceTests()
{
_unitOfWorkMock = new Mock<IUnitOfWork>();
_metadataAggregatorMock = new Mock<IMetadataAggregator>();
_loggerMock = new Mock<ILogger<LibraryService>>();
_service = new LibraryService(
_unitOfWorkMock.Object,
_metadataAggregatorMock.Object,
_loggerMock.Object
);
}
[Fact]
public async Task GetAlbumAsync_ExistingAlbum_ReturnsDto()
{
// Arrange
var album = new Album
{
Id = 1,
Title = "OK Computer",
Artist = new Artist { Id = 1, Name = "Radiohead" }
};
_unitOfWorkMock.Setup(u => u.Albums.GetByIdAsync(1))
.ReturnsAsync(album);
// Act
var result = await _service.GetAlbumAsync(1);
// Assert
Assert.NotNull(result);
Assert.Equal("OK Computer", result.Title);
Assert.Equal("Radiohead", result.ArtistName);
}
[Fact]
public async Task GetAlbumAsync_NonExistentAlbum_ReturnsNull()
{
// Arrange
_unitOfWorkMock.Setup(u => u.Albums.GetByIdAsync(999))
.ReturnsAsync((Album)null);
// Act
var result = await _service.GetAlbumAsync(999);
// Assert
Assert.Null(result);
}
}
Component Tests (bUnit)
Melodee.Tests.Blazor/Pages/AlbumsTests.cs:
public class AlbumsTests : TestContext
{
[Fact]
public void Albums_RendersAlbumGrid()
{
// Arrange
var libraryServiceMock = new Mock<ILibraryService>();
libraryServiceMock.Setup(s => s.SearchAlbumsAsync(null, null))
.ReturnsAsync(new List<AlbumDto>
{
new AlbumDto { Id = 1, Title = "OK Computer", ArtistName = "Radiohead" }
});
Services.AddSingleton(libraryServiceMock.Object);
// Act
var cut = RenderComponent<Albums>();
// Assert
cut.Find("h1").TextContent.Should().Be("Albums");
cut.FindAll("tr").Count.Should().BeGreaterThan(0);
}
}
Performance Tests (NBomber)
Melodee.Tests.Performance/ApiLoadTests.cs:
public class ApiLoadTests
{
[Fact]
public void AlbumsEndpoint_CanHandle100ConcurrentUsers()
{
var httpClient = new HttpClient { BaseAddress = new Uri("http://localhost:5000") };
var scenario = Scenario.Create("albums_load_test", async context =>
{
var response = await httpClient.GetAsync("/api/v1/albums?page=1&pageSize=20");
return response.IsSuccessStatusCode
? Response.Ok()
: Response.Fail();
})
.WithLoadSimulations(
Simulation.InjectPerSec(rate: 100, during: TimeSpan.FromSeconds(30))
);
var stats = NBomberRunner
.RegisterScenarios(scenario)
.Run();
var albumsStats = stats.ScenarioStats[0];
Assert.True(albumsStats.Ok.Request.RPS > 90); // At least 90 RPS
Assert.True(albumsStats.Ok.Latency.Percent99 < 500); // P99 < 500ms
}
}
Code Quality
Biome Configuration
biome.json:
{
"$schema": "https://biomejs.dev/schemas/1.0.0/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExtraBooleanCast": "error",
"noMultipleSpacesInRegularExpressionLiterals": "error",
"noUselessCatch": "error",
"noWith": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInnerDeclarations": "error",
"noInvalidConstructorSuper": "error",
"noNewSymbol": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedVariables": "error",
"useValidForDirection": "error"
},
"style": {
"noArguments": "error",
"noVar": "error",
"useConst": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noDebugger": "error",
"noDoubleEquals": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "warn",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noMisleadingCharacterClass": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noUnsafeNegation": "error",
"useGetterReturn": "error",
"useValidTypeof": "error"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
}
}
Conclusion
Melodee's codebase demonstrates clean architecture with clear separation of concerns across multiple projects. The layered structure (Web, Core, Data, Providers, Jobs) enables maintainability and testability.
Key strengths:
- Layered architecture: Clear boundaries between UI, business logic, and data access
- Dependency injection: Loose coupling and testability
- Repository pattern: Abstraction over data access
- Comprehensive testing: Unit, integration, component, and performance tests
- Code quality: Biome linting for frontend code
Key challenges:
- 100+ migrations: Complex schema evolution requires careful management
- Multiple projects: Increased build complexity and dependency management
- Blazor Server: Server-side rendering limits scalability
The codebase positions Melodee as a well-architected music server with modern .NET practices and comprehensive testing.