- 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
28 KiB
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.
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 loginpassword: Bcrypt hash with salt rounds = 10isAdmin: Admin flag for privileged operationscreatedAt: 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).
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.
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 titleslug: URL-safe identifiertype: Album type enum (see below)releaseDate: Original release dateartistId: Foreign key to primary artist
Album Types (enum):
studio: Studio albumlive: Live albumcompilation: Compilationsoundtrack: Soundtrackep: Extended playsingle: Singleremix: Remix albumbootleg: Bootleginterview: Interviewother: 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.
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 albumlabelId: Foreign key to record labelextensions: 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:
{
"catalogNumber": "PCS 7088",
"barcode": "5099969945724",
"format": "CD",
"country": "UK"
}
Song Model
Represents musical compositions.
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 titleslug: URL-safe identifiertype: Song type enum (see below)bpm: Beats per minute (tempo)artistId: Foreign key to primary artistgroupId: Foreign key to song group (for versions)
Song Types (enum):
original: Original recordinglive: Live performanceacoustic: Acoustic versionremix: Remixcover: Cover versiondemo: Demo recordinginstrumental: Instrumentalkaraoke: Karaoke versionradio_edit: Radio editextended: Extended versionclean: Clean versionexplicit: 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.
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 videoduration: Length in secondsbitrate: Bitrate in bits per secondcodec: Audio/video codec (mp3, flac, h264, etc.)ripSource: Source of rip (CD, vinyl, web, etc.)isBonus: Bonus track flagisRemastered: Remastered flagdiscIndex: Disc number (for multi-disc releases)trackIndex: Track number on discsongId: Foreign key to songreleaseId: Foreign key to releasesourceFileId: Foreign key to file
Track Types (enum):
audio: Audio trackvideo: Video track
Rip Source Types (enum):
cd: CD ripvinyl: Vinyl ripweb: Web downloadstream: Stream ripother: 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.
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 pathchecksum: SHA256 hash for duplicate detectionfingerprint: AcoustID fingerprint for matchinglibraryId: 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.
model Library {
id Int @id @default(autoincrement())
name String
path String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
files File[]
}
Fields:
name: Display namepath: 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.
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 titleslug: URL-safe identifiertype: Video type enum (see below)duration: Length in secondsartistId: Foreign key to artistsongId: Foreign key to song (optional, for non-song videos)sourceFileId: Foreign key to file
Video Types (enum):
official: Official music videolive: Live performancelyric: Lyric videoaudio: Audio-only videobehind_the_scenes: Behind the scenesinterview: Interviewdocumentary: Documentaryfan_made: Fan-made videoother: 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.
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.
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.
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.
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: Countrysubdivision: State/provincecounty: Countymunicipality: City/towncity: Citydistrict: Districtisland: 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.
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 lyricssynced: Synced lyrics in JSON format (see below)songId: Foreign key to song
Synced Lyrics Format:
[
{ "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.
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 nameslug: URL-safe identifierisPublic: Public visibility flagallowChanges: Allow non-owners to modify flagownerId: 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.
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 playlisttrackId: 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).
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 pathblurhash: BlurHash string for placeholdercolors: Dominant colors (hex codes)aspectRatio: Width/height ratiotype: Illustration type enum (see below)
Illustration Types (enum):
artist: Artist photoalbum: Album coverbanner: 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.
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 providersrating: 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.
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 URLexternalMetadataId: Foreign key to parent metadata
Relationships:
- Many-to-one with ExternalMetadata (source belongs to metadata)
Providers:
musicbrainz: MusicBrainzgenius: Geniuswikipedia: Wikipediawikidata: Wikidatadiscogs: Discogsallmusic: AllMusicmetacritic: Metacriticlrclib: LrcLib
LocalIdentifiers Model
Represents external IDs for matching.
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 MBIDacoustidId: AcoustID fingerprintdiscogsId: Discogs IDartistId,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.
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 providerdata: JSON field for provider-specific datauserId: 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:
{
"accessToken": "...",
"sessionKey": "...",
"expiresAt": 1705320600
}
Join Tables
ArtistArea
Links artists to geographic areas.
model ArtistArea {
artistId Int
artist Artist @relation(fields: [artistId])
areaId Int
area Area @relation(fields: [areaId])
@@id([artistId, areaId])
}
AlbumGenre
Links albums to genres.
model AlbumGenre {
albumId Int
album Album @relation(fields: [albumId])
genreId Int
genre Genre @relation(fields: [genreId])
@@id([albumId, genreId])
}
AlbumLabel
Links albums to labels.
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.
model SongArtist {
songId Int
song Song @relation(fields: [songId])
artistId Int
artist Artist @relation(fields: [artistId])
@@id([songId, artistId])
}
SongGenre
Links songs to genres.
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:
- Modify
schema.prisma - Run
prisma migrate dev --name <migration_name> - Prisma generates SQL migration
- Migration applied to database
- 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
includefor eager loading (avoid N+1) - Use
selectto fetch only needed fields - Use pagination (
skip,take) for large result sets - Use
wherefilters to reduce result size
Connection Pooling
Prisma uses connection pooling. Configure in schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
pool_size = 10
}
Backup and Restore
Backup
docker exec meelo-db pg_dump -U postgres meelo > backup.sql
Restore
docker exec -i meelo-db psql -U postgres meelo < backup.sql
Volume Backup
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.