Skip to content

数据模型设计 ​

本文档基于以下源文件编写:

目录 ​

  1. 概述
  2. ER 关系图
  3. Song 歌曲模型
  4. Playlist 歌单模型
  5. PlaylistSong 关联模型
  6. Config 配置模型
  7. AuthToken 认证令牌模型
  8. JSPlugin 插件模型
  9. ThemePack 主题包模型
  10. SongTag 自定义标签模型
  11. Artist 歌手模型
  12. 歌词模型
  13. 备份模型
  14. 请求/响应 DTO
  15. 模型关系类图

1. 概述 ​

Songloft 的数据持久层基于 SQLite,采用 goose 迁移 + sqlc 固定 SQL + squirrel 动态 SQL 的分层访问栈。领域模型定义在 internal/models/ 包,与 sqlc 自动生成的行映射模型(internal/database/sqlc/models.go)分离,由 Repository 层负责两者之间的转换。

当前 schema 共 13 张表:songs、playlists、playlist_songs、play_history、configs、auth_tokens、js_plugins、plugin_storage、theme_packs、song_tags、song_tag_links、artists、song_artists,通过 38 个迁移文件(0001–0038)从初始版本演进到当前状态。核心表的 updated_at 列均由 SQLite TRIGGER 自动维护。

章节来源


2. ER 关系图 ​

图表来源


3. Song 歌曲模型 ​

Song 是系统最核心的模型,包含 37 个持久化字段 + 5 个运行时字段(其中 3 个 JSON 可见:lyric_url/source_url/source_cover_url,2 个内部使用:CueStartSeconds/CueEndSeconds),承载本地歌曲、网络歌曲和电台三种业务形态。此外数据库另有 fingerprint_attempted_at(0029) 和 fingerprint_error(0037) 两列由 sqlc 查询层单独访问,未映射到 Go Song 结构体。类型由 CHECK(type IN ('local', 'remote', 'radio')) 约束。

常量值必填字段
TypeLocallocalfile_path
TypeRemoteremoteurl
TypeRadioradiourl

3.1 字段分组 ​

基础信息:id(PK), type, title, artist, album, year(0007), genre(0007), track(0020), language(0022), style(0022), duration

文件与音频:file_path, url, file_size, format, bit_rate, sample_rate, is_live, is_video(0024), file_modified_at(0019), cache_path(0014)

CUE 分轨:cue_source_path(0018), cue_track_index(0018), cue_audio_path(0018)

封面:cover_path(json:"-"), cover_url(序列化时重写)

歌词:lyric(json:"-", LyricPayload JSON), lyric_source(json:"-"), lyric_remote_url, lyric_url(运行时)

插件与去重:plugin_entry_path, source_data(opaque JSON), dedup_key, source_url(运行时)

识别:fingerprint(0008, Chromaprint 前 120 秒采样), fingerprint_duration(0008, 全片时长), fingerprint_attempted_at(0029, unix 秒,0=未尝试;失败也落库,避免每轮扫描重跑 ffmpeg), fingerprint_error(0037, 提取失败时记录错误信息,不在 Go Song 结构体中,由 sqlc 单独访问), isrc(0009)

时间戳:added_at, updated_at(TRIGGER)

章节来源

3.2 自定义 MarshalJSON ​

Song 实现了 MarshalJSON(),在序列化时重写四个字段,使客户端无需关心内部存储细节:

字段重写逻辑示例
urlPlaybackURL() 统一播放端点/api/v1/songs/42/play
cover_urlCoverURLPath(),无封面为空/api/v1/songs/42/cover
lyric_urlLyricURLPath(),无歌词为空/api/v1/songs/42/lyric
source_urlremote/radio 填原始 URLhttps://stream.example.com/live.m3u8

实现采用值拷贝技巧:cp := *s 先复制一份 Song,在拷贝体上修改 url/cover_url/lyric_url/source_url 等字段,再通过 type songAlias Song 别名类型序列化拷贝体(避免递归调用 MarshalJSON)。这样既不会污染原始 *Song 对象,也避免了旧版直接修改接收者带来的副作用。原始 URL 保留在数据库不暴露给客户端,所有播放统一通过 /api/v1/songs/{id}/play 分发。

