Skip to content

歌单管理

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

目录

  1. 歌单类型系统
  2. 内置歌单
  3. 歌单 CRUD
  4. 歌曲关联管理
  5. 排序系统
  6. 自动创建歌单
  7. 封面管理
  8. 备份与恢复
  9. Touch 机制

1. 歌单类型系统

章节来源internal/models/models.go

歌单通过 type 字段区分为两种类型,数据库层面由 CHECK(type IN ('normal', 'radio')) 约束强制保证:

类型常量说明
PlaylistTypeNormal"normal"普通歌单,存放本地和网络歌曲
PlaylistTypeRadio"radio"电台歌单,只能存放电台/广播

CanAddSong 类型校验

添加歌曲到歌单时,Playlist.CanAddSong(songType) 执行类型兼容性检查:

歌单类型允许的歌曲类型拒绝的歌曲类型
normallocalremoteradio
radioradiolocalremote

Service 层的 AddSongAddSongs 方法在执行插入前均调用此校验,类型不匹配时返回错误而非静默跳过。批量添加(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.gointernal/services/playlist_service.gointernal/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_pathcover_url 两个字段,其余字段(namedescriptionlabelstype)强制保持原值不变。


3. 歌单 CRUD

章节来源internal/handlers/playlist.gointernal/services/playlist_service.gointernal/database/playlist_repository.go

创建歌单

POST /api/v1/playlists

创建流程在仓储层通过事务保证原子性:

  1. Validate() 校验 name 非空、type 合法
  2. 事务内 FindPlaylistByName 检查同名歌单(不区分类型),冲突返回 ErrPlaylistNameConflict(HTTP 409)
  3. GetMaxPlaylistPosition 获取当前最大 position,新歌单 position = max + 1
  4. CreatePlaylist 插入记录并返回自增 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}

可更新字段:namedescriptioncover_pathcover_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.gointernal/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):

  1. 获取歌单信息,确认歌单存在
  2. 一次性查询所有候选歌曲的 type(ListTypesByIDs),避免 O(N) 逐首查询
  3. CanAddSong 过滤不兼容类型,不兼容者计入 skipped
  4. MaxPosition 获取当前最大 position 作为起始值
  5. 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.gointernal/database/playlist_repository.gointernal/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-playlistsscan_auto_create_playliststrue总开关:扫描后是否自动创建目录歌单
/settings/scan-playlist-modescan_playlist_modedirectory归并模式:directory(每个文件夹独立歌单)/ top_level(按顶层目录合并)/ bubble_up(歌曲向上冒泡加入所有父目录歌单)

AutoCreate 核心流程

整个操作在单一事务内完成:

  1. 查询所有本地歌曲,按 file_path 分组到各目录
  2. 目录聚合:按 scan_playlist_mode 归并——directory 每个目录各自成单;top_level 归并到顶层目录;bubble_up 时每首歌同时加入其所有父目录对应的歌单
  3. 删除旧的自动歌单:清除所有带 auto_created 标签的歌单(CASCADE 自动清理关联)
  4. 智能命名
    • 计算所有目录的公共前缀,提取相对路径的 baseName 作为歌单名
    • 同名目录消歧:追加父路径后缀(如 Album - Artist1/Album
    • 与已有歌单冲突时追加 (自动) / (自动 2) 后缀
  5. 批量插入歌单,标签设为 ["auto_created"]
  6. 批量插入关联,每批 500 行,歌曲按数字前缀排序(与 Flutter 前端展示顺序一致)
  7. 随机选取封面:从歌单内有封面的歌曲中随机选一首作为歌单封面

自动创建的歌单带有 auto_created 标签,每次扫描时先全部清除再重建,保证与磁盘目录结构同步。

歌曲排序规则

自动创建歌单内的歌曲按数字前缀排序(lessSongByNumberThenTitle),复刻了 Flutter 前端的排序逻辑:

  1. 双方标题都包含数字 -- 数值小者在前,相等时按标题字母序
  2. 只有一方包含数字 -- 有数字者在前
  3. 都没有数字 -- 按标题不区分大小写排序

容错设计

runAutoCreatePlaylists 失败时仅记录 slog.Warn 日志,不影响扫描的「完成」状态。下次扫描会重新尝试创建,避免因歌单创建失败阻塞整个扫描流程。


7. 封面管理

章节来源internal/handlers/playlist.gointernal/services/playlist_service.go

封面存储

歌单封面通过两个字段管理:

字段说明
cover_path本地文件路径(json:"-" 不暴露给客户端),优先级最高
cover_url外部 URL,本地封面不存在时使用

上传封面

POST /api/v1/playlists/{id}/cover(multipart/form-data)

  1. 限制上传文件 10 MB
  2. 校验格式:仅支持 jpg/jpeg/png/gif/bmp/webp
  3. 调用 MetadataExtractor.SaveCover 保存到本地
  4. 更新歌单的 cover_path,同时清空 cover_url

获取封面

GET /api/v1/playlists/{id}/cover

三级回退策略:

  1. 本地封面cover_path 非空且文件存在时直接返回,设置 Cache-Control: public, max-age=31536000(一年缓存)
  2. 外部 URLcover_url 非空时代理转发远程资源
  3. 歌曲封面回退:取歌单内前 20 首歌曲,返回第一首有本地封面的歌曲封面

8. 备份与恢复

章节来源internal/services/backup_service.gointernal/handlers/backup.gointernal/models/backup.go

导出(ExportPlaylists)

GET /api/v1/playlists/export

导出流程:

  1. 拉取所有歌单(不分页)
  2. 对每个歌单拉取全部歌曲,转换为 BackupSong 结构(包含 plugin_entry_pathdedup_key 等元数据)
  3. 封装为 BackupData,设置 version: 1 和导出时间戳
  4. 响应头设置 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 标签的歌单(过滤掉)。

歌曲匹配策略(按类型分别处理):

歌曲类型匹配方式未匹配行为
localfile_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

json
{
  "playlists_created": 2,
  "playlists_merged": 1,
  "songs_created": 15,
  "songs_matched": 30,
  "songs_skipped": 3
}

9. Touch 机制

章节来源internal/services/playlist_service.gointernal/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 不触发同名检查和数据校验,开销极小。