# Meelo Data Model ## Database Overview Meelo uses PostgreSQL with Prisma ORM. The schema defines 30+ models representing music metadata, relationships, and system entities. The design prioritizes flexibility and accuracy over simplicity. **Schema File**: `server/prisma/schema.prisma` (910 lines) **Migrations**: Automatic via Prisma Migrate **Indexes**: Foreign keys, unique constraints, frequently queried fields ## Core Principles ### Album vs Release Albums are abstract concepts. Releases are physical/digital manifestations. One album can have multiple releases (original, remaster, deluxe edition). This mirrors real-world music organization. ### Song vs Track Songs are compositions. Tracks are recordings. One song can have multiple tracks (studio version, live version, acoustic version). Tracks link to source files. ### Versioning Songs can belong to song groups, representing different versions of the same composition (original, cover, remix). This enables tracking relationships between versions. ### External Metadata Descriptions, ratings, and external IDs are stored separately from core entities. This allows multiple sources without polluting main tables. ## Entity Relationship Diagram ``` User ──┬─── Playlist ─── PlaylistEntry ─── Track └─── UserScrobbler Artist ──┬─── Album ─── Release ─── Track ├─── Song ──┬─── Track │ └─── SongGroup ├─── Video └─── ArtistArea ─── Area Album ──┬─── AlbumGenre ─── Genre └─── AlbumLabel ─── Label Song ──┬─── SongArtist (featuring) ├─── SongGenre ─── Genre └─── Lyrics Track ─── File ─── Library ExternalMetadata ─── ExternalSource LocalIdentifiers Illustration ``` ## User Model Represents system users with authentication and authorization. ```prisma model User { id Int @id @default(autoincrement()) username String @unique password String // bcrypt hash isAdmin Boolean @default(false) createdAt DateTime @default(now()) playlists Playlist[] scrobblers UserScrobbler[] } ``` **Fields**: - `username`: Unique identifier for login - `password`: Bcrypt hash with salt rounds = 10 - `isAdmin`: Admin flag for privileged operations - `createdAt`: Registration timestamp **Relationships**: - One-to-many with Playlist (user owns playlists) - One-to-many with UserScrobbler (user connects scrobblers) **Constraints**: - Username must be unique - Password must be hashed (never stored plain) - First user becomes admin automatically ## Artist Model Represents musical artists (individuals or groups). ```prisma model Artist { id Int @id @default(autoincrement()) name String slug String @unique sortName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt illustrationId Int? @unique illustration Illustration? @relation(fields: [illustrationId]) albums Album[] songs Song[] videos Video[] areas ArtistArea[] externalMetadata ExternalMetadata? localIdentifiers LocalIdentifiers? } ``` **Fields**: - `name`: Display name (e.g., "The Beatles") - `slug`: URL-safe identifier (e.g., "the-beatles") - `sortName`: Name for alphabetical sorting (e.g., "Beatles, The") - `illustrationId`: Foreign key to artist image **Relationships**: - One-to-one with Illustration (artist image) - One-to-many with Album (artist's albums) - One-to-many with Song (artist's songs) - One-to-many with Video (artist's videos) - Many-to-many with Area via ArtistArea (geographic associations) - One-to-one with ExternalMetadata (descriptions, ratings) - One-to-one with LocalIdentifiers (MusicBrainz ID, etc.) **Constraints**: - Slug must be unique - Name is required ## Album Model Represents abstract album concepts. ```prisma model Album { id Int @id @default(autoincrement()) name String slug String @unique type AlbumType releaseDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt artistId Int artist Artist @relation(fields: [artistId]) illustrationId Int? @unique illustration Illustration? @relation(fields: [illustrationId]) releases Release[] genres AlbumGenre[] labels AlbumLabel[] externalMetadata ExternalMetadata? localIdentifiers LocalIdentifiers? } ``` **Fields**: - `name`: Album title - `slug`: URL-safe identifier - `type`: Album type enum (see below) - `releaseDate`: Original release date - `artistId`: Foreign key to primary artist **Album Types** (enum): - `studio`: Studio album - `live`: Live album - `compilation`: Compilation - `soundtrack`: Soundtrack - `ep`: Extended play - `single`: Single - `remix`: Remix album - `bootleg`: Bootleg - `interview`: Interview - `other`: Other **Relationships**: - Many-to-one with Artist (album belongs to artist) - One-to-many with Release (album has multiple releases) - Many-to-many with Genre via AlbumGenre - Many-to-many with Label via AlbumLabel - One-to-one with Illustration (album cover) - One-to-one with ExternalMetadata - One-to-one with LocalIdentifiers ## Release Model Represents physical or digital album releases. ```prisma model Release { id Int @id @default(autoincrement()) name String releaseDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt albumId Int album Album @relation(fields: [albumId]) labelId Int? label Label? @relation(fields: [labelId]) tracks Track[] extensions Json? // catalogNumber, barcode, etc. } ``` **Fields**: - `name`: Release name (e.g., "Abbey Road (2019 Remaster)") - `releaseDate`: This release's date (may differ from album) - `albumId`: Foreign key to parent album - `labelId`: Foreign key to record label - `extensions`: JSON field for additional metadata (catalog number, barcode, format) **Relationships**: - Many-to-one with Album (release belongs to album) - Many-to-one with Label (release issued by label) - One-to-many with Track (release contains tracks) **Extensions JSON**: ```json { "catalogNumber": "PCS 7088", "barcode": "5099969945724", "format": "CD", "country": "UK" } ``` ## Song Model Represents musical compositions. ```prisma model Song { id Int @id @default(autoincrement()) name String slug String @unique type SongType bpm Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt artistId Int artist Artist @relation(fields: [artistId]) groupId Int? group SongGroup? @relation(fields: [groupId]) tracks Track[] featuring SongArtist[] genres SongGenre[] lyrics Lyrics? externalMetadata ExternalMetadata? localIdentifiers LocalIdentifiers? } ``` **Fields**: - `name`: Song title - `slug`: URL-safe identifier - `type`: Song type enum (see below) - `bpm`: Beats per minute (tempo) - `artistId`: Foreign key to primary artist - `groupId`: Foreign key to song group (for versions) **Song Types** (enum): - `original`: Original recording - `live`: Live performance - `acoustic`: Acoustic version - `remix`: Remix - `cover`: Cover version - `demo`: Demo recording - `instrumental`: Instrumental - `karaoke`: Karaoke version - `radio_edit`: Radio edit - `extended`: Extended version - `clean`: Clean version - `explicit`: Explicit version **Relationships**: - Many-to-one with Artist (song's main artist) - Many-to-one with SongGroup (song belongs to version group) - One-to-many with Track (song has multiple recordings) - Many-to-many with Artist via SongArtist (featuring artists) - Many-to-many with Genre via SongGenre - One-to-one with Lyrics - One-to-one with ExternalMetadata - One-to-one with LocalIdentifiers ## Track Model Represents individual audio or video recordings. ```prisma model Track { id Int @id @default(autoincrement()) name String type TrackType duration Int // seconds bitrate Int? // bits per second codec String? ripSource RipSource? isBonus Boolean @default(false) isRemastered Boolean @default(false) discIndex Int? trackIndex Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt songId Int song Song @relation(fields: [songId]) releaseId Int release Release @relation(fields: [releaseId]) sourceFileId Int @unique sourceFile File @relation(fields: [sourceFileId]) playlistEntries PlaylistEntry[] } ``` **Fields**: - `name`: Track name (may differ from song name) - `type`: Audio or video - `duration`: Length in seconds - `bitrate`: Bitrate in bits per second - `codec`: Audio/video codec (mp3, flac, h264, etc.) - `ripSource`: Source of rip (CD, vinyl, web, etc.) - `isBonus`: Bonus track flag - `isRemastered`: Remastered flag - `discIndex`: Disc number (for multi-disc releases) - `trackIndex`: Track number on disc - `songId`: Foreign key to song - `releaseId`: Foreign key to release - `sourceFileId`: Foreign key to file **Track Types** (enum): - `audio`: Audio track - `video`: Video track **Rip Source Types** (enum): - `cd`: CD rip - `vinyl`: Vinyl rip - `web`: Web download - `stream`: Stream rip - `other`: Other source **Relationships**: - Many-to-one with Song (track is recording of song) - Many-to-one with Release (track belongs to release) - One-to-one with File (track links to source file) - One-to-many with PlaylistEntry (track appears in playlists) ## File Model Represents physical files on disk. ```prisma model File { id Int @id @default(autoincrement()) path String @unique checksum String fingerprint String? // AcoustID fingerprint createdAt DateTime @default(now()) updatedAt DateTime @updatedAt libraryId Int library Library @relation(fields: [libraryId]) track Track? } ``` **Fields**: - `path`: Absolute file path - `checksum`: SHA256 hash for duplicate detection - `fingerprint`: AcoustID fingerprint for matching - `libraryId`: Foreign key to library **Relationships**: - Many-to-one with Library (file belongs to library) - One-to-one with Track (file may be linked to track) **Constraints**: - Path must be unique - Checksum used for duplicate detection - Fingerprint used for MusicBrainz matching ## Library Model Represents music library directories. ```prisma model Library { id Int @id @default(autoincrement()) name String path String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt files File[] } ``` **Fields**: - `name`: Display name - `path`: Absolute directory path **Relationships**: - One-to-many with File (library contains files) **Constraints**: - Path must be unique - Path must be absolute - Scanner watches this directory ## Video Model Represents music videos. ```prisma model Video { id Int @id @default(autoincrement()) name String slug String @unique type VideoType duration Int // seconds createdAt DateTime @default(now()) updatedAt DateTime @updatedAt artistId Int artist Artist @relation(fields: [artistId]) songId Int? song Song? @relation(fields: [songId]) sourceFileId Int @unique sourceFile File @relation(fields: [sourceFileId]) } ``` **Fields**: - `name`: Video title - `slug`: URL-safe identifier - `type`: Video type enum (see below) - `duration`: Length in seconds - `artistId`: Foreign key to artist - `songId`: Foreign key to song (optional, for non-song videos) - `sourceFileId`: Foreign key to file **Video Types** (enum): - `official`: Official music video - `live`: Live performance - `lyric`: Lyric video - `audio`: Audio-only video - `behind_the_scenes`: Behind the scenes - `interview`: Interview - `documentary`: Documentary - `fan_made`: Fan-made video - `other`: Other **Relationships**: - Many-to-one with Artist (video belongs to artist) - Many-to-one with Song (video may be for song) - One-to-one with File (video links to source file) ## SongGroup Model Represents groups of song versions. ```prisma model SongGroup { id Int @id @default(autoincrement()) name String slug String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt songs Song[] } ``` **Fields**: - `name`: Group name (e.g., "Hallelujah") - `slug`: URL-safe identifier **Relationships**: - One-to-many with Song (group contains song versions) **Use Case**: Group original, covers, remixes of same composition. Example: - "Hallelujah" by Leonard Cohen (original) - "Hallelujah" by Jeff Buckley (cover) - "Hallelujah" by Pentatonix (cover) ## Genre Model Represents music genres. ```prisma model Genre { id Int @id @default(autoincrement()) name String @unique slug String @unique albums AlbumGenre[] songs SongGenre[] } ``` **Fields**: - `name`: Genre name (e.g., "Rock") - `slug`: URL-safe identifier **Relationships**: - Many-to-many with Album via AlbumGenre - Many-to-many with Song via SongGenre ## Label Model Represents record labels. ```prisma model Label { id Int @id @default(autoincrement()) name String @unique slug String @unique releases Release[] albums AlbumLabel[] } ``` **Fields**: - `name`: Label name (e.g., "Apple Records") - `slug`: URL-safe identifier **Relationships**: - One-to-many with Release (label issues releases) - Many-to-many with Album via AlbumLabel ## Area Model Represents geographic areas. ```prisma model Area { id Int @id @default(autoincrement()) name String type AreaType iso3166 String? // ISO 3166 code parentId Int? parent Area? @relation("AreaHierarchy", fields: [parentId]) children Area[] @relation("AreaHierarchy") artists ArtistArea[] } ``` **Fields**: - `name`: Area name (e.g., "Liverpool") - `type`: Area type enum (see below) - `iso3166`: ISO 3166 code (e.g., "GB-LIV") - `parentId`: Foreign key to parent area (for hierarchy) **Area Types** (enum): - `country`: Country - `subdivision`: State/province - `county`: County - `municipality`: City/town - `city`: City - `district`: District - `island`: Island **Relationships**: - Self-referential many-to-one (area has parent) - Self-referential one-to-many (area has children) - Many-to-many with Artist via ArtistArea **Hierarchy Example**: ``` United Kingdom (country) └─ England (subdivision) └─ Merseyside (county) └─ Liverpool (city) ``` ## Lyrics Model Represents song lyrics. ```prisma model Lyrics { id Int @id @default(autoincrement()) plain String? @db.Text synced Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt songId Int @unique song Song @relation(fields: [songId]) } ``` **Fields**: - `plain`: Plain text lyrics - `synced`: Synced lyrics in JSON format (see below) - `songId`: Foreign key to song **Synced Lyrics Format**: ```json [ { "time": 0, "text": "Here come old flat top" }, { "time": 3500, "text": "He come grooving up slowly" }, { "time": 7200, "text": "He got joo-joo eyeball" } ] ``` Time in milliseconds. Used for karaoke-style display. **Relationships**: - One-to-one with Song (song has lyrics) ## Playlist Model Represents user playlists. ```prisma model Playlist { id Int @id @default(autoincrement()) name String slug String @unique isPublic Boolean @default(false) allowChanges Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ownerId Int owner User @relation(fields: [ownerId]) entries PlaylistEntry[] } ``` **Fields**: - `name`: Playlist name - `slug`: URL-safe identifier - `isPublic`: Public visibility flag - `allowChanges`: Allow non-owners to modify flag - `ownerId`: Foreign key to owner **Relationships**: - Many-to-one with User (playlist belongs to user) - One-to-many with PlaylistEntry (playlist contains entries) **Access Control**: - Owner can always modify - Others can modify if `allowChanges=true` - Others can view if `isPublic=true` ## PlaylistEntry Model Represents tracks in playlists. ```prisma model PlaylistEntry { id Int @id @default(autoincrement()) index Int createdAt DateTime @default(now()) playlistId Int playlist Playlist @relation(fields: [playlistId]) trackId Int track Track @relation(fields: [trackId]) @@unique([playlistId, index]) } ``` **Fields**: - `index`: Position in playlist (0-based) - `playlistId`: Foreign key to playlist - `trackId`: Foreign key to track **Relationships**: - Many-to-one with Playlist (entry belongs to playlist) - Many-to-one with Track (entry references track) **Constraints**: - Unique combination of playlistId and index (no duplicate positions) ## Illustration Model Represents images (artist photos, album covers). ```prisma model Illustration { id Int @id @default(autoincrement()) url String blurhash String colors String[] aspectRatio Float type IllustrationType createdAt DateTime @default(now()) artist Artist? album Album? } ``` **Fields**: - `url`: Image URL or path - `blurhash`: BlurHash string for placeholder - `colors`: Dominant colors (hex codes) - `aspectRatio`: Width/height ratio - `type`: Illustration type enum (see below) **Illustration Types** (enum): - `artist`: Artist photo - `album`: Album cover - `banner`: Banner image **Relationships**: - One-to-one with Artist (artist has illustration) - One-to-one with Album (album has illustration) **BlurHash**: Compact representation of image for placeholder. Decoded client-side for instant preview while loading full image. ## ExternalMetadata Model Represents metadata from external providers. ```prisma model ExternalMetadata { id Int @id @default(autoincrement()) description String? @db.Text rating Int? // 0-100 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt artistId Int? @unique artist Artist? @relation(fields: [artistId]) albumId Int? @unique album Album? @relation(fields: [albumId]) songId Int? @unique song Song? @relation(fields: [songId]) sources ExternalSource[] } ``` **Fields**: - `description`: Aggregated description from providers - `rating`: Aggregated rating (0-100 scale) - `artistId`, `albumId`, `songId`: Foreign keys (one of these set) **Relationships**: - One-to-one with Artist, Album, or Song - One-to-many with ExternalSource (metadata has sources) **Rating Scale**: Normalized to 0-100 regardless of provider scale. Example: - Metacritic: 85/100 → 85 - AllMusic: 4.5/5 → 90 ## ExternalSource Model Represents individual provider sources. ```prisma model ExternalSource { id Int @id @default(autoincrement()) provider String url String externalMetadataId Int externalMetadata ExternalMetadata @relation(fields: [externalMetadataId]) } ``` **Fields**: - `provider`: Provider name (musicbrainz, genius, wikipedia, etc.) - `url`: Source URL - `externalMetadataId`: Foreign key to parent metadata **Relationships**: - Many-to-one with ExternalMetadata (source belongs to metadata) **Providers**: - `musicbrainz`: MusicBrainz - `genius`: Genius - `wikipedia`: Wikipedia - `wikidata`: Wikidata - `discogs`: Discogs - `allmusic`: AllMusic - `metacritic`: Metacritic - `lrclib`: LrcLib ## LocalIdentifiers Model Represents external IDs for matching. ```prisma model LocalIdentifiers { id Int @id @default(autoincrement()) musicbrainzId String? acoustidId String? discogsId String? artistId Int? @unique artist Artist? @relation(fields: [artistId]) albumId Int? @unique album Album? @relation(fields: [albumId]) songId Int? @unique song Song? @relation(fields: [songId]) } ``` **Fields**: - `musicbrainzId`: MusicBrainz MBID - `acoustidId`: AcoustID fingerprint - `discogsId`: Discogs ID - `artistId`, `albumId`, `songId`: Foreign keys (one of these set) **Relationships**: - One-to-one with Artist, Album, or Song **Use Case**: Store external IDs for cross-referencing and matching. AcoustID used for initial matching, MusicBrainz ID for enrichment. ## UserScrobbler Model Represents user scrobbler connections. ```prisma model UserScrobbler { id Int @id @default(autoincrement()) provider String username String data Json // provider-specific data (tokens, etc.) connectedAt DateTime @default(now()) userId Int user User @relation(fields: [userId]) @@unique([userId, provider]) } ``` **Fields**: - `provider`: Scrobbler provider (lastfm, listenbrainz) - `username`: Username on provider - `data`: JSON field for provider-specific data - `userId`: Foreign key to user **Relationships**: - Many-to-one with User (user has scrobblers) **Constraints**: - Unique combination of userId and provider (one connection per provider) **Data JSON**: ```json { "accessToken": "...", "sessionKey": "...", "expiresAt": 1705320600 } ``` ## Join Tables ### ArtistArea Links artists to geographic areas. ```prisma model ArtistArea { artistId Int artist Artist @relation(fields: [artistId]) areaId Int area Area @relation(fields: [areaId]) @@id([artistId, areaId]) } ``` ### AlbumGenre Links albums to genres. ```prisma model AlbumGenre { albumId Int album Album @relation(fields: [albumId]) genreId Int genre Genre @relation(fields: [genreId]) @@id([albumId, genreId]) } ``` ### AlbumLabel Links albums to labels. ```prisma model AlbumLabel { albumId Int album Album @relation(fields: [albumId]) labelId Int label Label @relation(fields: [labelId]) @@id([albumId, labelId]) } ``` ### SongArtist Links songs to featuring artists. ```prisma model SongArtist { songId Int song Song @relation(fields: [songId]) artistId Int artist Artist @relation(fields: [artistId]) @@id([songId, artistId]) } ``` ### SongGenre Links songs to genres. ```prisma model SongGenre { songId Int song Song @relation(fields: [songId]) genreId Int genre Genre @relation(fields: [genreId]) @@id([songId, genreId]) } ``` ## MeiliSearch Indexes MeiliSearch indexes four entity types for full-text search. ### Artist Index **Indexed Fields**: - `id`, `name`, `slug`, `sortName` **Searchable Attributes**: - `name`, `sortName` **Filterable Attributes**: - None **Sortable Attributes**: - `name`, `sortName` ### Album Index **Indexed Fields**: - `id`, `name`, `slug`, `type`, `releaseDate`, `artistName` **Searchable Attributes**: - `name`, `artistName` **Filterable Attributes**: - `type`, `releaseDate` **Sortable Attributes**: - `name`, `releaseDate` ### Song Index **Indexed Fields**: - `id`, `name`, `slug`, `type`, `artistName` **Searchable Attributes**: - `name`, `artistName` **Filterable Attributes**: - `type` **Sortable Attributes**: - `name` ### Video Index **Indexed Fields**: - `id`, `name`, `slug`, `type`, `artistName` **Searchable Attributes**: - `name`, `artistName` **Filterable Attributes**: - `type` **Sortable Attributes**: - `name` ## Data Integrity ### Cascading Deletes Prisma handles cascading deletes: - Delete artist → delete albums, songs, videos - Delete album → delete releases - Delete release → delete tracks - Delete track → orphan file (not deleted) - Delete library → orphan files (not deleted) ### Orphan Cleanup Scanner's `/clean` endpoint removes orphaned files and tracks. ### Unique Constraints - Artist slug - Album slug - Song slug - Video slug - Playlist slug - File path - Genre name - Label name - User username ### Foreign Key Constraints All foreign keys enforced by PostgreSQL. Invalid references rejected. ## Data Migration Prisma Migrate handles schema changes: 1. Modify `schema.prisma` 2. Run `prisma migrate dev --name ` 3. Prisma generates SQL migration 4. Migration applied to database 5. Prisma Client regenerated Migrations stored in `prisma/migrations/` directory. ## Seeding Initial data seeded on first run: - Admin user (if none exists) - Default genres (Rock, Pop, Jazz, etc.) - Area hierarchy (countries, cities) Seed script: `prisma/seed.ts` ## Performance Considerations ### Indexes Prisma creates indexes on: - Primary keys (automatic) - Foreign keys (automatic) - Unique constraints (automatic) - Custom indexes for frequently queried fields ### Query Optimization - Use `include` for eager loading (avoid N+1) - Use `select` to fetch only needed fields - Use pagination (`skip`, `take`) for large result sets - Use `where` filters to reduce result size ### Connection Pooling Prisma uses connection pooling. Configure in `schema.prisma`: ```prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") pool_size = 10 } ``` ## Backup and Restore ### Backup ```bash docker exec meelo-db pg_dump -U postgres meelo > backup.sql ``` ### Restore ```bash docker exec -i meelo-db psql -U postgres meelo < backup.sql ``` ### Volume Backup ```bash docker run --rm -v meelo_db:/data -v $(pwd):/backup alpine tar czf /backup/db.tar.gz /data ``` ## Summary Meelo's data model is the most sophisticated among self-hosted music servers. The Album/Release and Song/Track distinctions enable accurate representation of real-world music organization. Song groups track versions and covers. External metadata and local identifiers separate provider data from core entities. The schema supports complex queries (artist with all albums, releases, tracks, genres) while maintaining referential integrity. MeiliSearch indexes provide fast full-text search. Prisma ORM handles migrations, type safety, and query optimization.