a1f6701bac
- gRPC service with MusicBrainz provider - PostgreSQL schema with migrations - Service layer with database-first caching - Repository pattern for data access - YAML configuration support - Research documentation for 17 music metadata projects
1272 lines
39 KiB
Markdown
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.
|