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. Lyrics Model
  10. Backup Model
  11. Request/Response DTOs
  12. 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 7 tables: songs, playlists, playlist_songs, configs, auth_tokens, js_plugins, and plugin_storage, evolved from the initial version to the current state through 25 migration files. 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 + 3 runtime-computed fields, carrying three business forms: local songs, network songs, and radio. 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), fingerprint_duration(0008), 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 type songAlias Song trick avoids recursion. 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 '[]')
SongCountintsong_countRuntime-computed (not persisted)

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


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. Lyrics Model

9.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.

9.2 LyricSource Enum

ValueDescription
file.lrc file in the same directory
embeddedLyrics embedded in the audio
urlFetched on demand at runtime (LyricRemoteURL holds the address)
cachedText cached after URL fetch
manualManually adjusted by the user, not overwritten by scanning (0005 migration extends the CHECK by rebuilding the table)

Section sources


10. 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


11. 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 -> downloading -> testing -> replacing -> restarting (exceptions failed/resetting)

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

Section sources


12. Model Relationship Class Diagram

Diagram sources