# Melodee: Data Architecture Analysis ## Data Strategy Overview Melodee employs a dual-database architecture: PostgreSQL 17 for transactional data and SQLite for the MusicBrainz metadata cache. This separation optimizes for different access patterns: PostgreSQL handles concurrent writes and complex queries for user data, while SQLite provides fast read-only access to reference metadata. The data model spans 40+ entities across six primary domains: 1. **Library**: Albums, Artists, Tracks, Genres 2. **Users**: Accounts, Settings, Sessions 3. **Playlists**: Manual and smart playlists 4. **Scrobbles**: Listening history 5. **Metadata**: Provider mappings, external IDs 6. **System**: Jobs, Logs, Health checks With 100+ migrations, the schema has evolved significantly, suggesting iterative development and feature additions over time. ## Database Architecture ### PostgreSQL 17 **Selection Rationale**: - **JSONB support**: Flexible storage for metadata from multiple providers - **Full-text search**: Efficient library searching without external search engines - **Concurrent writes**: Multiple users can scrobble, create playlists, and update settings simultaneously - **Mature ecosystem**: EF Core 10 provides robust ORM support - **Advanced features**: CTEs, window functions, and materialized views for analytics **Configuration**: ``` Host: postgres (Docker Compose service) Port: 5432 Database: melodee User: melodee Connection Pool: 20 connections (default EF Core) ``` **Performance Tuning**: ```sql -- Increase shared buffers for caching shared_buffers = 256MB -- Optimize for SSD storage random_page_cost = 1.1 -- Enable query planning statistics track_activity_query_size = 2048 ``` ### SQLite MusicBrainz Cache **Selection Rationale**: - **Embedded**: No separate database server required - **Fast reads**: Single-user read-only access is extremely fast - **Portable**: Database file can be copied between systems - **Offline**: No network dependency for metadata lookups **File Location**: `/data/mb-cache.db` **Size**: Approximately 2-5 GB depending on MusicBrainz dump version **Update Frequency**: Monthly (first day of month via Quartz.NET job) ## Entity Relationship Model ### Library Domain #### Artist Entity ```csharp public class Artist { public int Id { get; set; } public string Name { get; set; } public string SortName { get; set; } public string Bio { get; set; } public string Country { get; set; } public DateTime? FormedDate { get; set; } public DateTime? DisbandedDate { get; set; } public string ImagePath { get; set; } public Dictionary ExternalIds { get; set; } // JSONB public List Genres { get; set; } // JSONB array public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } // Navigation properties public List Albums { get; set; } public List Tracks { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE artists ( id SERIAL PRIMARY KEY, name VARCHAR(500) NOT NULL, sort_name VARCHAR(500), bio TEXT, country VARCHAR(2), formed_date DATE, disbanded_date DATE, image_path VARCHAR(1000), external_ids JSONB, genres JSONB, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_artists_name ON artists(name); CREATE INDEX idx_artists_sort_name ON artists(sort_name); CREATE INDEX idx_artists_external_ids ON artists USING GIN(external_ids); CREATE INDEX idx_artists_genres ON artists USING GIN(genres); ``` **JSONB Example**: ```json { "external_ids": { "musicbrainz": "a74b1b7f-71a5-4011-9441-d0b5e4122711", "spotify": "4Z8W4fKeB5YxbusRsdQVPb", "lastfm": "Radiohead", "discogs": "3840" }, "genres": ["Alternative Rock", "Art Rock", "Electronic"] } ``` **GIN Indexes**: Generalized Inverted Indexes enable fast JSONB queries: ```sql -- Find artists with Spotify ID SELECT * FROM artists WHERE external_ids->>'spotify' = '4Z8W4fKeB5YxbusRsdQVPb'; -- Find artists in genre SELECT * FROM artists WHERE genres @> '["Alternative Rock"]'; ``` #### Album Entity ```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; } // Album, EP, Single, Compilation public string Country { get; set; } public string Label { get; set; } public string Barcode { get; set; } public string CoverArtPath { get; set; } public Dictionary ExternalIds { get; set; } public List Genres { get; set; } public int TrackCount { get; set; } public int Duration { get; set; } // Total duration in seconds public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } // Navigation properties public List Tracks { get; set; } public List PlaylistAlbums { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE albums ( id SERIAL PRIMARY KEY, title VARCHAR(500) NOT NULL, sort_title VARCHAR(500), artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE, release_date DATE, release_type VARCHAR(50), country VARCHAR(2), label VARCHAR(200), barcode VARCHAR(50), cover_art_path VARCHAR(1000), external_ids JSONB, genres JSONB, track_count INTEGER DEFAULT 0, duration INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_albums_title ON albums(title); CREATE INDEX idx_albums_artist_id ON albums(artist_id); CREATE INDEX idx_albums_release_date ON albums(release_date); CREATE INDEX idx_albums_external_ids ON albums USING GIN(external_ids); CREATE INDEX idx_albums_genres ON albums USING GIN(genres); ``` **Computed Columns**: Track count and duration are denormalized for performance. They're updated via triggers: ```sql CREATE OR REPLACE FUNCTION update_album_stats() RETURNS TRIGGER AS $$ BEGIN UPDATE albums SET track_count = (SELECT COUNT(*) FROM tracks WHERE album_id = NEW.album_id), duration = (SELECT COALESCE(SUM(duration), 0) FROM tracks WHERE album_id = NEW.album_id) WHERE id = NEW.album_id; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER track_stats_trigger AFTER INSERT OR UPDATE OR DELETE ON tracks FOR EACH ROW EXECUTE FUNCTION update_album_stats(); ``` #### Track Entity ```csharp public class Track { public int Id { get; set; } public string Title { get; set; } public int AlbumId { get; set; } public Album Album { get; set; } public int ArtistId { get; set; } public Artist Artist { get; set; } public int Position { get; set; } public int DiscNumber { get; set; } public int Duration { get; set; } // Duration in seconds public string FilePath { get; set; } public long FileSize { get; set; } public string FileFormat { get; set; } // FLAC, MP3, OGG, etc. public int Bitrate { get; set; } public int SampleRate { get; set; } public int Channels { get; set; } public string Codec { get; set; } public Dictionary ExternalIds { get; set; } public List Genres { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } // Navigation properties public List PlaylistTracks { get; set; } public List Scrobbles { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE tracks ( id SERIAL PRIMARY KEY, title VARCHAR(500) NOT NULL, album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE, artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE, position INTEGER NOT NULL, disc_number INTEGER DEFAULT 1, duration INTEGER NOT NULL, file_path VARCHAR(2000) NOT NULL UNIQUE, file_size BIGINT NOT NULL, file_format VARCHAR(20) NOT NULL, bitrate INTEGER, sample_rate INTEGER, channels INTEGER, codec VARCHAR(50), external_ids JSONB, genres JSONB, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_tracks_title ON tracks(title); CREATE INDEX idx_tracks_album_id ON tracks(album_id); CREATE INDEX idx_tracks_artist_id ON tracks(artist_id); CREATE INDEX idx_tracks_file_path ON tracks(file_path); CREATE INDEX idx_tracks_external_ids ON tracks USING GIN(external_ids); ``` **File Path Uniqueness**: Ensures the same file isn't imported multiple times. The library scanner checks this index before creating new track records. #### Genre Entity ```csharp public class Genre { public int Id { get; set; } public string Name { get; set; } public string ParentGenre { get; set; } public int AlbumCount { get; set; } public int TrackCount { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE genres ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL UNIQUE, parent_genre VARCHAR(100), album_count INTEGER DEFAULT 0, track_count INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_genres_name ON genres(name); ``` **Genre Hierarchy**: ``` Rock ├── Alternative Rock │ ├── Indie Rock │ └── Post-Rock ├── Progressive Rock └── Hard Rock ``` The `parent_genre` field enables hierarchical genre browsing. Queries can find all "Rock" subgenres recursively. ### User Domain #### User Entity ```csharp public class User { public int Id { get; set; } public string Email { get; set; } public string PasswordHash { get; set; } public string Role { get; set; } // "admin" or "user" public bool IsActive { get; set; } public string GoogleId { get; set; } // For OAuth users public string ProfilePictureUrl { get; set; } public DateTime CreatedAt { get; set; } public DateTime LastLoginAt { get; set; } // Navigation properties public UserSettings Settings { get; set; } public List Playlists { get; set; } public List Scrobbles { get; set; } public List Sessions { get; set; } public List Favorites { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE users ( id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255), role VARCHAR(20) NOT NULL DEFAULT 'user', is_active BOOLEAN NOT NULL DEFAULT TRUE, google_id VARCHAR(255) UNIQUE, profile_picture_url VARCHAR(1000), created_at TIMESTAMP NOT NULL DEFAULT NOW(), last_login_at TIMESTAMP ); CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_google_id ON users(google_id); ``` **Password Hashing**: Uses BCrypt with work factor 12: ```csharp public string HashPassword(string password) { return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12); } public bool VerifyPassword(string password, string hash) { return BCrypt.Net.BCrypt.Verify(password, hash); } ``` #### UserSettings Entity ```csharp public class UserSettings { public int Id { get; set; } public int UserId { get; set; } public User User { get; set; } public string Language { get; set; } // en, es, fr, de, it, pt, ru, ja, zh, ko public string Theme { get; set; } // light, dark, auto public int TranscodeBitrate { get; set; } // 128, 192, 256, 320 public string TranscodeFormat { get; set; } // mp3, ogg, opus, aac public bool ScrobbleEnabled { get; set; } public string LastFmUsername { get; set; } public string LastFmSessionKey { get; set; } // Encrypted public bool PartyModeEnabled { get; set; } public int VolumeLevel { get; set; } // 0-100 public bool RepeatEnabled { get; set; } public bool ShuffleEnabled { get; set; } public DateTime UpdatedAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE user_settings ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, language VARCHAR(10) DEFAULT 'en', theme VARCHAR(20) DEFAULT 'auto', transcode_bitrate INTEGER DEFAULT 320, transcode_format VARCHAR(20) DEFAULT 'mp3', scrobble_enabled BOOLEAN DEFAULT FALSE, lastfm_username VARCHAR(100), lastfm_session_key VARCHAR(500), -- Encrypted party_mode_enabled BOOLEAN DEFAULT FALSE, volume_level INTEGER DEFAULT 80, repeat_enabled BOOLEAN DEFAULT FALSE, shuffle_enabled BOOLEAN DEFAULT FALSE, updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); ``` **Encryption**: Last.fm session keys are encrypted using ASP.NET Core Data Protection: ```csharp var protector = _dataProtectionProvider.CreateProtector("UserSecrets"); var encrypted = protector.Protect(sessionKey); ``` #### UserSession Entity ```csharp public class UserSession { public int Id { get; set; } public int UserId { get; set; } public User User { get; set; } public string Token { get; set; } public string IpAddress { get; set; } public string UserAgent { get; set; } public DateTime CreatedAt { get; set; } public DateTime ExpiresAt { get; set; } public DateTime? LastActivityAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE user_sessions ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(255) NOT NULL UNIQUE, ip_address VARCHAR(45), user_agent VARCHAR(500), created_at TIMESTAMP NOT NULL DEFAULT NOW(), expires_at TIMESTAMP NOT NULL, last_activity_at TIMESTAMP ); CREATE INDEX idx_user_sessions_token ON user_sessions(token); CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id); CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at); ``` **Session Cleanup**: Expired sessions are deleted by a Quartz.NET job: ```sql DELETE FROM user_sessions WHERE expires_at < NOW(); ``` ### Playlist Domain #### Playlist Entity ```csharp public class Playlist { public int Id { get; set; } public string Name { get; set; } public int UserId { get; set; } public User User { get; set; } public bool IsPublic { get; set; } public bool IsSmart { get; set; } public string SmartQuery { get; set; } // MQL query public string Description { get; set; } public int TrackCount { get; set; } public int Duration { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } // Navigation properties public List PlaylistTracks { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE playlists ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, is_public BOOLEAN DEFAULT FALSE, is_smart BOOLEAN DEFAULT FALSE, smart_query TEXT, description TEXT, track_count INTEGER DEFAULT 0, duration INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_playlists_user_id ON playlists(user_id); CREATE INDEX idx_playlists_is_public ON playlists(is_public); ``` #### PlaylistTrack Entity ```csharp public class PlaylistTrack { public int PlaylistId { get; set; } public Playlist Playlist { get; set; } public int TrackId { get; set; } public Track Track { get; set; } public int Position { get; set; } public DateTime AddedAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE playlist_tracks ( playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, position INTEGER NOT NULL, added_at TIMESTAMP NOT NULL DEFAULT NOW(), PRIMARY KEY (playlist_id, track_id) ); CREATE INDEX idx_playlist_tracks_playlist_id ON playlist_tracks(playlist_id); CREATE INDEX idx_playlist_tracks_position ON playlist_tracks(playlist_id, position); ``` **Position Management**: When tracks are added or removed, positions are recalculated: ```sql -- Add track at position 5 UPDATE playlist_tracks SET position = position + 1 WHERE playlist_id = 1 AND position >= 5; INSERT INTO playlist_tracks (playlist_id, track_id, position, added_at) VALUES (1, 420, 5, NOW()); ``` ### Scrobble Domain #### Scrobble Entity ```csharp public class Scrobble { public int Id { get; set; } public int UserId { get; set; } public User User { get; set; } public int TrackId { get; set; } public Track Track { get; set; } public DateTime PlayedAt { get; set; } public bool SubmittedToLastFm { get; set; } public DateTime? LastFmSubmittedAt { get; set; } public DateTime CreatedAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE scrobbles ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, played_at TIMESTAMP NOT NULL, submitted_to_lastfm BOOLEAN DEFAULT FALSE, lastfm_submitted_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_scrobbles_user_id ON scrobbles(user_id); CREATE INDEX idx_scrobbles_track_id ON scrobbles(track_id); CREATE INDEX idx_scrobbles_played_at ON scrobbles(played_at); CREATE INDEX idx_scrobbles_submitted_to_lastfm ON scrobbles(submitted_to_lastfm); ``` **Batch Submission**: Unsubmitted scrobbles are batched and sent to Last.fm: ```sql SELECT * FROM scrobbles WHERE submitted_to_lastfm = FALSE ORDER BY played_at LIMIT 50; ``` After successful submission: ```sql UPDATE scrobbles SET submitted_to_lastfm = TRUE, lastfm_submitted_at = NOW() WHERE id IN (...); ``` ### Metadata Domain #### MetadataProvider Entity ```csharp public class MetadataProvider { public int Id { get; set; } public string Name { get; set; } // MusicBrainz, Spotify, Last.fm, etc. public int Priority { get; set; } public bool IsEnabled { get; set; } public string ApiKey { get; set; } // Encrypted public string ApiSecret { get; set; } // Encrypted public DateTime? LastSyncAt { get; set; } public string LastSyncStatus { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE metadata_providers ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL UNIQUE, priority INTEGER NOT NULL, is_enabled BOOLEAN DEFAULT TRUE, api_key VARCHAR(500), api_secret VARCHAR(500), last_sync_at TIMESTAMP, last_sync_status VARCHAR(50), created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); ``` #### MetadataCache Entity ```csharp public class MetadataCache { public int Id { get; set; } public string Provider { get; set; } public string EntityType { get; set; } // Artist, Album, Track public string EntityId { get; set; } public string CacheKey { get; set; } public string CacheValue { get; set; } // JSON public DateTime ExpiresAt { get; set; } public DateTime CreatedAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE metadata_cache ( id SERIAL PRIMARY KEY, provider VARCHAR(100) NOT NULL, entity_type VARCHAR(50) NOT NULL, entity_id VARCHAR(255) NOT NULL, cache_key VARCHAR(500) NOT NULL, cache_value JSONB NOT NULL, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE (provider, cache_key) ); CREATE INDEX idx_metadata_cache_expires_at ON metadata_cache(expires_at); CREATE INDEX idx_metadata_cache_provider_key ON metadata_cache(provider, cache_key); ``` **Cache Invalidation**: ```sql DELETE FROM metadata_cache WHERE expires_at < NOW(); ``` ### System Domain #### Job Entity ```csharp public class Job { public int Id { get; set; } public string Name { get; set; } public string Status { get; set; } // Queued, Running, Completed, Failed public int Progress { get; set; } // 0-100 public string Message { get; set; } public DateTime? StartedAt { get; set; } public DateTime? CompletedAt { get; set; } public string ErrorMessage { get; set; } public DateTime CreatedAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE jobs ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, status VARCHAR(50) NOT NULL, progress INTEGER DEFAULT 0, message TEXT, started_at TIMESTAMP, completed_at TIMESTAMP, error_message TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_jobs_status ON jobs(status); CREATE INDEX idx_jobs_created_at ON jobs(created_at); ``` #### HealthCheck Entity ```csharp public class HealthCheck { public int Id { get; set; } public string Component { get; set; } // Database, MusicBrainz, Spotify, etc. public string Status { get; set; } // Healthy, Degraded, Unhealthy public string Message { get; set; } public Dictionary Data { get; set; } public DateTime CheckedAt { get; set; } } ``` **Database Schema**: ```sql CREATE TABLE health_checks ( id SERIAL PRIMARY KEY, component VARCHAR(100) NOT NULL, status VARCHAR(50) NOT NULL, message TEXT, data JSONB, checked_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_health_checks_component ON health_checks(component); CREATE INDEX idx_health_checks_checked_at ON health_checks(checked_at); ``` ## MusicBrainz Cache Schema ### Release Table ```sql CREATE TABLE mb_release ( id TEXT PRIMARY KEY, title TEXT NOT NULL, artist_credit TEXT NOT NULL, release_date TEXT, country TEXT, barcode TEXT, release_group_id TEXT, updated_at INTEGER NOT NULL ); CREATE INDEX idx_mb_release_title ON mb_release(title); CREATE INDEX idx_mb_release_artist_credit ON mb_release(artist_credit); CREATE INDEX idx_mb_release_barcode ON mb_release(barcode); ``` ### Recording Table ```sql CREATE TABLE mb_recording ( id TEXT PRIMARY KEY, title TEXT NOT NULL, artist_credit TEXT NOT NULL, length INTEGER, updated_at INTEGER NOT NULL ); CREATE INDEX idx_mb_recording_title ON mb_recording(title); CREATE INDEX idx_mb_recording_artist_credit ON mb_recording(artist_credit); ``` ### Artist Table ```sql CREATE TABLE mb_artist ( id TEXT PRIMARY KEY, name TEXT NOT NULL, sort_name TEXT, type TEXT, country TEXT, begin_date TEXT, end_date TEXT, updated_at INTEGER NOT NULL ); CREATE INDEX idx_mb_artist_name ON mb_artist(name); CREATE INDEX idx_mb_artist_sort_name ON mb_artist(sort_name); ``` ### Release Group Table ```sql CREATE TABLE mb_release_group ( id TEXT PRIMARY KEY, title TEXT NOT NULL, artist_credit TEXT NOT NULL, type TEXT, first_release_date TEXT, updated_at INTEGER NOT NULL ); CREATE INDEX idx_mb_release_group_title ON mb_release_group(title); CREATE INDEX idx_mb_release_group_artist_credit ON mb_release_group(artist_credit); ``` ## Data Migration Strategy ### Migration Management EF Core migrations track schema changes: ```csharp public partial class InitialCreate : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "artists", columns: table => new { id = table.Column(nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), name = table.Column(maxLength: 500, nullable: false), // ... }); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable(name: "artists"); } } ``` **Migration Naming Convention**: ``` 20250101000000_InitialCreate.cs 20250115120000_AddUserSettings.cs 20250201093000_AddPlaylistSupport.cs 20250315164500_AddScrobbling.cs ``` Timestamps ensure migrations apply in chronological order. ### Automatic Migration Application Docker entrypoint applies migrations on startup: ```bash #!/bin/bash set -e echo "Applying database migrations..." dotnet ef database update --project Melodee.Data --no-build echo "Starting Melodee..." exec dotnet Melodee.Web.dll ``` This ensures the database schema matches the application version without manual intervention. ### Migration Rollback Rollback to a specific migration: ```bash dotnet ef database update 20250201093000_AddPlaylistSupport ``` This reverts all migrations applied after the specified migration. **Caution**: Rollback can cause data loss if migrations drop columns or tables. Always backup before rollback. ## Data Access Patterns ### Repository Pattern ```csharp public interface IAlbumRepository { Task GetByIdAsync(int id); Task> GetAllAsync(); Task> GetByArtistAsync(int artistId); Task> SearchAsync(string query); Task CreateAsync(Album album); Task UpdateAsync(Album album); Task DeleteAsync(int id); } public class AlbumRepository : IAlbumRepository { private readonly MelodeeDbContext _context; public AlbumRepository(MelodeeDbContext context) { _context = context; } public async Task GetByIdAsync(int id) { return await _context.Albums .Include(a => a.Artist) .Include(a => a.Tracks) .FirstOrDefaultAsync(a => a.Id == id); } public async Task> 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(); } } ``` ### Unit of Work Pattern ```csharp public interface IUnitOfWork : IDisposable { IAlbumRepository Albums { get; } IArtistRepository Artists { get; } ITrackRepository Tracks { get; } IPlaylistRepository Playlists { get; } IScrobbleRepository Scrobbles { get; } Task SaveChangesAsync(); } public class UnitOfWork : IUnitOfWork { private readonly MelodeeDbContext _context; public UnitOfWork(MelodeeDbContext context) { _context = context; Albums = new AlbumRepository(context); Artists = new ArtistRepository(context); // ... } public IAlbumRepository Albums { get; } public IArtistRepository Artists { get; } // ... public async Task SaveChangesAsync() { return await _context.SaveChangesAsync(); } public void Dispose() { _context.Dispose(); } } ``` **Usage**: ```csharp public class LibraryService { private readonly IUnitOfWork _unitOfWork; public async Task CreateAlbumAsync(CreateAlbumRequest request) { var artist = await _unitOfWork.Artists.GetByIdAsync(request.ArtistId); if (artist == null) throw new NotFoundException("Artist not found"); var album = new Album { Title = request.Title, ArtistId = request.ArtistId, ReleaseDate = request.ReleaseDate }; await _unitOfWork.Albums.CreateAsync(album); await _unitOfWork.SaveChangesAsync(); return album; } } ``` ### Query Optimization **N+1 Query Problem**: ```csharp // BAD: N+1 queries var albums = await _context.Albums.ToListAsync(); foreach (var album in albums) { // Each iteration triggers a separate query var artist = await _context.Artists.FindAsync(album.ArtistId); } // GOOD: Single query with join var albums = await _context.Albums .Include(a => a.Artist) .ToListAsync(); ``` **Projection for Performance**: ```csharp // BAD: Loads entire entity var albums = await _context.Albums .Include(a => a.Artist) .Include(a => a.Tracks) .ToListAsync(); // GOOD: Projects only needed fields var albums = await _context.Albums .Select(a => new AlbumDto { Id = a.Id, Title = a.Title, ArtistName = a.Artist.Name, TrackCount = a.Tracks.Count }) .ToListAsync(); ``` **Pagination**: ```csharp public async Task> GetAlbumsAsync(int page, int pageSize) { var query = _context.Albums.Include(a => a.Artist); var totalCount = await query.CountAsync(); var items = await query .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResult { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize }; } ``` ### Full-Text Search PostgreSQL full-text search for library queries: ```sql -- Add tsvector column ALTER TABLE albums ADD COLUMN search_vector tsvector; -- Populate search vector UPDATE albums SET search_vector = to_tsvector('english', title || ' ' || COALESCE(label, '')); -- Create GIN index CREATE INDEX idx_albums_search_vector ON albums USING GIN(search_vector); -- Search query SELECT * FROM albums WHERE search_vector @@ to_tsquery('english', 'radiohead & computer') ORDER BY ts_rank(search_vector, to_tsquery('english', 'radiohead & computer')) DESC; ``` **EF Core Integration**: ```csharp public async Task> FullTextSearchAsync(string query) { return await _context.Albums .FromSqlRaw(@" SELECT * FROM albums WHERE search_vector @@ to_tsquery('english', {0}) ORDER BY ts_rank(search_vector, to_tsquery('english', {0})) DESC ", query) .ToListAsync(); } ``` ## Data Integrity ### Referential Integrity Foreign key constraints ensure data consistency: ```sql -- Cascade delete: deleting an artist deletes all albums ALTER TABLE albums ADD CONSTRAINT fk_albums_artist FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE; -- Restrict delete: cannot delete a track if it's in a playlist ALTER TABLE playlist_tracks ADD CONSTRAINT fk_playlist_tracks_track FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE RESTRICT; ``` ### Check Constraints ```sql -- Ensure valid rating range ALTER TABLE albums ADD CONSTRAINT chk_albums_rating CHECK (rating >= 0 AND rating <= 5); -- Ensure positive duration ALTER TABLE tracks ADD CONSTRAINT chk_tracks_duration CHECK (duration > 0); -- Ensure valid role ALTER TABLE users ADD CONSTRAINT chk_users_role CHECK (role IN ('admin', 'user')); ``` ### Unique Constraints ```sql -- Prevent duplicate artists ALTER TABLE artists ADD CONSTRAINT uq_artists_name UNIQUE (name); -- Prevent duplicate tracks in same album ALTER TABLE tracks ADD CONSTRAINT uq_tracks_album_position UNIQUE (album_id, position); -- Prevent duplicate playlists for same user ALTER TABLE playlists ADD CONSTRAINT uq_playlists_user_name UNIQUE (user_id, name); ``` ## Data Backup and Recovery ### PostgreSQL Backup **Automated Daily Backups**: ```bash #!/bin/bash BACKUP_DIR="/backups/postgres" TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_FILE="$BACKUP_DIR/melodee_$TIMESTAMP.sql.gz" pg_dump -h postgres -U melodee melodee | gzip > $BACKUP_FILE # Retain last 30 days find $BACKUP_DIR -name "melodee_*.sql.gz" -mtime +30 -delete ``` **Restore from Backup**: ```bash gunzip -c /backups/postgres/melodee_20250428_103000.sql.gz | psql -h postgres -U melodee melodee ``` ### SQLite Cache Backup ```bash # Copy SQLite database file cp /data/mb-cache.db /backups/mb-cache_$(date +%Y%m%d).db ``` SQLite backups are smaller and less critical (cache can be rebuilt from MusicBrainz). ## Performance Considerations ### Indexing Strategy **Index Coverage**: - Primary keys: Automatic clustered indexes - Foreign keys: Non-clustered indexes for join performance - Search fields: Indexes on `name`, `title`, `email` - JSONB fields: GIN indexes for JSON queries - Full-text search: GIN indexes on tsvector columns **Index Maintenance**: ```sql -- Analyze tables for query planner ANALYZE albums; -- Reindex to rebuild fragmented indexes REINDEX TABLE albums; -- Vacuum to reclaim space VACUUM ANALYZE albums; ``` ### Connection Pooling EF Core uses connection pooling by default: ```csharp services.AddDbContext(options => { options.UseNpgsql(connectionString, npgsqlOptions => { npgsqlOptions.MinBatchSize(1); npgsqlOptions.MaxBatchSize(100); npgsqlOptions.CommandTimeout(30); }); }); ``` **Connection String**: ``` Host=postgres;Database=melodee;Username=melodee;Password=melodee;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=20; ``` ### Query Caching **In-Memory Cache**: ```csharp public class CachedAlbumRepository : IAlbumRepository { private readonly IAlbumRepository _inner; private readonly IMemoryCache _cache; public async Task GetByIdAsync(int id) { return await _cache.GetOrCreateAsync($"album:{id}", async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15); return await _inner.GetByIdAsync(id); }); } } ``` **Redis Cache** (for distributed deployments): ```csharp public class RedisAlbumRepository : IAlbumRepository { private readonly IAlbumRepository _inner; private readonly IConnectionMultiplexer _redis; public async Task GetByIdAsync(int id) { var db = _redis.GetDatabase(); var cached = await db.StringGetAsync($"album:{id}"); if (cached.HasValue) return JsonSerializer.Deserialize(cached); var album = await _inner.GetByIdAsync(id); await db.StringSetAsync($"album:{id}", JsonSerializer.Serialize(album), TimeSpan.FromHours(1)); return album; } } ``` ## Conclusion Melodee's data architecture demonstrates thoughtful design for a music server application. The dual-database approach (PostgreSQL for transactional data, SQLite for reference data) optimizes for different access patterns. The 40+ entity model covers all aspects of music library management, user accounts, playlists, and scrobbling. Key strengths: - **JSONB for flexibility**: External IDs and metadata from multiple providers - **Full-text search**: Fast library searching without external dependencies - **Automatic migrations**: Docker entrypoint ensures schema consistency - **Repository pattern**: Clean separation between data access and business logic - **Comprehensive indexing**: Optimized for common query patterns Key challenges: - **100+ migrations**: Complex upgrade paths, potential for migration conflicts - **Denormalized data**: Track counts and durations require trigger maintenance - **Cache invalidation**: Multiple caching layers increase complexity The architecture positions Melodee for scalability and maintainability while supporting rich metadata aggregation and user features.