Skip to content

Data Model Design ​

This document is based on the following source files:

Table of Contents ​

  1. Overview
  2. ER Diagram
  3. Song Model
  4. Playlist Model
  5. PlaylistSong Association Model
  6. Config Model
  7. AuthToken Model
  8. JSPlugin Model
  9. ThemePack Model
  10. SongTag Custom Tag Model
  11. Artist Model
  12. Lyrics Model
  13. Backup Model
  14. Request/Response DTOs
  15. Model Relationship Class Diagram

1. Overview ​

Songloft's data persistence layer is based on SQLite, using a layered access stack of goose migration + sqlc fixed SQL + squirrel dynamic SQL. The domain models are defined in the internal/models/ package, separate from the sqlc auto-generated row-mapping models (internal/database/sqlc/models.go), with the Repository layer responsible for the conversion between the two.

The current schema has a total of 13 tables: songs, playlists, playlist_songs, play_history, configs, auth_tokens, js_plugins, plugin_storage, theme_packs, song_tags, song_tag_links, artists, song_artists, evolved from the initial version to the current state through 38 migration files (0001–0038). The updated_at column of the core tables is automatically maintained by a SQLite TRIGGER.

Section sources


2. ER Diagram ​

Diagram sources


3. Song Model ​

Song is the most core model in the system, containing 37 persisted fields + 5 runtime-computed fields (of which 3 are JSON-visible: lyric_url/source_url/source_cover_url, and 2 are used internally: CueStartSeconds/CueEndSeconds), carrying three business forms: local songs, network songs, and radio. Additionally, the database has two more columns -- fingerprint_attempted_at(0029) and fingerprint_error(0037) -- accessed separately by sqlc queries and not mapped to the Go Song struct. The type is constrained by CHECK(type IN ('local', 'remote', 'radio')).

ConstantValueRequired Field
TypeLocallocalfile_path
TypeRemoteremoteurl
TypeRadioradiourl

3.1 Field Grouping ​

Basic information: id(PK), type, title, artist, album, year(0007), genre(0007), track(0020), language(0022), style(0022), duration

File and audio: file_path, url, file_size, format, bit_rate, sample_rate, is_live, is_video(0024), file_modified_at(0019), cache_path(0014)

CUE track splitting: cue_source_path(0018), cue_track_index(0018), cue_audio_path(0018)

Cover art: cover_path(json:"-"), cover_url(rewritten on serialization)

Lyrics: lyric(json:"-", LyricPayload JSON), lyric_source(json:"-"), lyric_remote_url, lyric_url(runtime)

Plugin and deduplication: plugin_entry_path, source_data(opaque JSON), dedup_key, source_url(runtime)