章节来源

3.3 PlaybackURL 与 HLS 规则 ​

  • 普通歌曲:/api/v1/songs/{id}/play
  • HLS 电台(URL 后缀 .m3u8/.m3u):/api/v1/songs/{id}/play.m3u8

追加 .m3u8 后缀原因:播放器(Web 端 hls.js / 原生 media_kit)按 URL 后缀识别 HLS 流类型,无后缀可能按普通渐进式处理导致直播流无法播放。IsHLSStream() 解析 URL 路径后缀判定,仅 TypeRadio 生效。

章节来源

3.4 CoverURLPath 与 LyricURLPath ​

两个方法遵循"有则返回端点、无则返回空"原则,避免客户端发起注定 404 的请求:

  • CoverURLPath:CoverPath 或 CoverURL 任一非空时返回 /api/v1/songs/{id}/cover
  • LyricURLPath:Lyric 非空,或 LyricSource=="url" 且 LyricRemoteURL 非空时返回 /api/v1/songs/{id}/lyric

章节来源

3.5 DedupKey 去重机制 ​

dedup_key 用于网络歌曲去重,典型形态 <platform>:<platform_id>。与 plugin_entry_path 组成条件唯一索引(partial unique index),仅 dedup_key != '' 的行生效:

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

章节来源


4. Playlist 歌单模型 ​

字段Go 类型JSON说明
IDint64id自增主键
Typestringtypenormal 或 radio(CHECK 约束)
Namestringname全局唯一(idx_playlists_name_unique)
Descriptionstringdescription描述
CoverPathstring-(不暴露)封面本地路径
CoverURLstringcover_url序列化时重写为 /api/v1/playlists/{id}/cover
Labels[]stringlabelsJSON 数组(DB 默认 '[]')
SortBystringsort_by视图排序字段(默认 position,可选 added_at/file_modified_at/title/artist/duration)(0034 新增)
SortOrderstringsort_order视图排序方向(默认 asc,可选 desc)(0034 新增)
SongCountintsong_count运行时计算(非持久化)
RemoteCountintremote_count歌单内网络歌曲数(songs.type=remote),>0 即为「网络歌单」;仅列表接口填充,详情接口恒为 0
PinnedAt*time.Timepinned_at,omitempty置顶时间,nil 表示未置顶;置顶歌单在列表中始终排最前,多个置顶歌单按此字段倒序(最近置顶在前)
CreatedAttime.Timecreated_at创建时间

DB 层另有 position(排序用)和时间戳列。MarshalJSON() 同 Song 一样重写 cover_url。

CanAddSong 校验:normal 歌单仅接受 local/remote,radio 歌单仅接受 radio。

Labels:built_in 标记内置不可删除歌单(预置 id=1「收藏」、id=2「电台收藏」),auto_created 标记扫描自动创建的歌单。

章节来源


5. PlaylistSong 关联模型 ​

实现歌单与歌曲的 M:N 多对多关联:id(PK), playlist_id(FK), song_id(FK), position, added_at。

关键约束:UNIQUE(playlist_id, song_id) 防重复添加;ON DELETE CASCADE 跟随歌单/歌曲删除;(playlist_id, position) 复合索引加速排序。

章节来源


5.1 PlayHistory 播放历史模型 ​

