Files
metadata-agregator/docs/research/melodee/analysis/CODEBASE.md
T
Alexander a1f6701bac feat: initial implementation of metadata aggregator
- gRPC service with MusicBrainz provider
- PostgreSQL schema with migrations
- Service layer with database-first caching
- Repository pattern for data access
- YAML configuration support
- Research documentation for 17 music metadata projects
2026-04-28 16:28:53 +02:00

1272 lines
39 KiB
Markdown

# 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**:
```csharp
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**:
```csharp
[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**:
```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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```csharp
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**:
```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.