Identification: fingerprint(0008, Chromaprint over the first 120s), fingerprint_duration(0008, full-file length), fingerprint_attempted_at(0029, unix seconds, 0 = never attempted; failures are persisted too so scans don't re-run ffmpeg every round), fingerprint_error(0037, records the error message on extraction failure, not in the Go Song struct, accessed separately by sqlc), isrc(0009)

Timestamps: added_at, updated_at(TRIGGER)

Section sources

3.2 Custom MarshalJSON ​

Song implements MarshalJSON(), which rewrites four fields on serialization so that clients need not concern themselves with internal storage details:

FieldRewrite LogicExample
urlPlaybackURL() unified playback endpoint/api/v1/songs/42/play
cover_urlCoverURLPath(), empty when no cover art/api/v1/songs/42/cover
lyric_urlLyricURLPath(), empty when no lyrics/api/v1/songs/42/lyric
source_urlremote/radio filled with the original URLhttps://stream.example.com/live.m3u8

The implementation uses a value-copy trick: cp := *s first copies the Song, modifies url/cover_url/lyric_url/source_url etc. on the copy, then serializes the copy via type songAlias Song (avoiding recursive MarshalJSON calls). This neither pollutes the original *Song object nor incurs the side effects of the old approach that modified the receiver directly. The original URL is kept in the database and not exposed to the client; all playback is dispatched uniformly through /api/v1/songs/{id}/play.

Section sources

3.3 PlaybackURL and HLS Rules ​

  • Ordinary songs: /api/v1/songs/{id}/play
  • HLS radio (URL suffix .m3u8/.m3u): /api/v1/songs/{id}/play.m3u8

Reason for appending the .m3u8 suffix: ExoPlayer/AVPlayer selects the MediaSource type by URL suffix, and without a suffix it falls to ProgressiveMediaSource, making HLS live streams unplayable. IsHLSStream() determines this by parsing the URL path suffix, and only takes effect for TypeRadio.

Section sources

3.4 CoverURLPath and LyricURLPath ​

Both methods follow the principle of "return the endpoint if present, return empty if absent," avoiding clients making requests destined for 404:

  • CoverURLPath: returns /api/v1/songs/{id}/cover when either CoverPath or CoverURL is non-empty
  • LyricURLPath: returns /api/v1/songs/{id}/lyric when Lyric is non-empty, or when LyricSource=="url" and LyricRemoteURL is non-empty

Section sources

3.5 DedupKey Deduplication Mechanism ​

dedup_key is used for network song deduplication, typically in the form <platform>:<platform_id>. Together with plugin_entry_path it forms a conditional unique index (partial unique index), taking effect only for rows where dedup_key != '':

sql
CREATE UNIQUE INDEX idx_songs_dedup_key_unique
    ON songs(plugin_entry_path, dedup_key) WHERE dedup_key != '';

Section sources


4. Playlist Model ​

FieldGo TypeJSONDescription
IDint64idAuto-increment primary key
Typestringtypenormal or radio (CHECK constraint)
NamestringnameGlobally unique (idx_playlists_name_unique)
DescriptionstringdescriptionDescription
CoverPathstring- (not exposed)Local cover art path
CoverURLstringcover_urlRewritten on serialization to /api/v1/playlists/{id}/cover
Labels[]stringlabelsJSON array (DB default '[]')
SortBystringsort_byView sort field (default position, options: added_at/file_modified_at/title/artist/duration) (added in 0034)
SortOrderstringsort_orderView sort direction (default asc, option: desc) (added in 0034)
SongCountintsong_countRuntime-computed (not persisted)
RemoteCountintremote_countNumber of remote songs in the playlist (songs.type=remote); >0 means "network playlist"; only populated in list APIs, always 0 in detail APIs
PinnedAt*time.Timepinned_at,omitemptyPin timestamp; nil means unpinned. Pinned playlists always sort first in listings, and multiple pinned playlists sort by this field descending (most recently pinned first)
CreatedAttime.Timecreated_atCreation time

The DB layer additionally has a position column (for ordering) and timestamp columns. MarshalJSON() rewrites cover_url just like Song.

CanAddSong validation: normal playlists accept only local/remote, and radio playlists accept only radio.

Labels: built_in marks built-in, non-deletable playlists (preset id=1 "Favorites", id=2 "Radio Favorites"), and auto_created marks playlists automatically created during scanning.

Section sources


5. PlaylistSong Association Model ​

Implements the M:N many-to-many association between playlists and songs: id(PK), playlist_id(FK), song_id(FK), position, added_at.

Key constraints: UNIQUE(playlist_id, song_id) prevents duplicate additions; ON DELETE CASCADE follows playlist/song deletion; the (playlist_id, position) composite index accelerates ordering.

Section sources


5.1 PlayHistory Model ​

Records recently played songs per "playback context", so users can return to a playlist / artist / album and pick up where they left off (songloft-org/songloft#333).

Fields: id(PK), context_type, context_key, song_id(FK), played_at, play_count.

A context is identified by the (context_type, context_key) pair:

  • Playlist → ("playlist", "<playlist ID>")
  • Custom tag → ("tag", "<tag ID>")
  • Facet dimension → ("artist", "Jay Chou") / ("album", "Fantasy") etc. Values reuse the 7 dimensions of songFacetColumn (artist/album/genre/year/decade/language/style) rather than defining a separate enum

Key constraints and design trade-offs:

ItemDescription
UNIQUE(context_type, context_key, song_id)Deduplication relies on the DB constraint plus upsert (ON CONFLICT DO UPDATE refreshes played_at and increments play_count), not on application-level lookups
50-entry cap per contextservices.MaxPlayHistoryPerContext. The insert and the trim run in the same transaction (RunInTx) so a mid-way failure cannot leave excess rows. Deliberately a Go constant rather than a config item
song_id foreign key CASCADEHistory is cleaned up automatically when a song is deleted from the library
context_key is TEXT, so no foreign key to playlistsPlaylistService explicitly calls ClearByPlaylist on deletion. Batch deletion only clears playlists that were actually removed -- built-in playlists (Favorites / Radio Favorites) are never deleted, so their history must survive
Index (context_type, context_key, played_at DESC, id DESC)played_at has second-level precision and will collide; id DESC acts as a tie-breaker so read and trim ordering stay deterministic
Song removed from a playlist but still in the libraryThe history row is kept (history is history). The client locates it via the ordered ID list on playback and reports it as stale when not found

Section sources


6. Config Model ​

Key-value pattern, with key globally unique and value being a JSON string. Preset configuration items:

keyDescriptionMigration
music_pathMusic directory and excluded directories0001
scan_configAutomatic scanning, interval, formats0001
jwt_secretJWT secret (randomly generated with randomblob)0001
source_validation / source_fallback / source_metricsAudio source validation/fallback/metrics0001
plugin_registriesOfficial plugin registry URL0006
scan_auto_create_playlistsWhether to automatically create playlists0012

Section sources


7. AuthToken Model ​

JWT dual-token authentication; the auth_tokens table records token metadata and revocation audit.

FieldDescription
token_idUnique identifier (UNIQUE)
token_typeaccess (short-lived) or refresh (long-lived), CHECK constraint
client_infoUser-Agent tracking
expires_atExpiration time
revoked_atRevocation time (nullable, sql.NullTime); checking for null tells whether it has been revoked
revoked_by / revoked_reasonRevocation audit (who, why)

TokenInfo is the client-friendly version of AuthToken (with the internal ID removed), used for the token list API.

Section sources


8. JSPlugin Model ​

Metadata and runtime status of QuickJS sandbox plugins. Core fields:

FieldDescription
entry_pathRoute prefix (UNIQUE), e.g. "myplugin"
mainEntry file (default main.js)
permissionsJSON array, e.g. ["net","storage","fs:music"]
public_pathsPath prefixes not requiring JWT (added in 0010)
external_pathsAccessible external absolute paths (added in 0013)
iconPlugin icon (added in 0011)
statusactive / inactive (default) / error, CHECK constraint
zip_hash / entry_hashSHA256 hashes, used for file fingerprint hot-reload detection
file_pathRelative path of the ZIP file

At runtime, the file system hash is compared with the database record, and a reload is automatically triggered when they mismatch.

Section sources


9. ThemePack Model ​

Declarative JSON theme packs that let users customize colors, border radius, and gradients (songloft-org/songloft#337). raw_json stores the complete original JSON, which the frontend reads and renders directly; the top-level fields are used for indexing/queries.

FieldGo TypeJSONDescription
IDint64idAuto-increment primary key
ThemeIDstringtheme_idGlobally unique identifier (UNIQUE)
NamestringnameTheme name
VersionstringversionVersion number (default 1.0.0)
AuthorstringauthorAuthor
DescriptionstringdescriptionDescription
SchemaVersionintschema_versionTheme JSON schema version (default 1)
RawJSONstringraw_jsonComplete theme JSON
CreatedAttime.Timecreated_atCreation time
UpdatedAttime.Timeupdated_atUpdate time (maintained by TRIGGER)

Section sources


10. SongTag Custom Tag Model ​

User-defined song tags, implementing the M:N many-to-many relationship between songs and tags via the song_tag_links association table.

song_tags table:

FieldTypeDescription
idINTEGER PKAuto-increment primary key
nameTEXT, UNIQUE NOCASETag name (case-insensitive unique)
colorTEXTDisplay color (default empty)
created_atDATETIMECreation time

song_tag_links table:

FieldTypeDescription
song_idINTEGER FK→ songs(id) ON DELETE CASCADE
tag_idINTEGER FK→ song_tags(id) ON DELETE CASCADE
created_atDATETIMETagging time

The primary key is the (song_id, tag_id) composite primary key. A separate index idx_song_tag_links_tag on tag_id accelerates "all songs with a given tag" queries. Bidirectional CASCADE ensures that deleting a song or a tag automatically cleans up the associations.

Section sources


11. Artist Model ​

Normalized multi-value artists: artists is the artist entity (deduplicated by normalized_key), and song_artists is the M:N many-to-many association between songs and artists, with role distinguishing lead artists (artist) from album artists (album_artist).

artists table:

FieldTypeDescription
idINTEGER PKAuto-increment primary key
nameTEXTArtist name (original format for display)
normalized_keyTEXT, UNIQUENormalized key (lower(trim(name))), used for deduplication
created_atDATETIMECreation time

song_artists table:

FieldTypeDescription
song_idINTEGER FK→ songs(id) ON DELETE CASCADE
artist_idINTEGER FK→ artists(id) ON DELETE CASCADE
roleTEXT, CHECKartist (lead) or album_artist
positionINTEGERSort position (ordering for multiple artists, default 0)
created_atDATETIMEAssociation time

The primary key is the (song_id, artist_id, role) composite primary key. Migration 0038 automatically backfills the existing songs.artist full string as a single-row association (role='artist'); multi-value splitting only occurs during rescan/refresh when tags are re-read. The songs.artist column is demoted to a display-string cache.

Section sources


12. Lyrics Model ​

12.1 LyricPayload ​

The structured storage format of the songs.lyric column, which is also the response form of /api/v1/songs/{id}/lyric:

FieldJSONDescription
LyriclyricMain lyrics (LRC text)
TlyrictlyricTranslated lyrics
RlyricrlyricRomanized lyrics
LxlyriclxlyricWord-by-word lyrics

Key methods: MarshalString() returns an empty string (not "{}") for an empty payload to preserve SQL null-check semantics; UnmarshalLyric(raw) is compatible with three historical forms -- empty string, valid JSON, and raw LRC text; ApplyLyricToSong(s, text, source) decides whether to store into Lyric or LyricRemoteURL based on the source type.

12.2 LyricSource Enum ​

ValueDescription
file.lrc file in the same directory (supports case-variant extensions and <filename>.lrc; also read at runtime with priority)
embeddedLyrics embedded in the audio
urlFetched on demand at runtime (LyricRemoteURL holds the address)
cachedText cached after URL fetch
scrapedAuto-fetched by lyrics search plugin
manualManually adjusted by the user, not overwritten by scanning (0005 migration extends the CHECK by rebuilding the table)

Section sources


13. Backup Model ​

Versioned snapshot design (currently BackupVersion = 1), exporting playlists and songs as self-describing JSON.

  • BackupData: top-level container, containing version, exported_at, playlists[]
  • BackupPlaylist: playlist snapshot (name/type/description/labels/songs[])
  • BackupSong: a slimmed-down version of Song (16 core fields), excluding reconstructable data such as ID, timestamps, lyrics, and cover art paths
  • ImportResult: import statistics -- playlists_created/playlists_merged/songs_created/songs_matched/songs_skipped

Section sources


14. Request/Response DTOs ​

Domain models are decoupled from the HTTP layer through DTOs.

Authentication: LoginRequest(username/password) -> LoginResponse(access_token/refresh_token/expires_in/token_type); RefreshTokenRequest(refresh_token); RevokeTokenRequest(reason)

Batch operations: BatchDeleteSongsRequest(ids/delete_files) -> BatchDeleteSongsResponse(deleted); BatchDeletePlaylistsRequest(ids) -> BatchDeletePlaylistsResponse(deleted)

Auto-create playlists: AutoCreatePlaylistsRequest(include_subdirs) -> AutoCreatePlaylistsResponse(playlists[]/total); sub-structure PlaylistInfo(playlist_id/name/song_count)

Configuration: CreateConfigRequest(key/value); UpdateConfigRequest(value); ConfigFilter(keyword/limit/offset/order_by/order)

Upgrade: RemoteVersionInfo(version/git_commit/build_time/download_url_prefix/release_notes); UpgradeProgress(status/progress/current_step/error), with state transitions idle -> uploading -> testing -> replacing -> restarting, idle -> downloading -> testing -> replacing -> restarting (exceptions failed/resetting); UploadedBinaryInfo (binary info returned during upload upgrade)

Generic: ErrorResponse({error, detail}); SuccessResponse({message})

Section sources


15. Model Relationship Class Diagram ​

Diagram sources