歌单管理
本章基于以下源文件编写:
- internal/models/models.go -- Playlist / PlaylistSong 模型定义、类型常量、CanAddSong 校验
- internal/database/migrations/0001_init.sql -- playlists / playlist_songs 表结构与约束
- internal/database/playlist_repository.go -- 歌单仓储(CRUD、AutoCreate、BatchDelete 等)
- internal/database/playlist_song_repository.go -- 歌单-歌曲关联仓储
- internal/services/playlist_service.go -- 歌单服务层
- internal/services/backup_service.go -- 歌单备份与恢复
- internal/services/song_service.go -- 扫描完成后自动创建歌单触发逻辑
- internal/handlers/playlist.go -- 歌单 HTTP handler
- internal/handlers/backup.go -- 备份导入导出 handler
- internal/handlers/scan.go -- 自动创建歌单开关配置 handler
目录
1. 歌单类型系统
章节来源:internal/models/models.go
歌单通过 type 字段区分为两种类型,数据库层面由 CHECK(type IN ('normal', 'radio')) 约束强制保证:
| 类型常量 | 值 | 说明 |
|---|---|---|
PlaylistTypeNormal | "normal" | 普通歌单,存放本地和网络歌曲 |
PlaylistTypeRadio | "radio" | 电台歌单,只能存放电台/广播 |
CanAddSong 类型校验
添加歌曲到歌单时,Playlist.CanAddSong(songType) 执行类型兼容性检查:
| 歌单类型 | 允许的歌曲类型 | 拒绝的歌曲类型 |
|---|---|---|
normal | local、remote | radio |
radio | radio | local、remote |
Service 层的 AddSong 和 AddSongs 方法在执行插入前均调用此校验,类型不匹配时返回错误而非静默跳过。批量添加(AddSongs)中类型不兼容的歌曲计入 skipped 计数。
类型不可变
歌单创建后 type 字段不允许修改。Update 方法使用 ValidateForUpdate() 校验,该函数只验证 name 非空,不校验 type 字段;仓储层的 UpdatePlaylist SQL 也不包含 type 列的更新。
标签系统
歌单使用 labels 字段(JSON 字符串数组)标记特殊属性,目前定义了两个标签常量:
| 标签常量 | 值 | 作用 |
|---|---|---|
PlaylistLabelBuiltIn | "built_in" | 标识内置歌单,不可删除,更新受限 |
PlaylistLabelAutoCreated | "auto_created" | 标识扫描自动创建的歌单,每次扫描时先清除再重建 |
数据库中 labels 字段存储为 JSON 文本(如 ["built_in"]),查询时通过 SQLite 的 json_each 函数解析匹配。
2. 内置歌单
章节来源:internal/models/models.go、internal/services/playlist_service.go、internal/database/playlist_repository.go
系统通过数据库迁移预置两个内置歌单:
| ID | 名称 | 类型 | 标签 | 用途 |
|---|---|---|---|---|
| 1 | 收藏 | normal | ["built_in"] | 用户收藏的普通歌曲 |
| 2 | 电台收藏 | radio | ["built_in"] | 用户收藏的电台节目 |
内置歌单保护机制
删除保护:PlaylistService.Delete 在执行删除前检查歌单的 labels 数组,发现包含 "built_in" 时直接返回错误 "cannot delete built-in playlist"。批量删除(BatchDelete)在仓储层通过 SQL 的 NOT EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?) 子句跳过内置歌单。
更新保护:PlaylistService.Update 对内置歌单仅允许更新 cover_path 和 cover_url 两个字段,其余字段(name、description、labels、type)强制保持原值不变。
3. 歌单 CRUD
章节来源:internal/handlers/playlist.go、internal/services/playlist_service.go、internal/database/playlist_repository.go
创建歌单
POST /api/v1/playlists
创建流程在仓储层通过事务保证原子性:
Validate()校验 name 非空、type 合法- 事务内
FindPlaylistByName检查同名歌单(不区分类型),冲突返回ErrPlaylistNameConflict(HTTP 409) GetMaxPlaylistPosition获取当前最大 position,新歌单 position = max + 1CreatePlaylist插入记录并返回自增 ID
数据库层有 idx_playlists_name_unique 唯一索引兜底,即使并发场景也不会出现同名歌单。
读取歌单
GET /api/v1/playlists-- 列表查询,支持type(normal/radio)过滤和limit/offset分页,响应包含total总数GET /api/v1/playlists/{id}-- 单个查询,返回完整歌单信息(含song_count)
歌曲计数通过 LEFT JOIN 子查询 (SELECT playlist_id, COUNT(*) FROM playlist_songs GROUP BY playlist_id) 在查询时实时计算,不需要维护冗余字段。
更新歌单
PUT /api/v1/playlists/{id}
可更新字段:name、description、cover_path、cover_url。改名时事务内执行同名检查(排除自身 ID),冲突返回 409。内置歌单受保护规则限制。
删除歌单
DELETE /api/v1/playlists/{id}-- 单个删除,内置歌单不可删除POST /api/v1/playlists/batch-delete-- 批量删除,请求体{ids: [...]},内置歌单被跳过,返回{deleted: N}实际删除数
删除歌单时,playlist_songs 关联记录由外键 ON DELETE CASCADE 自动级联清理。Service 层在删除成功后调用 removeCoverIfUnreferenced 清理无引用的封面文件。
4. 歌曲关联管理
章节来源:internal/database/playlist_song_repository.go、internal/services/playlist_service.go
数据表结构
图表来源:internal/database/migrations/0001_init.sql
playlist_songs
├── id INTEGER PRIMARY KEY AUTOINCREMENT
├── playlist_id INTEGER NOT NULL → FK playlists(id) ON DELETE CASCADE
├── song_id INTEGER NOT NULL → FK songs(id) ON DELETE CASCADE
├── position INTEGER NOT NULL
├── added_at DATETIME DEFAULT CURRENT_TIMESTAMP
└── UNIQUE(playlist_id, song_id) -- 防止同一歌曲重复加入同一歌单UNIQUE(playlist_id, song_id) 约束是防重复的核心机制。批量添加使用 INSERT OR IGNORE 语句,已存在的关联被静默跳过并计入 skipped。
添加歌曲
POST /api/v1/playlists/{id}/songs,请求体 {song_ids: [...]}
批量添加流程(PlaylistService.AddSongs):
- 获取歌单信息,确认歌单存在
- 一次性查询所有候选歌曲的 type(
ListTypesByIDs),避免 O(N) 逐首查询 - 用
CanAddSong过滤不兼容类型,不兼容者计入skipped MaxPosition获取当前最大 position 作为起始值AddSongsBatch在单事务内批量插入,position 从 startPos+1 累加,INSERT OR IGNORE跳过已存在的关联
返回 {added: N, skipped: M},让客户端知道实际添加和跳过的数量。
移除歌曲
DELETE /api/v1/playlists/{id}/songs/{songId}
从关联表删除对应行,不存在时返回 ErrNotFound。不影响歌曲本身的数据。
替换歌曲
ReplaceSong 用于源切换等场景(如远程歌曲换源后更新关联):在事务内查找旧歌曲的 position,删除旧关联,以相同 position 插入新歌曲,保证播放顺序不变。SQL 主体 replaceSongInPlaylistTx 被设计为可复用的内部函数,既可以独立启动事务调用,也可以嵌入外层事务避免嵌套事务导致的 SQLITE_BUSY。
查询歌单包含的歌曲
GET /api/v1/playlists/{id}/songs,支持 limit/offset 分页
歌曲按 position 升序返回,响应同时包含 total 总数。仓储层提供两个查询方法:GetSongs(全量)用于内部统计场景,GetSongsPaginated(分页)用于 API 响应。ListPlaylistsContainingSong 方法反向查询包含指定歌曲的所有 normal 歌单 ID,用于缓存转换时定位需要更新的歌单。
5. 排序系统
章节来源:internal/services/playlist_service.go、internal/database/playlist_repository.go、internal/database/playlist_song_repository.go
歌单间排序(ReorderPlaylists)
PUT /api/v1/playlists/reorder,请求体 {playlist_ids: [3, 1, 2, ...]}
前端传入期望顺序的完整歌单 ID 列表。Service 层校验传入数量与数据库中歌单总数一致(防止遗漏或多余),然后在事务内按列表顺序依次更新 position 为 1..N。
默认排序规则为 position ASC, updated_at DESC(position 相同时最近更新的排前面)。
歌单内歌曲排序(ReorderPlaylistSongs)
PUT /api/v1/playlists/{id}/songs/reorder,请求体 {song_ids: [5, 3, 8, ...]}
同样要求传入的歌曲 ID 数量与歌单实际歌曲数一致。事务内依次更新每首歌的 position 为 1..N,歌曲不存在时返回错误。
事务保证
两种排序操作都在仓储层的 runInTx 内完成。如果底层连接已是 *sql.Tx(例如处于 UnitOfWork 事务中),则直接复用而不嵌套事务;底层为 *sql.DB 时自动开启新事务。这保证了批量 position 更新的原子性:要么全部成功,要么全部回滚。
6. 自动创建歌单
章节来源:internal/database/playlist_repository.go(AutoCreate 方法)、internal/services/song_service.go(触发逻辑)、internal/handlers/scan.go(配置开关)
触发时机
扫描完成后,SongService 检查 scan_auto_create_playlists 配置(默认 true),启用时调用 runAutoCreatePlaylists。该方法读取 scan_playlist_mode 配置(默认 directory),然后以该模式调用 PlaylistRepository.AutoCreate。
配置开关
| 配置端点 | 配置键 | 默认值 | 说明 |
|---|---|---|---|
/settings/scan-auto-create-playlists | scan_auto_create_playlists | true | 总开关:扫描后是否自动创建目录歌单 |
/settings/scan-playlist-mode | scan_playlist_mode | directory | 归并模式:directory(每个文件夹独立歌单)/ top_level(按顶层目录合并)/ bubble_up(歌曲向上冒泡加入所有父目录歌单) |
AutoCreate 核心流程
整个操作在单一事务内完成:
- 查询所有本地歌曲,按
file_path分组到各目录 - 目录聚合:按
scan_playlist_mode归并——directory每个目录各自成单;top_level归并到顶层目录;bubble_up时每首歌同时加入其所有父目录对应的歌单 - 删除旧的自动歌单:清除所有带
auto_created标签的歌单(CASCADE 自动清理关联) - 智能命名:
- 计算所有目录的公共前缀,提取相对路径的 baseName 作为歌单名
- 同名目录消歧:追加父路径后缀(如
Album - Artist1/Album) - 与已有歌单冲突时追加
(自动)/(自动 2)后缀
- 批量插入歌单,标签设为
["auto_created"] - 批量插入关联,每批 500 行,歌曲按数字前缀排序(与 Flutter 前端展示顺序一致)
- 随机选取封面:从歌单内有封面的歌曲中随机选一首作为歌单封面
自动创建的歌单带有 auto_created 标签,每次扫描时先全部清除再重建,保证与磁盘目录结构同步。
歌曲排序规则
自动创建歌单内的歌曲按数字前缀排序(lessSongByNumberThenTitle),复刻了 Flutter 前端的排序逻辑:
- 双方标题都包含数字 -- 数值小者在前,相等时按标题字母序
- 只有一方包含数字 -- 有数字者在前
- 都没有数字 -- 按标题不区分大小写排序
容错设计
runAutoCreatePlaylists 失败时仅记录 slog.Warn 日志,不影响扫描的「完成」状态。下次扫描会重新尝试创建,避免因歌单创建失败阻塞整个扫描流程。
7. 封面管理
章节来源:internal/handlers/playlist.go、internal/services/playlist_service.go
封面存储
歌单封面通过两个字段管理:
| 字段 | 说明 |
|---|---|
cover_path | 本地文件路径(json:"-" 不暴露给客户端),优先级最高 |
cover_url | 外部 URL,本地封面不存在时使用 |
上传封面
POST /api/v1/playlists/{id}/cover(multipart/form-data)
- 限制上传文件 10 MB
- 校验格式:仅支持 jpg/jpeg/png/gif/bmp/webp
- 调用
MetadataExtractor.SaveCover保存到本地 - 更新歌单的
cover_path,同时清空cover_url
获取封面
GET /api/v1/playlists/{id}/cover
三级回退策略:
- 本地封面:
cover_path非空且文件存在时直接返回,设置Cache-Control: public, max-age=31536000(一年缓存) - 外部 URL:
cover_url非空时代理转发远程资源 - 歌曲封面回退:取歌单内前 20 首歌曲,返回第一首有本地封面的歌曲封面
8. 备份与恢复
章节来源:internal/services/backup_service.go、internal/handlers/backup.go、internal/models/backup.go
导出(ExportPlaylists)
GET /api/v1/playlists/export
导出流程:
- 拉取所有歌单(不分页)
- 对每个歌单拉取全部歌曲,转换为
BackupSong结构(包含plugin_entry_path、dedup_key等元数据) - 封装为
BackupData,设置version: 1和导出时间戳 - 响应头设置
Content-Disposition: attachment; filename="songloft-backup-20260612.json"触发浏览器下载
导出的 JSON 结构:
BackupData
├── version: 1
├── exported_at: "2026-06-12T..."
└── playlists[]
├── name, type, description, labels
└── songs[]
├── type, title, artist, album, duration
├── file_path -- 本地歌曲路径(用于导入时匹配)
├── url, cover_url -- 网络歌曲信息
├── plugin_entry_path -- 来源插件标识
└── dedup_key -- 去重键导入(ImportPlaylists)
POST /api/v1/playlists/import(multipart/form-data,限 32 MB)
导入在单一事务内完成,核心策略是合并而非覆盖:
歌单匹配:按名称精确查找,存在则合并歌曲,不存在则创建新歌单。导入时不会创建带 built_in 标签的歌单(过滤掉)。
歌曲匹配策略(按类型分别处理):
| 歌曲类型 | 匹配方式 | 未匹配行为 |
|---|---|---|
local | 按 file_path 在本地曲库中查找 | 跳过(文件不在库中) |
remote/radio | 先按 plugin_entry_path + dedup_key 组合查找 | 通过 UpsertRemote 创建新歌曲 |
去重机制:plugin_entry_path + dedup_key 是网络歌曲的唯一性标识,数据库有 idx_songs_dedup_key_unique 部分唯一索引(WHERE dedup_key != '')保证。关联插入使用 AddSongIgnore(INSERT OR IGNORE),已在歌单中的歌曲静默跳过。
导入结果返回 ImportResult:
{
"playlists_created": 2,
"playlists_merged": 1,
"songs_created": 15,
"songs_matched": 30,
"songs_skipped": 3
}9. Touch 机制
章节来源:internal/services/playlist_service.go、internal/database/playlist_repository.go
POST /api/v1/playlists/{id}/touch
Touch 操作仅更新歌单的 updated_at 时间戳为当前时间,不修改任何其他字段。用途是记录歌单的最后播放/访问时间,使得按 updated_at DESC 排序时最近播放的歌单排在前面。
仓储层实现简单高效:执行 UPDATE playlists SET updated_at = ? WHERE id = ?,受影响行数为 0 时返回 ErrNotFound。
典型使用场景:前端在用户点击播放歌单时调用 Touch,使歌单列表按「最近播放」排序时能反映真实访问顺序。与 Update 不同,Touch 不触发同名检查和数据校验,开销极小。