按「播放上下文」记录最近播放过的歌曲,让用户切换歌单 / 歌手 / 专辑后能回到原上下文接着往下播(songloft-org/songloft#333)。

字段:id(PK), context_type, context_key, song_id(FK), played_at, play_count。

上下文由 (context_type, context_key) 二元组标识:

  • 歌单 → ("playlist", "<歌单 ID>")
  • 自定义标签 → ("tag", "<标签 ID>")
  • 分面维度 → ("artist", "周杰伦") / ("album", "范特西") 等,取值复用 songFacetColumn 的 7 个维度(artist/album/genre/year/decade/language/style),不另立枚举

关键约束与设计取舍:

项说明
UNIQUE(context_type, context_key, song_id)去重靠数据库约束 + upsert(ON CONFLICT DO UPDATE 刷新 played_at、累加 play_count),不在应用层查重
每上下文上限 50 条services.MaxPlayHistoryPerContext,写入与裁剪在同一事务内(RunInTx),避免中途失败留下超额记录。刻意做成 Go 常量而非配置项
song_id 外键 CASCADE歌曲从库中删除时历史自动清理
context_key 是 TEXT,无法对 playlists 建外键删歌单时由 PlaylistService 显式调 ClearByPlaylist;批量删除时只清理真正被删掉的歌单,内置歌单(收藏 / 电台收藏)不会被删,其历史必须存活
索引 (context_type, context_key, played_at DESC, id DESC)played_at 是秒级精度会撞,id DESC 兜底 tie-break 保证读取与裁剪顺序确定
歌曲被移出歌单但仍在库里历史行保留(历史就是历史)。客户端起播时用有序 ID 列表定位,找不到即判定失效并提示

章节来源


6. Config 配置模型 ​

Key-value 模式,key 全局唯一,value 为 JSON 字符串。预置配置项:

key说明迁移
music_path音乐目录与排除目录0001
scan_config自动扫描、间隔、格式0001
jwt_secretJWT 密钥(randomblob 随机生成)0001
source_validation / source_fallback / source_metrics音源验证/降级/指标0001
plugin_registries官方插件注册表 URL0006
scan_auto_create_playlists是否自动创建歌单0012

章节来源


7. AuthToken 认证令牌模型 ​

JWT 双 Token 认证,auth_tokens 表记录令牌元数据与撤销审计。

字段说明
token_id唯一标识(UNIQUE)
token_typeaccess(短期)或 refresh(长期),CHECK 约束
client_infoUser-Agent 追踪
expires_at过期时间
revoked_at撤销时间(nullable,sql.NullTime),判空即知是否已撤销
revoked_by / revoked_reason撤销审计(谁、为什么)

TokenInfo 是 AuthToken 的客户端友好版本(去掉内部 ID),用于令牌列表 API。

章节来源


8. JSPlugin 插件模型 ​

QuickJS 沙盒插件的元数据与运行状态。核心字段:

字段说明
entry_path路由前缀(UNIQUE),如 "myplugin"
main入口文件(默认 main.js)
permissionsJSON 数组,如 ["net","storage","fs:music"]
public_paths无需 JWT 的路径前缀(0010 新增)
external_paths可访问的外部绝对路径(0013 新增)
icon插件图标(0011 新增)
statusactive / inactive(默认)/ error,CHECK 约束
zip_hash / entry_hashSHA256 哈希,用于文件指纹热更新检测
file_pathZIP 文件相对路径

运行时比对文件系统 hash 与数据库记录,不一致时自动触发重新加载。

章节来源


9. ThemePack 主题包模型 ​

声明式 JSON 主题包,用户可自定义配色、圆角、渐变(songloft-org/songloft#337)。raw_json 存储完整原始 JSON,前端直接读取渲染;顶层字段做索引/查询用。

字段Go 类型JSON说明
IDint64id自增主键
ThemeIDstringtheme_id全局唯一标识(UNIQUE)
Namestringname主题名称
Versionstringversion版本号(默认 1.0.0)
Authorstringauthor作者
Descriptionstringdescription描述
SchemaVersionintschema_version主题 JSON schema 版本(默认 1)
RawJSONstringraw_json完整主题 JSON
CreatedAttime.Timecreated_at创建时间
UpdatedAttime.Timeupdated_at更新时间(TRIGGER 维护)

章节来源


10. SongTag 自定义标签模型 ​

用户自定义的歌曲标签,通过 song_tag_links 关联表实现歌曲与标签的 M:N 多对多关系。

song_tags 表:

字段类型说明
idINTEGER PK自增主键
nameTEXT, UNIQUE NOCASE标签名称(不区分大小写唯一)
colorTEXT显示颜色(默认空)
created_atDATETIME创建时间

song_tag_links 表:

字段类型说明
song_idINTEGER FK→ songs(id) ON DELETE CASCADE
tag_idINTEGER FK→ song_tags(id) ON DELETE CASCADE
created_atDATETIME打标时间

主键为 (song_id, tag_id) 复合主键,tag_id 上有单独索引 idx_song_tag_links_tag 加速“某标签下所有歌曲”查询。双向 CASCADE 确保删歌/删标签时自动清理关联。

章节来源


11. Artist 歌手模型 ​

规范化多值歌手:artists 是歌手实体(按 normalized_key 去重),song_artists 是歌曲与歌手的 M:N 多对多关联,role 区分主唱 (artist) 和专辑歌手 (album_artist)。

artists 表:

字段类型说明
idINTEGER PK自增主键
nameTEXT歌手名称(显示用原始格式)
normalized_keyTEXT, UNIQUE规范化键(lower(trim(name))),用于去重
created_atDATETIME创建时间

song_artists 表:

字段类型说明
song_idINTEGER FK→ songs(id) ON DELETE CASCADE
artist_idINTEGER FK→ artists(id) ON DELETE CASCADE
roleTEXT, CHECKartist(主唱)或 album_artist(专辑歌手)
positionINTEGER排序位置(多位歌手时的顺序,默认 0)
created_atDATETIME关联时间

主键为 (song_id, artist_id, role) 复合主键。迁移 0038 会自动回填现有 songs.artist 整串为单行关联(role='artist'),多值拆分仅在重新扫描/刷新重读 tag 时发生。songs.artist 列降级为显示串缓存。

章节来源


12. 歌词模型 ​

12.1 LyricPayload ​

songs.lyric 列的结构化存储格式,同时也是 /api/v1/songs/{id}/lyric 的响应形态:

字段JSON说明
Lyriclyric主歌词(LRC 文本)
Tlyrictlyric翻译歌词
Rlyricrlyric罗马音歌词
Lxlyriclxlyric逐字歌词

关键方法:MarshalString() 空 payload 返回空字符串(非 "{}")保持 SQL 判空语义;UnmarshalLyric(raw) 兼容空字符串、合法 JSON、裸 LRC 文本三种历史形态;ApplyLyricToSong(s, text, source) 按 source 类型决定存到 Lyric 还是 LyricRemoteURL。

12.2 LyricSource 枚举 ​

值说明
file同目录 .lrc 文件(支持大小写扩展名及 <文件名>.lrc 变体;运行时也会实时读盘优先返回)
embedded音频内嵌歌词
url运行时按需拉取(LyricRemoteURL 存地址)
cachedURL 拉取后缓存的文本
scraped歌词搜索插件自动获取
manual用户手动调整,扫描不覆盖(0005 迁移通过重建表扩展 CHECK)

章节来源


13. 备份模型 ​

版本化快照设计(当前 BackupVersion = 1),将歌单与歌曲导出为自描述 JSON。

  • BackupData:顶层容器,含 version、exported_at、playlists[]
  • BackupPlaylist:歌单快照(name/type/description/labels/songs[])
  • BackupSong:Song 的精简版(16 个核心字段),不含 ID、时间戳、歌词、封面路径等可重建数据
  • ImportResult:导入统计 -- playlists_created/playlists_merged/songs_created/songs_matched/songs_skipped

章节来源


14. 请求/响应 DTO ​

领域模型与 HTTP 层通过 DTO 解耦。

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

批量操作:BatchDeleteSongsRequest(ids/delete_files) -> BatchDeleteSongsResponse(deleted);BatchDeletePlaylistsRequest(ids) -> BatchDeletePlaylistsResponse(deleted)

自动创建歌单:AutoCreatePlaylistsRequest(include_subdirs) -> AutoCreatePlaylistsResponse(playlists[]/total);子结构 PlaylistInfo(playlist_id/name/song_count)

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

升级:RemoteVersionInfo(version/git_commit/build_time/download_url_prefix/release_notes);UpgradeProgress(status/progress/current_step/error),状态流转 idle -> uploading -> testing -> replacing -> restarting、idle -> downloading -> testing -> replacing -> restarting(异常 failed/resetting);UploadedBinaryInfo(上传升级时返回的二进制信息)

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

章节来源


15. 模型关系类图 ​

图表来源