Initial import: Music_Server, MusicFree, catalog-sync
This commit is contained in:
+858
@@ -0,0 +1,858 @@
|
||||
# Music_Cloud 公网音乐服务方案设计
|
||||
|
||||
日期:2026-04-19
|
||||
状态:已确认设计,待实现计划
|
||||
范围:`Music_Cloud / catalogsync`、独立部署的 `Public Music Service`、`MusicFree` 插件接入、播放器后端 API
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Music_Cloud / catalogsync` 已经承担了这些职责:
|
||||
|
||||
- 从多个平台采集歌单池、榜单、歌曲元数据
|
||||
- 同步歌单歌曲关系、歌手关系、热度数据
|
||||
- 多源搜歌、解析、下载、去重
|
||||
- 维护本地文件、对象存储、`file_locations`、`song_backend_presence`
|
||||
- 提供运维后台,用于采集、同步、下载、上传、巡检
|
||||
|
||||
它本质上是内容底座和文件资产库,而不是面向最终听歌用户的播放器服务端。
|
||||
|
||||
接下来要补的是一套独立于 `Music_Cloud` 的公网服务,使其可以:
|
||||
|
||||
- 为 `MusicFree` 提供完整音源插件所需接口
|
||||
- 为未来自建网页播放器或 App 提供播放器后端接口
|
||||
- 部署在另一台公网服务器,不与 NAS 上的 `Music_Cloud` 混为一个服务
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本方案的目标是:
|
||||
|
||||
- 保持 `Music_Cloud` 只负责采集、同步、下载、上传、运维
|
||||
- 新建一个可独立部署的 `Public Music Service`
|
||||
- `MusicFree` 插件只做薄适配,不直连数据库,不承担复杂播放回落逻辑
|
||||
- 公网服务优先播放 `Music_Cloud` 已下载或已上传的文件
|
||||
- 当本地或对象存储无可播文件时,公网服务再回落到外部平台解析
|
||||
- 同时为未来网页播放器保留用户态 API 边界
|
||||
|
||||
非目标:
|
||||
|
||||
- 本阶段不设计社交、评论、推荐算法、多人权限系统
|
||||
- 本阶段不把 `catalogsync` 运维后台改造成前台播放器
|
||||
- 本阶段不要求把现有 `catalogsync.db` 直接暴露到公网
|
||||
|
||||
## 3. 结论与总架构
|
||||
|
||||
采用三层结构:
|
||||
|
||||
1. `Music_Cloud`
|
||||
- 部署位置:NAS / 内网
|
||||
- 角色:内容真相源、下载与存储资产管理、运维控制台
|
||||
2. `Public Music Service`
|
||||
- 部署位置:公网服务器
|
||||
- 角色:内容查询、播放解析、播放器后端、MusicFree 兼容接口
|
||||
3. `Object Storage / CDN`
|
||||
- 部署位置:云存储或兼容对象存储
|
||||
- 角色:公网分发音频文件与封面
|
||||
|
||||
核心原则:
|
||||
|
||||
- `Music_Cloud` 与公网服务物理分开、职责分开
|
||||
- 公网服务默认读取本机只读目录镜像,不跨公网直接读 NAS 的 SQLite
|
||||
- 公网服务可选通过私有链路向 NAS 请求未上传资源,但该链路不对公网开放
|
||||
- `MusicFree` 和未来网页播放器只连接公网服务,不直连 NAS
|
||||
|
||||
## 4. 组件边界
|
||||
|
||||
### 4.1 Music_Cloud
|
||||
|
||||
保留现有职责:
|
||||
|
||||
- 歌单采集:歌单广场、榜单、手工输入歌单
|
||||
- 歌单同步:歌单详情、歌曲列表、歌手派生
|
||||
- 下载:跨平台搜歌、候选优选、落盘、去重
|
||||
- 上传:对象存储补传、`file_locations` 更新、`song_backend_presence` 刷新
|
||||
- 运维:任务编排、日志、暂停恢复、巡检去重
|
||||
|
||||
新增一个导出职责:
|
||||
|
||||
- 导出供公网服务使用的目录镜像快照
|
||||
|
||||
### 4.2 Public Music Service
|
||||
|
||||
这是本方案新增的主服务。对外暴露两个命名空间:
|
||||
|
||||
- `/mf/v1/*`
|
||||
- 面向 `MusicFree` 插件
|
||||
- 返回结构尽量贴近 `MusicFree` 需要的字段
|
||||
- `/player/v1/*`
|
||||
- 面向自建网页播放器或 App
|
||||
- 提供用户态能力,例如收藏、历史、播放上下文
|
||||
|
||||
其内部拆为 6 个模块:
|
||||
|
||||
- `catalog_reader`
|
||||
- 读取目录镜像库
|
||||
- `playlist_service`
|
||||
- 推荐歌单、榜单、歌单详情、歌曲分页
|
||||
- `cover_service`
|
||||
- 封面定位、代理、缓存
|
||||
- `media_resolver`
|
||||
- 选播放源、做优先级回落
|
||||
- `stream_service`
|
||||
- 签名流地址、Range、代理转发
|
||||
- `player_service`
|
||||
- 收藏、历史、最近播放、用户歌单等用户态能力
|
||||
|
||||
### 4.3 MusicFree 插件
|
||||
|
||||
插件是薄层,不负责:
|
||||
|
||||
- 数据库存取
|
||||
- 文件路径选择
|
||||
- 多平台解析
|
||||
- 对象存储鉴权
|
||||
- NAS 私有回源
|
||||
|
||||
插件只负责:
|
||||
|
||||
- 调用 `/mf/v1/*`
|
||||
- 把响应映射为 `MusicFree` 插件结构
|
||||
- 把 `sheetItem` / `musicItem` 的稳定 ID 传回服务端
|
||||
|
||||
## 5. 数据同步方案
|
||||
|
||||
### 5.1 选择的方案
|
||||
|
||||
采用“快照镜像”而不是“公网服务直读 NAS 主库”。
|
||||
|
||||
理由:
|
||||
|
||||
- 主库仍由 `Music_Cloud` 独占写入,风险最小
|
||||
- 公网服务读本机只读库,延迟稳定,运维简单
|
||||
- 即使 NAS 暂时不可达,公网服务也能继续提供上一次成功快照
|
||||
- 后续可以升级到增量同步,但第一版不需要先上复杂 CDC
|
||||
|
||||
### 5.2 同步链路
|
||||
|
||||
`Music_Cloud` 在这些时机触发目录快照导出:
|
||||
|
||||
- `collect` 任务完成后
|
||||
- `sync` 任务完成后
|
||||
- `download` 任务完成后
|
||||
- `upload` 任务完成后
|
||||
- 定时兜底任务,例如每 30 分钟一次
|
||||
|
||||
导出产物:
|
||||
|
||||
- 一个目录镜像数据库,例如 `catalog_read.db`
|
||||
- 一个清单文件,例如 `manifest.json`
|
||||
- 可选封面缓存目录
|
||||
|
||||
`manifest.json` 至少包含:
|
||||
|
||||
- `snapshot_id`
|
||||
- `generated_at`
|
||||
- `schema_version`
|
||||
- `playlist_count`
|
||||
- `track_count`
|
||||
- `file_count`
|
||||
- `cover_count`
|
||||
|
||||
发布方式推荐:
|
||||
|
||||
1. NAS 导出快照
|
||||
2. 上传到对象存储或通过 `rsync/scp` 推送到公网服务器
|
||||
3. 公网服务下载或接收最新成功快照
|
||||
4. 先写到临时路径
|
||||
5. 校验清单后原子替换线上读库
|
||||
|
||||
### 5.3 只读目录镜像库
|
||||
|
||||
第一版不要求完整复制 `catalogsync.db` 全表,只同步公网查询真正需要的字段。
|
||||
|
||||
建议镜像库包含这些读模型表:
|
||||
|
||||
- `catalog_playlists`
|
||||
- `playlist_id`
|
||||
- `platform`
|
||||
- `remote_playlist_id`
|
||||
- `source_kind`
|
||||
- `name`
|
||||
- `description`
|
||||
- `cover_url`
|
||||
- `play_count`
|
||||
- `song_count`
|
||||
- `synced_at`
|
||||
- `updated_at`
|
||||
- `catalog_tracks`
|
||||
- `song_id`
|
||||
- `platform`
|
||||
- `remote_song_id`
|
||||
- `name`
|
||||
- `singers`
|
||||
- `album`
|
||||
- `cover_url`
|
||||
- `duration_ms`
|
||||
- `metadata_json`
|
||||
- `catalog_playlist_tracks`
|
||||
- `playlist_id`
|
||||
- `song_id`
|
||||
- `position`
|
||||
- `catalog_track_files`
|
||||
- `song_id`
|
||||
- `quality_label`
|
||||
- `ext`
|
||||
- `file_size_bytes`
|
||||
- `backend_type`
|
||||
- `backend_name`
|
||||
- `locator`
|
||||
- `public_url`
|
||||
- `status`
|
||||
- `is_primary`
|
||||
- `catalog_track_presence`
|
||||
- `song_id`
|
||||
- `has_local`
|
||||
- `has_object_storage`
|
||||
- `has_private_origin`
|
||||
- `has_external_fallback`
|
||||
- `catalog_toplists`
|
||||
- `toplist_id`
|
||||
- `platform`
|
||||
- `name`
|
||||
- `description`
|
||||
- `cover_url`
|
||||
- `play_count`
|
||||
- `song_count`
|
||||
- `group_name`
|
||||
|
||||
说明:
|
||||
|
||||
- 这些表是导出后的读模型,不要求与 `catalogsync` 内部表名完全一致
|
||||
- 读模型允许从 `playlists / songs / playlist_songs / file_locations / song_backend_presence` 聚合而来
|
||||
- 公网服务不依赖 `job_*` 运维表
|
||||
|
||||
## 6. 存储与播放资源策略
|
||||
|
||||
### 6.1 播放源优先级
|
||||
|
||||
公网服务按以下顺序选择播放源:
|
||||
|
||||
1. 对象存储 / CDN 上已有可播文件
|
||||
2. 公网服务本地缓存文件
|
||||
3. NAS 私有回源
|
||||
4. 外部平台解析回落
|
||||
|
||||
说明:
|
||||
|
||||
- 第 1 优先级最适合公网分发
|
||||
- 第 2 优先级用于热点缓存或调试
|
||||
- 第 3 优先级只走内网或私有网络,不暴露给公网
|
||||
- 第 4 优先级用于“歌单来自平台 A,但平台 A 拿不到时到其他平台找同名候选”
|
||||
|
||||
### 6.2 NAS 私有回源
|
||||
|
||||
为避免“未上传但已下载的歌”在公网不可播,设计一个可选的私有回源能力:
|
||||
|
||||
- `Music_Cloud` 暴露一个仅内网可访问的私有 origin 接口
|
||||
- 该接口只接受来自公网服务的签名请求
|
||||
- 该接口只负责:
|
||||
- 验签
|
||||
- 定位本地文件
|
||||
- 以只读方式返回流
|
||||
|
||||
不允许:
|
||||
|
||||
- 对公网暴露 NAS 本地路径
|
||||
- 公网直接浏览 NAS 目录
|
||||
- `MusicFree` 或网页端直接访问 NAS
|
||||
|
||||
### 6.3 外部平台回落
|
||||
|
||||
当 `Music_Cloud` 中不存在可播放文件时,`media_resolver` 可回落到外部平台解析。
|
||||
|
||||
回落策略:
|
||||
|
||||
- 按歌曲名、歌手名进行跨平台搜索
|
||||
- 候选优先匹配高可信条目
|
||||
- 在可信候选内优先更高音质和更大文件
|
||||
- 若音质接近,按配置的下载源顺序决定优先级
|
||||
|
||||
回落结果默认用于在线播放,不强制自动入库。
|
||||
|
||||
原因:
|
||||
|
||||
- 播放链路要快
|
||||
- 不把播放器请求与下载入库任务耦合
|
||||
- 避免用户点击播放时触发重型后台任务
|
||||
|
||||
## 7. MusicFree 兼容接口设计
|
||||
|
||||
### 7.1 插件能力映射
|
||||
|
||||
根据 `MusicFree` 现有插件接口,插件需要实现这些方法:
|
||||
|
||||
- `getRecommendSheetTags`
|
||||
- `getRecommendSheetsByTag`
|
||||
- `getMusicSheetInfo`
|
||||
- `getTopLists`
|
||||
- `getTopListDetail`
|
||||
- `getMediaSource`
|
||||
|
||||
映射如下:
|
||||
|
||||
- `getRecommendSheetTags()`
|
||||
- `GET /mf/v1/recommend/tags`
|
||||
- `getRecommendSheetsByTag(tag, page)`
|
||||
- `GET /mf/v1/recommend/sheets`
|
||||
- `getMusicSheetInfo(sheetItem, page)`
|
||||
- `GET /mf/v1/playlists/{id}`
|
||||
- `GET /mf/v1/playlists/{id}/tracks?page={page}`
|
||||
- `getTopLists()`
|
||||
- `GET /mf/v1/toplists`
|
||||
- `getTopListDetail(topListItem, page)`
|
||||
- `GET /mf/v1/toplists/{id}`
|
||||
- `GET /mf/v1/toplists/{id}/tracks?page={page}`
|
||||
- `getMediaSource(musicItem, quality)`
|
||||
- `POST /mf/v1/media/resolve`
|
||||
|
||||
### 7.2 稳定 ID 规范
|
||||
|
||||
插件与服务端交互时使用稳定前缀 ID:
|
||||
|
||||
- 歌单:`catalogsync:playlist:{playlist_id}`
|
||||
- 榜单:`catalogsync:toplist:{toplist_id}`
|
||||
- 歌曲:`catalogsync:song:{song_id}`
|
||||
|
||||
这样做的目的是:
|
||||
|
||||
- 避免与其他插件的裸 ID 冲突
|
||||
- 插件端可以稳定回传主键
|
||||
- 服务端未来调整数据库内部结构时,不破坏插件协议
|
||||
|
||||
### 7.3 返回对象
|
||||
|
||||
歌单基础对象:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "catalogsync:playlist:18165",
|
||||
"platform": "catalogsync",
|
||||
"title": "经典老歌:免费下载重温好旋律",
|
||||
"coverImg": "https://public-host/mf/v1/covers/playlists/18165",
|
||||
"description": "netease / 歌单广场",
|
||||
"worksNum": 126
|
||||
}
|
||||
```
|
||||
|
||||
歌曲基础对象:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "catalogsync:song:3476",
|
||||
"platform": "catalogsync",
|
||||
"title": "海屿你",
|
||||
"artist": "马也 / Crabbit",
|
||||
"album": "",
|
||||
"artwork": "https://public-host/mf/v1/covers/songs/3476",
|
||||
"duration": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 `/mf/v1/*` 详细接口
|
||||
|
||||
#### `GET /mf/v1/recommend/tags`
|
||||
|
||||
用途:
|
||||
|
||||
- 返回推荐歌单标签分组
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"pinned": [
|
||||
{ "id": "all", "title": "全部" },
|
||||
{ "id": "netease", "title": "网易云" },
|
||||
{ "id": "qq", "title": "QQ音乐" },
|
||||
{ "id": "kuwo", "title": "酷我" }
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"title": "来源",
|
||||
"data": [
|
||||
{ "id": "playlist_square", "title": "歌单广场" },
|
||||
{ "id": "toplist", "title": "排行榜" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /mf/v1/recommend/sheets`
|
||||
|
||||
查询参数:
|
||||
|
||||
- `tag`
|
||||
- `page`
|
||||
- `page_size`
|
||||
- `platform`
|
||||
- `sort`
|
||||
|
||||
默认排序:
|
||||
|
||||
- `play_count_desc`
|
||||
|
||||
默认过滤:
|
||||
|
||||
- 仅返回 `song_count > 0` 的歌单
|
||||
|
||||
#### `GET /mf/v1/playlists/{id}`
|
||||
|
||||
返回歌单头信息:
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `coverImg`
|
||||
- `description`
|
||||
- `worksNum`
|
||||
- `playCount`
|
||||
|
||||
#### `GET /mf/v1/playlists/{id}/tracks`
|
||||
|
||||
查询参数:
|
||||
|
||||
- `page`
|
||||
- `page_size`
|
||||
|
||||
返回:
|
||||
|
||||
- `sheetItem`
|
||||
- 仅第 1 页可返回
|
||||
- `musicList`
|
||||
- `isEnd`
|
||||
|
||||
#### `GET /mf/v1/toplists`
|
||||
|
||||
返回榜单分组数组,每组包含:
|
||||
|
||||
- `title`
|
||||
- `data`
|
||||
|
||||
每个榜单对象字段与歌单对象兼容。
|
||||
|
||||
#### `GET /mf/v1/toplists/{id}`
|
||||
|
||||
返回榜单头信息。
|
||||
|
||||
#### `GET /mf/v1/toplists/{id}/tracks`
|
||||
|
||||
与歌单 tracks 分页结构一致。
|
||||
|
||||
#### `GET /mf/v1/covers/playlists/{id}`
|
||||
|
||||
返回歌单封面。
|
||||
|
||||
约束:
|
||||
|
||||
- 不要求 Bearer Token
|
||||
- 可直接返回对象存储地址、静态文件或代理流
|
||||
- 应允许较长缓存时间
|
||||
|
||||
#### `GET /mf/v1/covers/songs/{id}`
|
||||
|
||||
返回歌曲封面,约束与歌单封面一致。
|
||||
|
||||
#### `POST /mf/v1/media/resolve`
|
||||
|
||||
请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"song_id": "catalogsync:song:3476",
|
||||
"quality": "super"
|
||||
}
|
||||
```
|
||||
|
||||
响应体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"song_id": "catalogsync:song:3476",
|
||||
"selected_source": {
|
||||
"kind": "object_storage",
|
||||
"backend": "main-s3",
|
||||
"quality": "super",
|
||||
"ext": "flac",
|
||||
"size_bytes": 42345678
|
||||
},
|
||||
"stream": {
|
||||
"url": "https://public-host/mf/v1/media/stream/eyJhbGciOi...",
|
||||
"headers": {},
|
||||
"expires_at": "2026-04-19T12:00:00Z",
|
||||
"range_supported": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 播放器后端接口设计
|
||||
|
||||
`/player/v1/*` 面向未来网页播放器或 App,不要求与 `MusicFree` 插件接口一致。
|
||||
|
||||
第一版范围只做单用户自用,不设计复杂账号体系。
|
||||
|
||||
### 8.1 用户能力范围
|
||||
|
||||
第一版支持:
|
||||
|
||||
- 首页聚合
|
||||
- 推荐歌单列表
|
||||
- 榜单列表
|
||||
- 歌单详情
|
||||
- 歌曲播放解析
|
||||
- 收藏歌曲
|
||||
- 收藏歌单
|
||||
- 最近播放
|
||||
- 播放记录上报
|
||||
|
||||
第一版不支持:
|
||||
|
||||
- 社交评论
|
||||
- 多用户协作歌单
|
||||
- 站内消息
|
||||
- 推荐算法
|
||||
- 付费体系
|
||||
|
||||
### 8.2 `/player/v1/*` 接口
|
||||
|
||||
#### `GET /player/v1/home`
|
||||
|
||||
返回首页聚合数据:
|
||||
|
||||
- 热门歌单
|
||||
- 榜单分组
|
||||
- 最近播放
|
||||
- 收藏入口
|
||||
|
||||
#### `GET /player/v1/playlists`
|
||||
|
||||
查询参数:
|
||||
|
||||
- `scope`
|
||||
- `recommend`
|
||||
- `toplist`
|
||||
- `favorite`
|
||||
- `mine`
|
||||
- `page`
|
||||
- `page_size`
|
||||
- `sort`
|
||||
|
||||
#### `GET /player/v1/playlists/{id}`
|
||||
|
||||
返回歌单头信息和统计信息。
|
||||
|
||||
#### `GET /player/v1/playlists/{id}/tracks`
|
||||
|
||||
返回歌曲分页列表。
|
||||
|
||||
#### `POST /player/v1/tracks/{id}/play`
|
||||
|
||||
返回播放器使用的播放地址,可内部复用 `media_resolver`。
|
||||
|
||||
#### `GET /player/v1/me/favorites/tracks`
|
||||
|
||||
返回收藏歌曲列表。
|
||||
|
||||
#### `PUT /player/v1/me/favorites/tracks/{id}`
|
||||
|
||||
收藏歌曲。
|
||||
|
||||
#### `DELETE /player/v1/me/favorites/tracks/{id}`
|
||||
|
||||
取消收藏歌曲。
|
||||
|
||||
#### `GET /player/v1/me/favorites/playlists`
|
||||
|
||||
返回收藏歌单列表。
|
||||
|
||||
#### `PUT /player/v1/me/favorites/playlists/{id}`
|
||||
|
||||
收藏歌单。
|
||||
|
||||
#### `DELETE /player/v1/me/favorites/playlists/{id}`
|
||||
|
||||
取消收藏歌单。
|
||||
|
||||
#### `GET /player/v1/me/history`
|
||||
|
||||
返回最近播放记录。
|
||||
|
||||
#### `POST /player/v1/me/history`
|
||||
|
||||
写入播放记录,字段包括:
|
||||
|
||||
- `track_id`
|
||||
- `played_at`
|
||||
- `progress_seconds`
|
||||
- `source_kind`
|
||||
|
||||
## 9. 鉴权与安全
|
||||
|
||||
### 9.1 API 鉴权
|
||||
|
||||
`/mf/v1/*` 与 `/player/v1/*` 的 JSON 接口统一使用 Bearer Token。
|
||||
|
||||
第一版采用单用户 Token 模型:
|
||||
|
||||
- 服务端配置一个或少量长期 Token
|
||||
- MusicFree 插件和网页播放器都用这个 Token
|
||||
|
||||
后续如需多用户,再升级为真正的登录体系。
|
||||
|
||||
### 9.2 封面访问
|
||||
|
||||
封面地址不适合依赖 Bearer Header,因为:
|
||||
|
||||
- `MusicFree` 的 `coverImg` / `artwork` 仅提供 URL
|
||||
- 图片加载通常不会自动带业务鉴权头
|
||||
|
||||
因此第一版采用以下之一:
|
||||
|
||||
- 封面接口直接公开只读访问
|
||||
- 或返回可长期缓存的签名图片 URL
|
||||
|
||||
推荐第一版直接公开封面接口,因为风险低、实现简单。
|
||||
|
||||
### 9.3 播放地址保护
|
||||
|
||||
播放地址不能直接暴露真实文件路径、对象存储原始 Key、NAS 本地路径。
|
||||
|
||||
规则:
|
||||
|
||||
- `resolve` 接口只返回短时有效的流地址
|
||||
- 该地址带签名和过期时间
|
||||
- 服务端验签后再执行真正的文件流读取或转发
|
||||
|
||||
要求:
|
||||
|
||||
- 支持 `Range`
|
||||
- 支持 HEAD 或等效元信息读取
|
||||
- 支持必要的透传 Header
|
||||
|
||||
## 10. 数据库与存储设计
|
||||
|
||||
### 10.1 继续复用的 `Music_Cloud` 数据
|
||||
|
||||
第一版继续把这些数据视为事实来源:
|
||||
|
||||
- `playlists`
|
||||
- `songs`
|
||||
- `playlist_songs`
|
||||
- `file_assets`
|
||||
- `file_locations`
|
||||
- `song_backend_presence`
|
||||
|
||||
### 10.2 建议补强字段
|
||||
|
||||
为了让公网服务更顺滑,建议在导出链路或源库中确保这些字段可用:
|
||||
|
||||
- `playlists.cover_url`
|
||||
- `playlists.description`
|
||||
- `playlists.play_count`
|
||||
- `playlists.collected_song_count`
|
||||
- `songs.cover_url`
|
||||
- `songs.duration_ms`
|
||||
|
||||
要求:
|
||||
|
||||
- 能拿到就导出
|
||||
- 拿不到就允许为空
|
||||
- 不要求第一版先完成所有平台补全
|
||||
|
||||
### 10.3 Public Music Service 自身数据
|
||||
|
||||
公网服务不直接复用 `catalogsync.db` 作为用户库。
|
||||
|
||||
它需要至少两份本地数据:
|
||||
|
||||
1. `catalog_read.db`
|
||||
- 只读目录镜像库
|
||||
- 来源是 `Music_Cloud` 导出
|
||||
2. `player.db`
|
||||
- 用户态数据库
|
||||
- 保存收藏、历史、用户歌单、播放上下文
|
||||
|
||||
第一版可使用 SQLite:
|
||||
|
||||
- 对单用户自用足够
|
||||
- 运维成本低
|
||||
- 未来若用户态写入量明显增加,可平滑迁移到 PostgreSQL
|
||||
|
||||
## 11. 部署设计
|
||||
|
||||
### 11.1 Music_Cloud 部署
|
||||
|
||||
保持在 NAS:
|
||||
|
||||
- NAS 统一根目录:`/volume4/Music_Cloud`
|
||||
- `catalogsync` 工作目录:`/volume4/Music_Cloud/catalogsync`
|
||||
- 数据库:`/volume4/Music_Cloud/catalogsync/data/catalogsync.db`
|
||||
- 本地曲库目录:`/volume4/Music_Cloud/library`
|
||||
- 歌单信息输出目录:`/volume4/Music_Cloud/playlists`
|
||||
- 运维后台:`catalogsync serve`
|
||||
|
||||
### 11.2 Public Music Service 部署
|
||||
|
||||
部署在公网服务器:
|
||||
|
||||
- 服务进程:一个 Web 服务
|
||||
- 读库:`catalog_read.db`
|
||||
- 用户库:`player.db`
|
||||
- 缓存目录:封面缓存、热点流缓存
|
||||
- 反向代理:Nginx / Caddy
|
||||
|
||||
若 `Public Music Service` 先部署在 NAS 上联调 / 过渡运行:
|
||||
|
||||
- 宿主机标准目录:`/volume4/Music_Cloud/Music_Server`
|
||||
- Docker 项目目录:`/volume4/Music_Cloud/Music_Server/app`
|
||||
- 运行时配置目录:`/volume4/Music_Cloud/Music_Server/config`
|
||||
- 运行时数据目录:`/volume4/Music_Cloud/Music_Server/data`
|
||||
- 运维脚本目录:`/volume4/Music_Cloud/Music_Server/bin`
|
||||
- 不再使用旧路径:`/volume4/Music_Server` 下的 `app` 目录
|
||||
|
||||
### 11.3 对象存储
|
||||
|
||||
推荐启用对象存储作为主公网分发层:
|
||||
|
||||
- 音频文件 URL 优先走对象存储或 CDN
|
||||
- 封面文件优先走对象存储或静态缓存
|
||||
- 公网服务只负责解析、鉴权和必要的中转
|
||||
|
||||
## 12. 错误处理与降级
|
||||
|
||||
必须明确这些降级行为:
|
||||
|
||||
- 最新快照导入失败
|
||||
- 继续使用上一次成功快照
|
||||
- 对象存储不可用
|
||||
- 回落到公网缓存、NAS 私有回源或外部平台
|
||||
- 外部平台解析失败
|
||||
- 返回“不可播放”,不触发自动下载
|
||||
- 封面缺失
|
||||
- 返回默认占位图
|
||||
- 歌单歌曲数为 0
|
||||
- 默认不在推荐列表返回
|
||||
|
||||
## 13. 测试策略
|
||||
|
||||
实现阶段至少覆盖这些测试:
|
||||
|
||||
- 目录快照导出与导入测试
|
||||
- 目录镜像库查询测试
|
||||
- `MusicFree` 插件协议契约测试
|
||||
- 歌单详情分页测试
|
||||
- 榜单分页测试
|
||||
- `media_resolver` 优先级测试
|
||||
- `Range` 流播放测试
|
||||
- Bearer Token / 流签名鉴权测试
|
||||
- 收藏与历史 API 测试
|
||||
|
||||
手工验证至少包括:
|
||||
|
||||
- 在 `MusicFree` 中成功加载推荐歌单
|
||||
- 点进歌单后分页加载歌曲正常
|
||||
- 榜单加载正常
|
||||
- 已上传歌曲优先走对象存储
|
||||
- 未上传但 NAS 有本地文件时,可通过私有回源播放
|
||||
- 本地和私有回源都没有时,可回落到外部平台
|
||||
|
||||
## 14. 分阶段实施
|
||||
|
||||
### Phase 1: 目录镜像与 MusicFree 浏览
|
||||
|
||||
交付内容:
|
||||
|
||||
- `Music_Cloud` 导出目录镜像快照
|
||||
- 公网服务加载 `catalog_read.db`
|
||||
- `/mf/v1/recommend/*`
|
||||
- `/mf/v1/playlists/*`
|
||||
- `/mf/v1/toplists/*`
|
||||
- `/mf/v1/covers/*`
|
||||
|
||||
目标:
|
||||
|
||||
- 先让 `MusicFree` 能像音乐平台一样浏览歌单和榜单
|
||||
|
||||
### Phase 2: 播放解析
|
||||
|
||||
交付内容:
|
||||
|
||||
- `/mf/v1/media/resolve`
|
||||
- `stream_service`
|
||||
- 对象存储优先
|
||||
- 可选 NAS 私有回源
|
||||
- 外部平台回落
|
||||
|
||||
目标:
|
||||
|
||||
- 让 `MusicFree` 可以实际播放
|
||||
|
||||
### Phase 3: 播放器后端
|
||||
|
||||
交付内容:
|
||||
|
||||
- `/player/v1/home`
|
||||
- `/player/v1/playlists/*`
|
||||
- `/player/v1/me/favorites/*`
|
||||
- `/player/v1/me/history`
|
||||
- `player.db`
|
||||
|
||||
目标:
|
||||
|
||||
- 为未来网页播放器或 App 提供后端
|
||||
|
||||
### Phase 4: 首个前端播放器
|
||||
|
||||
交付内容:
|
||||
|
||||
- 自建网页播放器或客户端
|
||||
|
||||
说明:
|
||||
|
||||
- 该阶段不在本 spec 的实现范围内
|
||||
- 本 spec 只负责把服务端接口边界设计清楚
|
||||
|
||||
## 15. 实施边界与仓库建议
|
||||
|
||||
建议保持仓库职责清晰,并以当前工作树为准:
|
||||
|
||||
- `D:\source\musicdl-catalog-sync-worktrees\catalog-sync`
|
||||
- 继续承载 `Music_Cloud / catalogsync`
|
||||
- 负责目录镜像导出、私有回源签名能力、对象存储存在性同步
|
||||
- `D:\source\musicdl-catalog-sync-worktrees\Music_Server`
|
||||
- 承载 `Public Music Service`
|
||||
- 包含 `/mf/v1/*` 与 `/player/v1/*`
|
||||
- 本 spec 与 implementation plans 也保存在这个仓库
|
||||
- `D:\source\musicdl-catalog-sync-worktrees\Music_Server\integrations\musicfree-plugin`
|
||||
- 承载 `MusicFree` 插件适配逻辑
|
||||
- 最终产出单文件插件或对应构建产物
|
||||
|
||||
第一阶段允许在 `Music_Server` 仓库内同时维护服务端代码和插件代码,但部署和运行时仍保持为两个独立产物。
|
||||
|
||||
## 16. 最终结论
|
||||
|
||||
最终架构定为:
|
||||
|
||||
- `Music_Cloud`:内网内容底座
|
||||
- `Public Music Service`:公网内容与播放服务
|
||||
- `MusicFree` 插件:薄适配层
|
||||
- `Player Backend`:与公网服务同部署、面向未来播放器前端的用户态 API
|
||||
- `Object Storage / CDN`:首选公网分发层
|
||||
|
||||
核心决策定为:
|
||||
|
||||
- 公网服务与 `Music_Cloud` 分开部署
|
||||
- 公网服务不直接读 NAS 主库,采用目录镜像快照
|
||||
- `MusicFree` 只连 `/mf/v1/*`
|
||||
- 未来网页播放器只连 `/player/v1/*`
|
||||
- 播放优先级为“对象存储 -> 公网缓存 -> NAS 私有回源 -> 外部平台回落”
|
||||
|
||||
这份 spec 作为后续 implementation plan 的唯一设计依据。
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
# MusicFree Pure Music_Server Plugin Design
|
||||
|
||||
日期:2026-04-19
|
||||
状态:已确认设计,待实现计划
|
||||
范围:`Music_Server` 榜单详情接口补齐、`MusicFree` 纯 `Music_Server` 插件、旧插件兼容壳
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Music_Server` 已经提供这些面向 `MusicFree` 的能力:
|
||||
|
||||
- `GET /mf/v1/recommend/tags`
|
||||
- `GET /mf/v1/recommend/sheets`
|
||||
- `GET /mf/v1/playlists/{id}`
|
||||
- `GET /mf/v1/playlists/{id}/tracks`
|
||||
- `GET /mf/v1/toplists`
|
||||
- `GET /mf/v1/search/songs`
|
||||
- `POST /mf/v1/media/resolve`
|
||||
|
||||
同时,当前 `MusicFree` 使用的 [netease_17000.js](/d:/source/MusicFree/keep-alive-master/Music_Free/netease_17000.js) 仍然是旧的网易 relay 风格插件,内部直接调用旧服务端接口,并带有与本轮目标无关的能力:
|
||||
|
||||
- `album` 搜索与详情
|
||||
- `artist` 搜索与作品
|
||||
- `lyric`
|
||||
- `importMusicSheet`
|
||||
- 旧的网易与 relay 回退逻辑
|
||||
|
||||
本轮要把插件收敛成“纯 `Music_Server` 插件”,并把服务端缺失的榜单详情链路补齐,使 `MusicFree` 可以完整走“搜歌、看歌单、看榜单、播放”这一条自有服务链路。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本轮目标:
|
||||
|
||||
- `Music_Server` 补齐榜单详情与榜单歌曲分页接口
|
||||
- `MusicFree` 新增正式插件 `music_server.js`
|
||||
- 旧插件 `netease_17000.js` 保留为兼容壳,转发到新插件
|
||||
- 插件只依赖 `Music_Server`,不再直连旧网易 relay 或其他平台
|
||||
- 插件能力只保留当前服务端已支持的:
|
||||
- `music` 搜索
|
||||
- 推荐歌单
|
||||
- 歌单详情
|
||||
- 榜单列表与榜单详情
|
||||
- 播放解析
|
||||
|
||||
非目标:
|
||||
|
||||
- 不做 `album` 搜索或专辑详情
|
||||
- 不做 `artist` 搜索或歌手作品页
|
||||
- 不做 `lyric`
|
||||
- 不做 `importMusicSheet`
|
||||
- 不做插件侧多平台回退
|
||||
- 不做插件直连网易、QQ、酷我等外部平台
|
||||
|
||||
## 3. 总体设计
|
||||
|
||||
采用“两端各补一小段”的方式完成:
|
||||
|
||||
1. `Music_Server`
|
||||
- 保持现有 `/mf/v1/*` 结构不变
|
||||
- 新增榜单详情与榜单歌曲接口
|
||||
- 返回结构与歌单详情链路保持一致
|
||||
|
||||
2. `MusicFree` 插件
|
||||
- 新增正式插件文件 `music_server.js`
|
||||
- 旧文件 `netease_17000.js` 退化为兼容壳
|
||||
- 所有方法仅调用 `Music_Server`
|
||||
- 仅负责请求与字段映射,不承担内容回源逻辑
|
||||
|
||||
这样做的原则是:
|
||||
|
||||
- 服务端负责内容真相与业务契约
|
||||
- 插件只负责协议适配
|
||||
- 旧入口兼容,新入口语义清晰
|
||||
|
||||
## 4. Music_Server 设计
|
||||
|
||||
### 4.1 数据模型现状
|
||||
|
||||
`catalog_read.db` 现有导出脚本 [export_catalog_read.py](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/scripts/export_catalog_read.py) 已经包含:
|
||||
|
||||
- `catalog_toplists`
|
||||
- `catalog_toplist_tracks`
|
||||
|
||||
因此本轮不需要改读模型导出结构,只需要把现有数据通过读取层和路由层暴露出来。
|
||||
|
||||
### 4.2 读取层补齐
|
||||
|
||||
在 [catalog_reader.py](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/src/music_server/services/catalog_reader.py) 中新增两个能力:
|
||||
|
||||
- `get_toplist(toplist_id: str) -> ToplistRow | None`
|
||||
- `list_toplist_tracks(toplist_id: str, page: int, page_size: int) -> list[PlaylistTrackRow]`
|
||||
|
||||
约束:
|
||||
|
||||
- `get_toplist()` 从 `catalog_toplists` 取单条
|
||||
- `list_toplist_tracks()` 从 `catalog_toplist_tracks` 联结 `catalog_tracks`
|
||||
- 排序按 `position asc`
|
||||
- 返回的歌曲对象字段与 `list_playlist_tracks()` 保持一致:
|
||||
- `song_id`
|
||||
- `name`
|
||||
- `singers`
|
||||
- `album`
|
||||
- `cover_url`
|
||||
- `duration_ms`
|
||||
|
||||
### 4.3 路由层补齐
|
||||
|
||||
在 [mf_catalog.py](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/src/music_server/routes/mf_catalog.py) 中新增:
|
||||
|
||||
- `GET /mf/v1/toplists/{toplist_id}`
|
||||
- `GET /mf/v1/toplists/{toplist_id}/tracks?page=&page_size=`
|
||||
|
||||
返回约束:
|
||||
|
||||
- `/mf/v1/toplists/{toplist_id}`
|
||||
- 返回对象 shape 与 `/mf/v1/playlists/{playlist_id}` 一致
|
||||
- `id` 使用 `catalogsync:toplist:{toplist_id}`
|
||||
- `platform` 固定返回 `catalogsync`
|
||||
|
||||
- `/mf/v1/toplists/{toplist_id}/tracks`
|
||||
- 返回对象 shape 与 `/mf/v1/playlists/{playlist_id}/tracks` 一致
|
||||
- 返回:
|
||||
- `isEnd`
|
||||
- `musicList`
|
||||
|
||||
错误语义:
|
||||
|
||||
- 找不到榜单:`404`
|
||||
- 缺少或错误 Bearer Token:`401`
|
||||
- 空榜单:`200` 且 `musicList: []`
|
||||
|
||||
### 4.4 与现有接口的关系
|
||||
|
||||
本轮不改这些接口的既有行为:
|
||||
|
||||
- `GET /mf/v1/recommend/tags`
|
||||
- `GET /mf/v1/recommend/sheets`
|
||||
- `GET /mf/v1/playlists/{id}`
|
||||
- `GET /mf/v1/playlists/{id}/tracks`
|
||||
- `GET /mf/v1/toplists`
|
||||
- `GET /mf/v1/search/songs`
|
||||
- `POST /mf/v1/media/resolve`
|
||||
|
||||
也不改导出脚本中的歌单、歌曲、文件位置逻辑。
|
||||
|
||||
## 5. MusicFree 插件设计
|
||||
|
||||
### 5.1 文件布局
|
||||
|
||||
插件文件放在 `D:\source\MusicFree\keep-alive-master\Music_Free`。
|
||||
|
||||
本轮交付:
|
||||
|
||||
- 新增 [music_server.js](/d:/source/MusicFree/keep-alive-master/Music_Free/music_server.js)
|
||||
- 改造 [netease_17000.js](/d:/source/MusicFree/keep-alive-master/Music_Free/netease_17000.js)
|
||||
|
||||
处理方式:
|
||||
|
||||
- `music_server.js` 作为正式插件文件
|
||||
- `netease_17000.js` 只保留一层兼容壳:
|
||||
- `module.exports = require("./music_server")`
|
||||
|
||||
这样既保留旧入口,又让正式插件名称与实际能力一致。
|
||||
|
||||
### 5.2 插件元数据
|
||||
|
||||
建议插件元数据固定为:
|
||||
|
||||
- `platform: "Music_Server"`
|
||||
- `version`: 本轮实现版本
|
||||
- `author`: 保留现有项目惯例
|
||||
- `supportedSearchType: ["music"]`
|
||||
|
||||
`userVariables` 只保留:
|
||||
|
||||
- `baseUrl`
|
||||
- `accessToken`
|
||||
|
||||
规则:
|
||||
|
||||
- `baseUrl` 指向 `Music_Server` 部署地址
|
||||
- `accessToken` 指向 `Music_Server` Bearer Token
|
||||
- 插件不内置 NAS 地址、旧 relay 地址或其他平台地址
|
||||
|
||||
### 5.3 请求层规则
|
||||
|
||||
插件请求层保持极薄:
|
||||
|
||||
- 所有 JSON 业务请求带:
|
||||
- `Authorization: Bearer <accessToken>`
|
||||
- `baseUrl` 统一去掉尾部斜杠
|
||||
- 不在插件内保存本地数据库或缓存目录
|
||||
- 不做多平台搜索与回退
|
||||
|
||||
`media/resolve` 特殊规则:
|
||||
|
||||
- 若 `stream.url` 是相对路径,例如 `/mf/v1/media/stream/...`
|
||||
- 插件自动拼接为 `${baseUrl}${url}`
|
||||
- 若 `stream.url` 已是绝对地址
|
||||
- 直接原样使用
|
||||
|
||||
### 5.4 插件能力映射
|
||||
|
||||
插件仅实现并暴露这些方法:
|
||||
|
||||
- `search(query, page, "music")`
|
||||
- 对应 `GET /mf/v1/search/songs`
|
||||
- `getRecommendSheetTags()`
|
||||
- 对应 `GET /mf/v1/recommend/tags`
|
||||
- `getRecommendSheetsByTag(tag, page)`
|
||||
- 对应 `GET /mf/v1/recommend/sheets`
|
||||
- `getMusicSheetInfo(sheetItem, page)`
|
||||
- 对应:
|
||||
- `GET /mf/v1/playlists/{id}`
|
||||
- `GET /mf/v1/playlists/{id}/tracks`
|
||||
- `getTopLists()`
|
||||
- 对应 `GET /mf/v1/toplists`
|
||||
- `getTopListDetail(topListItem, page)`
|
||||
- 对应:
|
||||
- `GET /mf/v1/toplists/{id}`
|
||||
- `GET /mf/v1/toplists/{id}/tracks`
|
||||
- `getMediaSource(musicItem, quality)`
|
||||
- 对应 `POST /mf/v1/media/resolve`
|
||||
|
||||
不再实现:
|
||||
|
||||
- `album`
|
||||
- `artist`
|
||||
- `lyric`
|
||||
- `importMusicSheet`
|
||||
|
||||
### 5.5 稳定 ID 规则
|
||||
|
||||
插件与服务端之间继续使用稳定 public id:
|
||||
|
||||
- 歌单:`catalogsync:playlist:{playlist_id}`
|
||||
- 榜单:`catalogsync:toplist:{toplist_id}`
|
||||
- 歌曲:`catalogsync:song:{song_id}`
|
||||
|
||||
插件只负责从 public id 解析出最后一段真实 id,然后发给服务端路由。
|
||||
|
||||
## 6. 字段映射
|
||||
|
||||
### 6.1 歌单与榜单对象
|
||||
|
||||
服务端返回的歌单、榜单对象直接映射成 MusicFree 的 `sheetItem` / `topListItem` 风格对象,核心字段统一为:
|
||||
|
||||
- `id`
|
||||
- `platform`
|
||||
- `title`
|
||||
- `coverImg`
|
||||
- `description`
|
||||
- `worksNum`
|
||||
- `playCount`
|
||||
|
||||
### 6.2 歌曲对象
|
||||
|
||||
服务端返回的 `musicItem` 直接映射,核心字段:
|
||||
|
||||
- `id`
|
||||
- `platform`
|
||||
- `title`
|
||||
- `artist`
|
||||
- `album`
|
||||
- `artwork`
|
||||
- `duration`
|
||||
|
||||
### 6.3 播放源对象
|
||||
|
||||
`getMediaSource()` 返回:
|
||||
|
||||
- `url`
|
||||
- `headers`
|
||||
- `quality`
|
||||
|
||||
其中:
|
||||
|
||||
- `headers` 来自服务端 `stream.headers`
|
||||
- `quality` 优先取服务端 `selected_source.quality`
|
||||
|
||||
## 7. 错误处理
|
||||
|
||||
### 7.1 插件配置错误
|
||||
|
||||
若 `baseUrl` 或 `accessToken` 为空:
|
||||
|
||||
- 插件直接抛出明确错误
|
||||
- 不做隐式默认值兜底
|
||||
- 不静默退回旧服务
|
||||
|
||||
### 7.2 浏览型接口错误
|
||||
|
||||
对于这些接口:
|
||||
|
||||
- 搜歌
|
||||
- 推荐歌单
|
||||
- 榜单列表
|
||||
|
||||
如果请求失败:
|
||||
|
||||
- 插件返回空结果
|
||||
- 同时保留清晰错误信息,方便调试
|
||||
|
||||
### 7.3 详情接口错误
|
||||
|
||||
对于:
|
||||
|
||||
- `getMusicSheetInfo`
|
||||
- `getTopListDetail`
|
||||
|
||||
若详情页或 tracks 请求失败:
|
||||
|
||||
- 不伪造旧平台数据
|
||||
- 返回空 `musicList`
|
||||
- 第 1 页如果头信息获取失败,不补假数据
|
||||
|
||||
### 7.4 播放解析错误
|
||||
|
||||
若 `/mf/v1/media/resolve` 失败或无可播放地址:
|
||||
|
||||
- `getMediaSource()` 返回 `null`
|
||||
- 不回退到旧网易、QQ、酷我逻辑
|
||||
|
||||
## 8. 测试设计
|
||||
|
||||
### 8.1 Music_Server 测试
|
||||
|
||||
在 `Music_Server` 仓库补这些测试:
|
||||
|
||||
- `CatalogReader`
|
||||
- `get_toplist()`
|
||||
- `list_toplist_tracks()`
|
||||
- `mf_catalog` 路由
|
||||
- `GET /mf/v1/toplists/{id}`
|
||||
- `GET /mf/v1/toplists/{id}/tracks`
|
||||
|
||||
重点覆盖:
|
||||
|
||||
- 正常返回 shape
|
||||
- 第 1 页与末页 `isEnd`
|
||||
- `404`
|
||||
- `401`
|
||||
|
||||
### 8.2 MusicFree 插件测试
|
||||
|
||||
在 `MusicFree` 仓库新增独立测试,测试目标是 `music_server.js`,不再复用旧 `netease_17000.test.js` 的网易 fallback 语义。
|
||||
|
||||
重点覆盖:
|
||||
|
||||
- `userVariables` 只有 `baseUrl` 与 `accessToken`
|
||||
- `supportedSearchType` 只有 `music`
|
||||
- 搜歌走 `/mf/v1/search/songs`
|
||||
- 歌单详情走 `/mf/v1/playlists/*`
|
||||
- 榜单详情走 `/mf/v1/toplists/*`
|
||||
- `getMediaSource()` 正确处理相对流地址与绝对流地址
|
||||
- `netease_17000.js` 兼容壳导出与 `music_server.js` 相同插件对象
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
联调通过标准:
|
||||
|
||||
- `MusicFree` 能搜歌
|
||||
- 能看推荐歌单
|
||||
- 能点进歌单并加载歌曲列表
|
||||
- 能看榜单列表
|
||||
- 能点进榜单并加载歌曲列表
|
||||
- 能播放 `Music_Server` 返回的流地址
|
||||
- 插件逻辑不再依赖旧网易 relay 搜索或播放路径
|
||||
|
||||
## 10. 实施边界
|
||||
|
||||
代码边界保持如下:
|
||||
|
||||
- `Music_Server` 服务端代码只改 `D:\source\musicdl-catalog-sync-worktrees\Music_Server`
|
||||
- `MusicFree` 插件代码只改 `D:\source\MusicFree\keep-alive-master\Music_Free`
|
||||
- 不回到 `D:\source\musicdl` 老仓库实现服务逻辑
|
||||
|
||||
本 spec 只覆盖“纯 `Music_Server` 插件 + 服务端榜单详情补齐”这一小段工作,后续若要继续扩充 `album`、`artist`、`lyric`,需要新 spec 或新实现计划单独推进。
|
||||
@@ -0,0 +1,559 @@
|
||||
# Music_Server 单终端绑定 Token 与时效控制设计
|
||||
|
||||
日期:2026-04-20
|
||||
状态:已确认设计,待实现计划
|
||||
范围:`Music_Server`、`MusicFree` 插件、`MusicFree` 客户端插件列表页
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Music_Server` 的鉴权逻辑非常简单:
|
||||
|
||||
- 所有受保护接口只校验 `Authorization: Bearer <token>`
|
||||
- 服务端把 Bearer 字符串与 `PUBLIC_MUSIC_ACCESS_TOKEN` 做一次直接比较
|
||||
- 不区分不同终端
|
||||
- 不支持过期、撤销、解绑、状态查询
|
||||
|
||||
这套逻辑足够支撑最初的自用联调,但在当前链路下已经不够用:
|
||||
|
||||
- `MusicFree` 插件已经支持通过订阅地址自动更新,插件分发更方便后,访问控制也需要更精细
|
||||
- 你希望一个 token 只能给一个终端使用,避免同一个 token 被多端直接共享
|
||||
- 你希望 token 本身有失效时间,例如默认 90 天
|
||||
- 你还希望在 `MusicFree` 插件管理页一眼看到 token 是否可用、剩余多久、是否已绑到别的终端,以及当前服务里已有多少首可播歌曲
|
||||
|
||||
本设计要解决的是“可控授权”,不是“强 DRM”。目标是把权限边界收紧到“一个 token 对应一个当前终端,并且 token 自身会过期”,同时保持部署和日常签发仍然足够简单。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 一个 token 在同一时刻只能绑定一个终端
|
||||
- token 首次被合法终端使用时自动绑定
|
||||
- token 本身有明确过期时间,默认 90 天
|
||||
- 支持查看、签发、撤销、解绑 token
|
||||
- `Music_Server` 能返回 token 状态,供 `MusicFree` 展示剩余时间和绑定情况
|
||||
- `Music_Server` 的状态接口在 token 有效时,同时返回当前全局可播歌曲数
|
||||
- `MusicFree` 为每个安装实例持久化一个随机 `clientId`
|
||||
- 清缓存或重装后生成新的 `clientId`,服务端视为另一台终端
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不做硬件指纹、IMEI、MAC 地址等强设备识别
|
||||
- 不做自动续期,token 过期后必须重新签发
|
||||
- 不做“一个 token 多终端共享配额”
|
||||
- 不做歌单、搜索、播放接口语义调整
|
||||
- 不修改 `catalog-sync` 的采集、下载、导出链路
|
||||
|
||||
## 3. 设计结论
|
||||
|
||||
本轮采用以下方案:
|
||||
|
||||
1. `Music_Server` 把 token 状态落到本地可写库 `player.db`
|
||||
2. token 不明文入库,只保存哈希
|
||||
3. 业务接口使用“Bearer + clientId”联合校验
|
||||
4. token 第一次被使用时自动首绑到当前 `clientId`
|
||||
5. token 一旦绑定后,只允许同一 `clientId` 继续使用
|
||||
6. token 过期或被撤销后立即失效,不做自动恢复
|
||||
7. `MusicFree` 客户端为插件维护稳定的随机 `clientId`,插件只负责随请求发送
|
||||
8. `MusicFree` 插件列表页通过新状态接口显示“剩余时间/绑定状态/可播歌曲数”
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 对现有部署侵入最小,不需要额外数据库服务
|
||||
- 绑定语义清楚,用户容易理解
|
||||
- 清缓存后的行为也一致:会变成“另一台终端”,而不是偷偷重置有效期
|
||||
- 服务端掌握最终状态判断,插件和客户端只展示,不各自发明规则
|
||||
|
||||
## 4. 系统边界
|
||||
|
||||
### 4.1 Music_Server
|
||||
|
||||
负责:
|
||||
|
||||
- token 的签发数据存储
|
||||
- token 过期、撤销、绑定状态校验
|
||||
- 首绑与续用时更新时间戳
|
||||
- 对外提供 token 状态接口
|
||||
- 从 `catalog_read.db` 聚合当前至少有一个 active 文件位置的歌曲总数
|
||||
- 为运维提供签发、列出、撤销、解绑命令
|
||||
|
||||
不负责:
|
||||
|
||||
- 识别物理设备真身
|
||||
- 自动续期
|
||||
- 为插件生成 UI 文案
|
||||
|
||||
### 4.2 MusicFree 插件
|
||||
|
||||
负责:
|
||||
|
||||
- 从运行环境拿到 `baseUrl`、`accessToken`、`clientId`
|
||||
- 给所有 `Music_Server` 请求附带鉴权头
|
||||
- 调用 token 状态接口并把结果透传给客户端展示层,包括 `playableSongCount`
|
||||
|
||||
不负责:
|
||||
|
||||
- 本地计算 token 是否过期
|
||||
- 本地决定绑定是否合法
|
||||
- 本地生成“剩余 89 天”这种最终展示文案
|
||||
|
||||
### 4.3 MusicFree 客户端
|
||||
|
||||
负责:
|
||||
|
||||
- 为 `Music_Server` 插件保存稳定的随机 `clientId`
|
||||
- 在插件管理列表页展示 token 状态与当前可播歌曲数
|
||||
- 在用户修改 `accessToken` 后刷新一次状态
|
||||
|
||||
不负责:
|
||||
|
||||
- 直接解析或验证 token
|
||||
- 代替服务端决定绑定、过期、撤销逻辑
|
||||
|
||||
### 4.4 catalog-sync
|
||||
|
||||
本轮无改动。它继续只负责歌单采集、下载、上传、导出,不参与 Music_Server 的访问授权。
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
### 5.1 存储位置
|
||||
|
||||
token 元数据写入 `Music_Server` 的 `player.db`。原因:
|
||||
|
||||
- `catalog_read.db` 是读模型快照,不适合存可写授权状态
|
||||
- `player.db` 已经承担 `Music_Server` 本地状态数据职责,继续放 token 合理且部署简单
|
||||
|
||||
### 5.2 access_tokens 表
|
||||
|
||||
新增表:`access_tokens`
|
||||
|
||||
建议字段:
|
||||
|
||||
- `token_id TEXT PRIMARY KEY`
|
||||
- `token_hash TEXT NOT NULL UNIQUE`
|
||||
- `label TEXT`
|
||||
- `issued_at TEXT NOT NULL`
|
||||
- `expires_at TEXT NOT NULL`
|
||||
- `bound_client_id TEXT`
|
||||
- `bound_client_label TEXT`
|
||||
- `bound_at TEXT`
|
||||
- `last_seen_at TEXT`
|
||||
- `revoked_at TEXT`
|
||||
- `revoked_reason TEXT`
|
||||
|
||||
字段语义:
|
||||
|
||||
- `token_id`:运维侧稳定主键,供 `list/revoke/unbind` 使用
|
||||
- `token_hash`:token 明文的哈希值,服务端不保存明文
|
||||
- `label`:签发时填的备注,例如 `iphone16`
|
||||
- `issued_at`:签发时间
|
||||
- `expires_at`:失效时间
|
||||
- `bound_client_id`:当前已绑定终端 ID
|
||||
- `bound_client_label`:终端备注,例如 `My iPhone`
|
||||
- `bound_at`:首次绑定时间
|
||||
- `last_seen_at`:最近一次成功校验时间
|
||||
- `revoked_at`:撤销时间,非空即永久失效
|
||||
- `revoked_reason`:撤销备注,可空
|
||||
|
||||
建议索引:
|
||||
|
||||
- `UNIQUE INDEX idx_access_tokens_token_hash(token_hash)`
|
||||
- `INDEX idx_access_tokens_expires_at(expires_at)`
|
||||
- `INDEX idx_access_tokens_bound_client_id(bound_client_id)`
|
||||
|
||||
### 5.3 token 明文格式
|
||||
|
||||
token 明文设计为高熵随机字符串,例如:
|
||||
|
||||
```text
|
||||
msv1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 明文只在签发时输出一次
|
||||
- 服务端落库时只保存哈希
|
||||
- 后续所有管理动作以 `token_id` 为主,不依赖再次输入明文
|
||||
|
||||
## 6. 鉴权与绑定流程
|
||||
|
||||
### 6.1 请求头约定
|
||||
|
||||
所有受保护的 `Music_Server` 接口统一接受:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
- `X-Music-Client-Id: <clientId>`
|
||||
- `X-Music-Client-Label: <clientLabel>` 可选
|
||||
|
||||
其中:
|
||||
|
||||
- `clientId` 是 `MusicFree` 客户端为该插件实例持久化的随机 ID
|
||||
- `clientLabel` 是可选终端备注,不参与唯一性判断,只用于展示
|
||||
|
||||
### 6.2 校验顺序
|
||||
|
||||
服务端按以下顺序处理:
|
||||
|
||||
1. 校验 `Authorization` 是否存在且格式正确
|
||||
2. 校验 `X-Music-Client-Id` 是否存在
|
||||
3. 根据 token 明文哈希查 `access_tokens`
|
||||
4. 若 token 不存在,拒绝
|
||||
5. 若 `revoked_at` 非空,拒绝
|
||||
6. 若当前时间超过 `expires_at`,拒绝
|
||||
7. 若 `bound_client_id` 为空,则原子地绑定到当前 `clientId`
|
||||
8. 若 `bound_client_id` 等于当前 `clientId`,允许通过并更新 `last_seen_at`
|
||||
9. 若 `bound_client_id` 不等于当前 `clientId`,拒绝
|
||||
|
||||
### 6.3 首绑规则
|
||||
|
||||
首绑发生在“第一次成功访问受保护接口”时,而不是签发时。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 不需要在签发时就知道具体终端
|
||||
- token 可以先发出去,再由用户首次在设备上配置
|
||||
- 终端备注也能在首次请求时一并带上
|
||||
|
||||
这里的“受保护接口”包括业务接口,也包括 `GET /auth/v1/token-status`。也就是说,用户第一次在 `MusicFree` 里配置好插件后,插件列表页如果先请求状态接口,也可以完成首绑。
|
||||
|
||||
### 6.4 清缓存/重装行为
|
||||
|
||||
`MusicFree` 清缓存或卸载重装后,会生成新的 `clientId`。在本设计里,这被视为另一台终端:
|
||||
|
||||
- token 原有过期时间不变
|
||||
- 不会重置 90 天倒计时
|
||||
- 若 token 已绑到旧 `clientId`,新 `clientId` 再使用会被拒绝
|
||||
- 用户需要执行 `unbind-token` 或重新签发一个新 token
|
||||
|
||||
这与用户已确认的预期一致。
|
||||
|
||||
## 7. 状态接口设计
|
||||
|
||||
### 7.1 路由
|
||||
|
||||
新增:
|
||||
|
||||
- `GET /auth/v1/token-status`
|
||||
|
||||
### 7.2 输入
|
||||
|
||||
请求头与业务接口一致:
|
||||
|
||||
- `Authorization`
|
||||
- `X-Music-Client-Id`
|
||||
- `X-Music-Client-Label` 可选
|
||||
|
||||
### 7.3 输出
|
||||
|
||||
建议统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"status": "active",
|
||||
"tokenId": "tok_01",
|
||||
"label": "iphone16",
|
||||
"issuedAt": "2026-04-20T08:00:00Z",
|
||||
"expiresAt": "2026-07-19T08:00:00Z",
|
||||
"remainingSeconds": 7776000,
|
||||
"remainingDays": 90,
|
||||
"playableSongCount": 12345,
|
||||
"bound": true,
|
||||
"isCurrentClientBound": true,
|
||||
"boundClientLabel": "My iPhone"
|
||||
}
|
||||
```
|
||||
|
||||
其中 `playableSongCount` 的语义固定为:当前 `Music_Server` 读模型中,至少存在一个 active 文件位置的歌曲总数。
|
||||
|
||||
返回策略:
|
||||
|
||||
- 当 `status = active` 且统计成功时,返回实际整数值
|
||||
- 当 token 不可用,或当前无法可靠统计时,返回 `null`
|
||||
- 插件列表页只在该字段为数值时展示“可播 N 首”
|
||||
|
||||
状态码约定:
|
||||
|
||||
- `401`:`Authorization` 缺失或 Bearer 格式非法
|
||||
- `200`:Bearer 格式合法且已进入状态判断,具体状态写在 body 中
|
||||
|
||||
`status` 枚举:
|
||||
|
||||
- `active`
|
||||
- `expired`
|
||||
- `revoked`
|
||||
- `bound_to_other_client`
|
||||
- `token_not_found`
|
||||
- `client_id_missing`
|
||||
|
||||
设计原因:
|
||||
|
||||
- 业务接口继续严格返回 `401`
|
||||
- 状态接口允许把“为什么不可用”讲清楚,方便插件列表页展示
|
||||
- `X-Music-Client-Id` 缺失时,状态接口返回 `200 + status=client_id_missing`,而不是直接短路成 `401`
|
||||
|
||||
### 7.4 业务接口错误码
|
||||
|
||||
受保护业务接口继续使用 `401`,但建议响应 body 带明确 `detail` code:
|
||||
|
||||
- `unauthorized`
|
||||
- `client_id_missing`
|
||||
- `token_not_found`
|
||||
- `token_expired`
|
||||
- `token_revoked`
|
||||
- `token_bound_to_other_client`
|
||||
|
||||
这能让插件或调试日志更容易定位问题。
|
||||
|
||||
## 8. 管理命令设计
|
||||
|
||||
### 8.1 目标命令
|
||||
|
||||
本轮提供四个命令:
|
||||
|
||||
- `issue-token`
|
||||
- `list-tokens`
|
||||
- `revoke-token`
|
||||
- `unbind-token`
|
||||
|
||||
### 8.2 推荐命令行形式
|
||||
|
||||
签发:
|
||||
|
||||
```bash
|
||||
python -m music_server.tools.issue_token --days 90 --label iphone16
|
||||
```
|
||||
|
||||
列出:
|
||||
|
||||
```bash
|
||||
python -m music_server.tools.list_tokens
|
||||
```
|
||||
|
||||
撤销:
|
||||
|
||||
```bash
|
||||
python -m music_server.tools.revoke_token --token-id tok_01 --reason replaced
|
||||
```
|
||||
|
||||
解绑:
|
||||
|
||||
```bash
|
||||
python -m music_server.tools.unbind_token --token-id tok_01
|
||||
```
|
||||
|
||||
### 8.3 命令语义
|
||||
|
||||
`issue-token`
|
||||
|
||||
- 默认 `--days 90`
|
||||
- 输出 `token_id`、明文 token、`expires_at`
|
||||
- 明文 token 只在这一步展示
|
||||
|
||||
`list-tokens`
|
||||
|
||||
- 默认列出未撤销 token
|
||||
- 至少显示:`token_id`、`label`、`expires_at`、是否已绑定、绑定备注、最近使用时间、是否已撤销
|
||||
|
||||
`revoke-token`
|
||||
|
||||
- 设置 `revoked_at`
|
||||
- 一旦撤销,不可恢复使用
|
||||
|
||||
`unbind-token`
|
||||
|
||||
- 清空 `bound_client_id`
|
||||
- 清空 `bound_client_label`
|
||||
- 清空 `bound_at`
|
||||
- 不修改 `expires_at`
|
||||
- token 若未过期,下一次可重新绑定到新终端
|
||||
|
||||
## 9. MusicFree 插件设计
|
||||
|
||||
### 9.1 插件用户变量
|
||||
|
||||
插件用户变量保持最小集:
|
||||
|
||||
- `baseUrl`
|
||||
- `accessToken`
|
||||
|
||||
不把 `clientId` 暴露为用户可编辑输入项,避免用户随手改掉后破坏绑定语义。
|
||||
|
||||
### 9.2 clientId 存储位置
|
||||
|
||||
`clientId` 由 `MusicFree` 客户端生成并持久化到插件私有元数据,而不是存到插件的 `userVariables`:
|
||||
|
||||
- `userVariables` 是面向用户可编辑的
|
||||
- `clientId` 应该是客户端私有运行时状态
|
||||
|
||||
建议在插件元数据存储中新增类似键位:
|
||||
|
||||
- `${pluginPlatform}.runtimeClientId`
|
||||
- `${pluginPlatform}.runtimeClientLabel`
|
||||
|
||||
插件运行环境通过注入的 `env` 读取它,而不是自己写文件。
|
||||
|
||||
### 9.3 插件请求行为
|
||||
|
||||
插件对所有 `Music_Server` 请求统一附带:
|
||||
|
||||
- `Authorization: Bearer <accessToken>`
|
||||
- `X-Music-Client-Id: <runtimeClientId>`
|
||||
- `X-Music-Client-Label: <runtimeClientLabel>` 可选
|
||||
|
||||
插件新增一个轻量状态查询方法,供客户端列表页调用:
|
||||
|
||||
- 调用 `GET /auth/v1/token-status`
|
||||
- 原样返回服务端状态结果,包括 `playableSongCount`
|
||||
|
||||
## 10. MusicFree 客户端展示设计
|
||||
|
||||
### 10.1 展示位置
|
||||
|
||||
只在插件管理列表页展示,不扩散到更多页面。
|
||||
|
||||
目标位置:
|
||||
|
||||
- `src/pages/setting/settingTypes/pluginSetting/components/pluginItem.tsx`
|
||||
|
||||
### 10.2 展示策略
|
||||
|
||||
只对 `Music_Server` 插件显示状态信息。推荐文案映射:
|
||||
|
||||
- `active` 且剩余大于 1 天:主文案为 `Token 剩余 89 天`
|
||||
- `active` 且剩余不足 1 天:主文案为 `Token 今日到期`
|
||||
- `expired`:`Token 已过期`
|
||||
- `revoked`:`Token 已撤销`
|
||||
- `bound_to_other_client`:`Token 已绑定其他终端`
|
||||
- `token_not_found`:`Token 无效`
|
||||
- 请求失败:`Token 状态获取失败`
|
||||
|
||||
当满足以下条件时:
|
||||
|
||||
- `status = active`
|
||||
- `playableSongCount` 为数值
|
||||
|
||||
则在主文案后追加:
|
||||
|
||||
- `可播 12345 首`
|
||||
|
||||
组合后的推荐展示形态:
|
||||
|
||||
- `Token 剩余 89 天 · 可播 12345 首`
|
||||
|
||||
若服务端返回:
|
||||
|
||||
- `valid = true`
|
||||
- `isCurrentClientBound = true`
|
||||
|
||||
则可附加补充文案:
|
||||
|
||||
- `已绑定当前终端`
|
||||
|
||||
### 10.3 刷新时机
|
||||
|
||||
不做高频轮询,采用轻量刷新:
|
||||
|
||||
- 打开插件列表页时刷新一次
|
||||
- 用户保存 `accessToken` 后刷新一次
|
||||
- 手动更新插件后刷新一次
|
||||
|
||||
这样能拿到足够新的状态,同时避免插件列表页每秒请求服务端。
|
||||
|
||||
## 11. 边界行为
|
||||
|
||||
### 11.1 已绑定 token 再被别的终端使用
|
||||
|
||||
结果:
|
||||
|
||||
- 业务接口返回 `401`
|
||||
- 状态接口返回 `200 + status=bound_to_other_client`
|
||||
|
||||
### 11.2 token 到期
|
||||
|
||||
结果:
|
||||
|
||||
- 所有业务接口拒绝
|
||||
- 状态页显示已过期
|
||||
- 只能重新签发新 token,不能靠刷新 clientId 恢复
|
||||
|
||||
### 11.3 token 被解绑
|
||||
|
||||
结果:
|
||||
|
||||
- 旧终端下一次请求会因“未再次绑定”而进入重新首绑流程
|
||||
- 哪个终端先成功请求,token 就重新绑给谁
|
||||
- 解绑不延长 token 生命周期
|
||||
|
||||
### 11.4 token 被撤销
|
||||
|
||||
结果:
|
||||
|
||||
- 所有终端全部失效
|
||||
- 即便原绑定终端也不能继续使用
|
||||
|
||||
### 11.5 用户只改 baseUrl,不改 token
|
||||
|
||||
如果切换到另一台 `Music_Server`:
|
||||
|
||||
- 新服务端把它当作独立 token 空间
|
||||
- token 是否可用只由新服务端自己的 `access_tokens` 决定
|
||||
|
||||
## 12. 测试与验收
|
||||
|
||||
### 12.1 Music_Server
|
||||
|
||||
新增测试覆盖:
|
||||
|
||||
- token 签发后可被首绑
|
||||
- 已绑定同一 `clientId` 可继续访问
|
||||
- 不同 `clientId` 被拒绝
|
||||
- 过期 token 被拒绝
|
||||
- 撤销 token 被拒绝
|
||||
- `unbind-token` 后可重新首绑
|
||||
- `GET /auth/v1/token-status` 返回正确状态,并在有效 token 下带上 `playableSongCount`
|
||||
|
||||
### 12.2 MusicFree 插件
|
||||
|
||||
新增测试覆盖:
|
||||
|
||||
- 请求头正确携带 `Authorization`
|
||||
- 请求头正确携带 `X-Music-Client-Id`
|
||||
- 状态接口调用正确,并能透传 `playableSongCount`
|
||||
- 缺少 `baseUrl` 或 `accessToken` 时给出明确错误
|
||||
|
||||
### 12.3 MusicFree 客户端
|
||||
|
||||
新增测试覆盖:
|
||||
|
||||
- `Music_Server` 插件列表项显示 token 状态行
|
||||
- 不同状态映射为正确文案
|
||||
- `playableSongCount` 为数值时,列表项追加显示“可播 N 首”
|
||||
- 保存用户变量后会刷新状态
|
||||
- 插件私有 `clientId` 可持久化复用
|
||||
|
||||
### 12.4 验收标准
|
||||
|
||||
以下条件同时满足即视为完成:
|
||||
|
||||
1. 新签发 token 默认 90 天有效
|
||||
2. 第一次在 `MusicFree` 配置后会自动绑定当前终端
|
||||
3. 同一个 token 在第二台终端上会被拒绝
|
||||
4. 清缓存后生成新 `clientId`,旧 token 不会自动恢复可用
|
||||
5. `unbind-token` 后,未过期 token 能重新绑定到新终端
|
||||
6. 插件管理列表页能看到剩余时间或失败原因;当 token 有效且统计成功时,还能看到可播歌曲数
|
||||
7. 业务接口不会因为状态展示需求而放松鉴权
|
||||
|
||||
## 13. 实施边界
|
||||
|
||||
本 spec 只覆盖:
|
||||
|
||||
- `D:\source\musicdl-catalog-sync-worktrees\Music_Server`
|
||||
- `D:\source\MusicFree`
|
||||
|
||||
不回到旧仓库 `D:\source\musicdl` 实现这部分功能。
|
||||
|
||||
本轮完成后,下一步应进入实现计划阶段,再拆成:
|
||||
|
||||
- `Music_Server` token 存储与认证
|
||||
- `MusicFree` 插件请求头与状态查询
|
||||
- `MusicFree` 客户端插件列表状态与可播歌曲数展示
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
# 下载完成自动导出与仅展示可播歌曲设计
|
||||
|
||||
日期:2026-04-20
|
||||
状态:已确认设计,待实现计划
|
||||
范围:`catalog-sync`、`Music_Server`、`MusicFree`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前链路存在两个直接影响使用体验的问题:
|
||||
|
||||
- `catalog-sync` 下载歌曲后,只会写入 `catalogsync.db`,不会自动刷新 `Music_Server` 读取的 `catalog_read.db`。这意味着歌曲虽然已经下载成功,但 `Music_Server` 和 `MusicFree` 侧未必能立刻看到最新可播结果。
|
||||
- `Music_Server` 歌单详情和榜单详情当前返回全量歌曲,其中大量条目实际上没有任何 `active` 文件位置,结果是歌单里能看到很多歌,但点开并不能稳定播放。
|
||||
|
||||
当前三个仓库的职责边界已经基本清晰:
|
||||
|
||||
- `catalog-sync` 负责采集、同步、下载、上传、入库和任务编排,是写入侧和资产管理侧。
|
||||
- `Music_Server` 负责读取导出的只读快照并对外提供歌单、榜单、搜索、播放解析接口,是只读分发层。
|
||||
- `MusicFree` 负责协议适配和前端展示,不直接接触网易、QQ 等平台接口,也不直接读取数据库。
|
||||
|
||||
本设计要解决的问题是:
|
||||
|
||||
1. 下载任务完成后,自动触发一次 `catalog-export`,让最新已下载歌曲尽快进入 `Music_Server` 可播视图。
|
||||
2. 歌单和榜单详情只展示可播歌曲,不再展示当前无法播放的条目。
|
||||
3. 歌单入口和详情头部同时显示“可播数/总数”,例如 `3/10`。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 下载任务结束后自动刷新 `catalog_read.db`。
|
||||
- `Music_Server` 歌单详情、榜单详情只返回当前可播歌曲。
|
||||
- 歌单卡片、榜单卡片、歌单详情头部统一展示 `可播数/总数`。
|
||||
- 搜索、歌单详情、榜单详情、播放解析对“可播”的定义保持一致:只有存在至少一个 `active` 文件位置的歌曲才视为可播。
|
||||
- 下载成功后,新的可播歌曲能够在一次自动导出后被 `Music_Server` 和 `MusicFree` 看见,无需人工再跑一遍导出。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 本轮不再展示“灰掉但不可播”的歌曲条目。
|
||||
- 本轮不在 `collect` 或 `sync` 任务结束后自动触发 `catalog-export`。
|
||||
- 本轮不改变 `Music_Server` 的播放解析优先级,不新增新的回源策略。
|
||||
- 本轮不新增歌手搜索、专辑搜索或其它新的客户端展示能力。
|
||||
|
||||
## 3. 设计结论
|
||||
|
||||
本轮采用以下设计:
|
||||
|
||||
1. `catalog-sync` 在 `download` stage 进入终态后,按每个下载 stage 触发一次外部 `catalog-export` 命令。
|
||||
2. `Music_Server` 的读模型中显式保存总歌曲数和可播歌曲数。
|
||||
3. `Music_Server` 的歌单/榜单详情接口只返回可播歌曲。
|
||||
4. `MusicFree` 插件透传“可播数”字段。
|
||||
5. `MusicFree` 客户端在歌单入口和详情头部显示 `可播数/总数`,而不是只显示单个总数。
|
||||
|
||||
## 4. 系统边界
|
||||
|
||||
### 4.1 `catalog-sync`
|
||||
|
||||
继续负责:
|
||||
|
||||
- 采集歌单池、榜单、歌单详情、歌曲详情
|
||||
- 下载歌曲并维护本地文件记录
|
||||
- 上传对象存储并维护文件位置
|
||||
- 维护任务队列、任务状态、任务日志
|
||||
|
||||
本轮新增:
|
||||
|
||||
- 在下载 stage 完成后触发一次只读快照导出命令
|
||||
- 记录导出成功、失败、跳过等事件
|
||||
|
||||
### 4.2 `Music_Server`
|
||||
|
||||
继续负责:
|
||||
|
||||
- 从 `catalog_read.db` 读取歌单、榜单、歌曲、文件位置
|
||||
- 向 `MusicFree` 提供 `/mf/v1/*` 接口
|
||||
- 解析可播文件并返回媒体地址
|
||||
|
||||
本轮新增:
|
||||
|
||||
- 在读模型中维护可播歌曲计数
|
||||
- 歌单和榜单详情接口过滤为“只显示可播歌曲”
|
||||
- 对歌单/榜单对象返回 `playableSongCount`
|
||||
|
||||
### 4.3 `MusicFree`
|
||||
|
||||
继续负责:
|
||||
|
||||
- 通过插件调用 `Music_Server`
|
||||
- 把服务端对象映射为 `MusicFree` 的 `sheetItem` / `musicItem`
|
||||
- 渲染歌单入口、歌单详情和播放列表
|
||||
|
||||
本轮新增:
|
||||
|
||||
- 插件透传 `playableSongCount`
|
||||
- 客户端在远程歌单入口和歌单详情头部显示 `playable/total`
|
||||
|
||||
## 5. 详细设计
|
||||
|
||||
### 5.1 下载完成后自动触发 `catalog-export`
|
||||
|
||||
#### 5.1.1 触发时机
|
||||
|
||||
自动导出只绑定到 `download` stage,不绑定 `collect`、`sync`、`upload`。
|
||||
|
||||
触发条件:
|
||||
|
||||
- 当前 stage 类型为 `download`
|
||||
- 当前 stage 已进入终态
|
||||
- 当前终态为 `COMPLETED` 或 `FAILED`
|
||||
- 当前 job 不是被取消或暂停中断
|
||||
|
||||
这里明确允许 `download` stage 在“部分歌曲失败”的情况下仍触发导出。原因是即便 stage 最终标记为 `FAILED`,其中成功下载的歌曲仍然已经落库,应该尽快进入只读快照,不能因为少量失败就让整批成功结果对外不可见。
|
||||
|
||||
不触发的情况:
|
||||
|
||||
- `download` stage 被暂停
|
||||
- `download` stage 被取消
|
||||
- job 没有 `download` stage
|
||||
|
||||
#### 5.1.2 执行方式
|
||||
|
||||
`catalog-sync` 不直接导入 `Music_Server` 仓库中的 Python 模块,也不硬编码另一个仓库的本地路径,而是通过可配置的外部命令调用导出。
|
||||
|
||||
新增配置:
|
||||
|
||||
- `CATALOG_EXPORT_COMMAND`
|
||||
- `CATALOG_EXPORT_WORKDIR`(可选)
|
||||
|
||||
推荐的 NAS 部署方式是把它配置成一个固定脚本,例如:
|
||||
|
||||
```bash
|
||||
bash /volume4/Music_Cloud/Music_Server/scripts/catalog-export.sh
|
||||
```
|
||||
|
||||
这样做的好处:
|
||||
|
||||
- 不把两个仓库耦合成 Python 代码级依赖
|
||||
- 迁移路径时只需改部署配置,不必改代码
|
||||
- 后续可以把导出命令替换成任意脚本、容器命令或 systemd 任务
|
||||
|
||||
#### 5.1.3 并发与串行化
|
||||
|
||||
导出命令需要串行执行,不能允许多个下载任务在极短时间内并发重建同一个 `catalog_read.db`。
|
||||
|
||||
因此 `catalog-sync` runner 侧需要增加一个进程内导出锁:
|
||||
|
||||
- 同一时刻只允许一个 `catalog-export` 执行
|
||||
- 如果未来存在多个下载 job,同一时段的导出也必须串行
|
||||
|
||||
当前系统已经限制同一时间只跑一个下载任务,但这里仍然显式设计锁,作为安全保障和后续扩展预留。
|
||||
|
||||
#### 5.1.4 失败处理
|
||||
|
||||
导出命令失败时:
|
||||
|
||||
- 只记录 job event 和日志
|
||||
- 不回滚任何已写入的下载结果
|
||||
- 不把整个下载 job 从“已完成/部分完成”改判为失败
|
||||
|
||||
建议的事件类型:
|
||||
|
||||
- `catalog_export_skipped`
|
||||
- `catalog_export_started`
|
||||
- `catalog_export_succeeded`
|
||||
- `catalog_export_failed`
|
||||
|
||||
无配置时:
|
||||
|
||||
- 跳过执行
|
||||
- 记录 `catalog_export_skipped`
|
||||
|
||||
### 5.2 `Music_Server` 读模型与查询口径
|
||||
|
||||
#### 5.2.1 读模型新增字段
|
||||
|
||||
在 `catalog_read.db` 中为歌单和榜单读模型新增可播计数字段:
|
||||
|
||||
- `catalog_playlists.playable_song_count`
|
||||
- `catalog_toplists.playable_song_count`
|
||||
|
||||
保留现有总数字段:
|
||||
|
||||
- `catalog_playlists.song_count`
|
||||
- `catalog_toplists.song_count`
|
||||
|
||||
字段语义:
|
||||
|
||||
- `song_count`:该歌单或榜单的总歌曲数
|
||||
- `playable_song_count`:该歌单或榜单中,存在至少一个 `active` 文件位置的歌曲数
|
||||
|
||||
#### 5.2.2 导出时的统计规则
|
||||
|
||||
`export_catalog_read.py` 在生成只读库时计算 `playable_song_count`。
|
||||
|
||||
统计规则:
|
||||
|
||||
- 以歌单/榜单中的歌曲集合为基础
|
||||
- 只统计当前存在至少一条 `catalog_track_files.status = 'active'` 记录的歌曲
|
||||
- 即使同一首歌存在多条 `active` 文件位置,也只计数一次
|
||||
|
||||
这样可以确保:
|
||||
|
||||
- 歌单总数固定反映源歌单规模
|
||||
- 可播数固定反映当前系统真实可播规模
|
||||
- 接口层无需每次再做大范围聚合,读性能更稳定
|
||||
|
||||
#### 5.2.3 歌曲列表接口只返回可播歌曲
|
||||
|
||||
以下接口只返回可播歌曲:
|
||||
|
||||
- `/mf/v1/playlists/{playlist_id}/tracks`
|
||||
- `/mf/v1/toplists/{toplist_id}/tracks`
|
||||
|
||||
过滤规则:
|
||||
|
||||
- 歌曲必须在对应歌单/榜单中
|
||||
- 同时必须满足存在至少一条 `catalog_track_files.status = 'active'`
|
||||
|
||||
排序规则保持原有:
|
||||
|
||||
- 歌单按 `position asc`
|
||||
- 榜单按 `position asc, song_id asc`
|
||||
|
||||
搜索接口维持现有可播过滤逻辑,不需要改变语义,只需要和歌单/榜单详情对齐为同一套“可播”定义。
|
||||
|
||||
### 5.3 `Music_Server` API 返回结构
|
||||
|
||||
歌单/榜单对象在现有字段基础上新增:
|
||||
|
||||
- `playableSongCount`
|
||||
|
||||
返回口径:
|
||||
|
||||
- `worksNum` 或对应总数字段继续表示总数
|
||||
- `playableSongCount` 表示可播数
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "catalogsync:playlist:18165",
|
||||
"platform": "catalogsync",
|
||||
"title": "示例歌单",
|
||||
"coverImg": "https://example/cover.jpg",
|
||||
"description": "creator",
|
||||
"worksNum": 10,
|
||||
"playableSongCount": 3,
|
||||
"playCount": 99999
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 `MusicFree` 插件映射
|
||||
|
||||
插件继续只做字段搬运和协议适配,不在插件中自己计算可播数。
|
||||
|
||||
插件新增映射规则:
|
||||
|
||||
- 服务端 `playableSongCount` -> 前端对象 `playableWorksNum`
|
||||
- 服务端 `worksNum` -> 前端对象 `worksNum`
|
||||
|
||||
要求同时更新:
|
||||
|
||||
- 源插件文件
|
||||
- 当前发布使用的 `release` 版本插件文件
|
||||
|
||||
这样可以保证调试版和发布版字段口径一致。
|
||||
|
||||
### 5.5 `MusicFree` 客户端展示
|
||||
|
||||
#### 5.5.1 展示位置
|
||||
|
||||
需要改两个展示点:
|
||||
|
||||
- 首页/推荐页/收藏页等远程歌单入口卡片
|
||||
- 歌单详情页头部
|
||||
|
||||
#### 5.5.2 展示规则
|
||||
|
||||
远程歌单对象存在 `playableWorksNum` 且存在 `worksNum` 时:
|
||||
|
||||
- 显示 `playableWorksNum/worksNum`
|
||||
- 例如 `3/10`
|
||||
|
||||
当 `playableWorksNum` 缺失时:
|
||||
|
||||
- 退回当前逻辑
|
||||
- 仅显示 `worksNum`
|
||||
|
||||
本地歌单继续保持现有逻辑,不引入 `playable/total` 的双计数展示。
|
||||
|
||||
#### 5.5.3 详情页歌曲列表
|
||||
|
||||
由于服务端已经只返回可播歌曲,所以客户端不再做“未下载灰显”或“禁止点击”等处理。本轮详情页的歌曲列表应只包含当前可播放条目。
|
||||
|
||||
## 6. 数据流
|
||||
|
||||
目标链路如下:
|
||||
|
||||
1. `catalog-sync` 下载歌曲并写入 `catalogsync.db`
|
||||
2. `download` stage 结束后自动执行 `catalog-export`
|
||||
3. 导出脚本重建并替换 `catalog_read.db`
|
||||
4. `Music_Server` 从新的只读库读取歌单总数、可播数和可播歌曲列表
|
||||
5. `MusicFree` 插件读取新的字段并透传给客户端
|
||||
6. `MusicFree` 客户端显示 `可播数/总数`,且详情页只看到可播歌曲
|
||||
|
||||
## 7. 错误处理与观测
|
||||
|
||||
### 7.1 `catalog-sync`
|
||||
|
||||
- 导出开始、结束、失败都要写 job event
|
||||
- 导出命令 stdout/stderr 至少要进入后台日志
|
||||
- 导出失败不改变已下载文件状态,不影响后续上传阶段
|
||||
|
||||
### 7.2 `Music_Server`
|
||||
|
||||
- 如果某歌单 `playable_song_count = 0`,歌单对象仍然返回,计数显示为 `0/总数`
|
||||
- 歌单详情接口返回空列表属于正常情况,不应报错
|
||||
- 搜索结果仍然只返回可播歌曲
|
||||
|
||||
### 7.3 `MusicFree`
|
||||
|
||||
- 若插件拿不到 `playableSongCount`,界面退回单数字展示,不阻塞歌单浏览
|
||||
- 若详情返回空歌曲列表,按空列表处理,不做额外错误提示
|
||||
|
||||
## 8. 测试策略
|
||||
|
||||
### 8.1 `catalog-sync`
|
||||
|
||||
新增测试覆盖:
|
||||
|
||||
- `download` stage 终态后会触发导出命令一次
|
||||
- 未配置 `CATALOG_EXPORT_COMMAND` 时会跳过并记录事件
|
||||
- 导出命令失败时只记录事件,不把 job 改判为失败
|
||||
- 导出命令不会在暂停或取消场景下触发
|
||||
|
||||
### 8.2 `Music_Server`
|
||||
|
||||
新增测试覆盖:
|
||||
|
||||
- `catalog_playlists` / `catalog_toplists` 能读出 `playable_song_count`
|
||||
- 歌单列表、歌单详情、榜单列表、榜单详情接口返回 `playableSongCount`
|
||||
- 歌单详情和榜单详情只返回有 `active` 文件位置的歌曲
|
||||
- 搜索语义保持不变
|
||||
|
||||
### 8.3 `MusicFree`
|
||||
|
||||
新增测试覆盖:
|
||||
|
||||
- 插件把 `playableSongCount` 正确映射到 `playableWorksNum`
|
||||
- 远程歌单卡片显示 `3/10`
|
||||
- 歌单详情头部显示 `3/10`
|
||||
- 缺失 `playableWorksNum` 时退回原有单数字展示
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
以下条件同时满足,即视为本轮完成:
|
||||
|
||||
1. 新下载的一首歌曲在下载任务结束后,无需人工执行导出,即可在 `Music_Server` 歌单详情中出现。
|
||||
2. `Music_Server` 歌单详情和榜单详情只返回当前可播歌曲。
|
||||
3. `MusicFree` 远程歌单入口和歌单详情头部显示 `可播数/总数`,例如 `3/10`。
|
||||
4. 未下载歌曲不再出现在歌单详情列表中。
|
||||
5. 导出失败时能在 `catalog-sync` 后台任务事件里明确看到失败记录,但不影响已完成下载结果保留。
|
||||
|
||||
## 10. 实施顺序
|
||||
|
||||
建议按以下顺序实现:
|
||||
|
||||
1. 修改 `Music_Server` 读模型导出脚本和查询层,先把“可播数”和“只返回可播歌曲”的语义立住。
|
||||
2. 修改 `catalog-sync` 下载 stage 完成后的自动导出钩子,打通“下载完成即刷新只读库”链路。
|
||||
3. 修改 `MusicFree` 插件和客户端展示,接出 `3/10`。
|
||||
4. 做 NAS 联调,验证“下载前不可见、下载后自动出现”的完整闭环。
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# Music_Server 多类型搜索与歌手详情设计
|
||||
|
||||
日期:2026-04-23
|
||||
状态:已确认设计,待实现计划
|
||||
范围:`catalog-sync`、`Music_Server`、`MusicFree`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Music_Server` 面向 MusicFree 插件只支持单曲搜索,链路上存在三个直接影响使用的问题:
|
||||
|
||||
1. 搜索页只能返回 `单曲`,无法返回 `歌手` 和 `歌单`。
|
||||
2. 即使补出歌手搜索结果,当前 MusicFree 的歌手详情页默认固定为 `单曲 / 专辑` 两个 tab,而本次目标是不再展示专辑,只展示该歌手的全部歌曲。
|
||||
3. 当前已有榜单详情能力,但如果把榜单混入“歌单搜索”结果,插件详情入口还不能自动识别并转发到榜单详情接口。
|
||||
|
||||
当前三端职责边界保持不变:
|
||||
|
||||
- `catalog-sync` 负责采集、同步、下载和维护源库 `catalogsync.db`。
|
||||
- `Music_Server` 负责把源库导出成只读快照,并对 MusicFree 插件暴露 `/mf/v1/*` 接口。
|
||||
- `MusicFree` 负责插件调用、结果展示和详情页交互,不直接访问源站接口或服务端数据库。
|
||||
|
||||
本轮需要解决的是:
|
||||
|
||||
1. `Music_Server` 支持 `单曲 / 歌手 / 歌单` 三类搜索。
|
||||
2. `歌手` 搜索结果按平台分别显示,不做跨平台合并。
|
||||
3. 点进歌手后只展示该歌手的全部可播歌曲,不再展示专辑。
|
||||
4. `歌单` 搜索同时返回普通歌单和现有榜单,并且点进去后能正常打开对应详情。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- `Music_Server` 暴露 MusicFree 兼容的三类搜索接口。
|
||||
- `Music_Server` 导出稳定的歌手只读模型,避免运行时从 `songs.singers` 临时反推。
|
||||
- `Music_Server` 只返回至少包含 1 首可播歌曲的歌手和歌单结果。
|
||||
- `Music_Server` 歌手详情只返回该歌手的可播歌曲列表,并支持分页。
|
||||
- `Music_Server` 歌单搜索同时覆盖普通歌单和榜单。
|
||||
- `Music_Server` 插件同时支持 `music`、`artist`、`sheet` 三类搜索。
|
||||
- `MusicFree` 对 `Music_Server` 歌手详情仅展示单曲列表,不展示专辑 tab。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不做跨平台歌手身份合并。
|
||||
- 不做歌手专辑详情能力。
|
||||
- 不改动其它插件的歌手详情行为。
|
||||
- 不新增新的播放解析策略。
|
||||
- 不改动 MusicFree 全局搜索架构,只做与 `Music_Server` 插件兼容所需的最小改动。
|
||||
|
||||
## 3. 方案选择
|
||||
|
||||
评估过三个方向:
|
||||
|
||||
1. 运行时从 `catalog_tracks.singers` 现算歌手搜索和详情。
|
||||
2. 在导出阶段补歌手读模型,再由服务端和插件接入。
|
||||
3. 服务端只提供少量接口,更多聚合逻辑放到插件端处理。
|
||||
|
||||
本轮选择方案 2,原因如下:
|
||||
|
||||
- `catalog-sync` 源库已经有 `artists` 和 `artist_songs` 表,可以稳定表达“平台内歌手”和“歌手作品关系”。
|
||||
- 歌手详情需要分页、排序和稳定 id,用导出读模型比字符串现算更可靠。
|
||||
- 榜单混入歌单搜索后,插件只需识别 id 类型并分发详情请求,不需要自己做复杂聚合。
|
||||
|
||||
## 4. 核心设计
|
||||
|
||||
### 4.1 读模型扩展
|
||||
|
||||
在 `catalog_read.db` 中新增两张表:
|
||||
|
||||
#### `catalog_artists`
|
||||
|
||||
- `artist_id integer primary key`
|
||||
- `artist_key text not null unique`
|
||||
- `platform text not null`
|
||||
- `remote_artist_id text`
|
||||
- `name text not null`
|
||||
- `normalized_name text not null`
|
||||
- `avatar_url text`
|
||||
- `description text`
|
||||
- `playable_song_count integer not null`
|
||||
|
||||
用途:
|
||||
|
||||
- 作为歌手搜索结果和歌手详情头部的主表。
|
||||
- `artist_id` 直接沿用源库 `artists.id`,避免重新映射。
|
||||
- `artist_key` 沿用源库 `platform + remote_artist_id/normalized_name` 的稳定语义。
|
||||
|
||||
#### `catalog_artist_tracks`
|
||||
|
||||
- `artist_id integer not null`
|
||||
- `song_id integer not null`
|
||||
- `position integer not null`
|
||||
|
||||
用途:
|
||||
|
||||
- 存储歌手与歌曲的展开关系。
|
||||
- 用于歌手详情分页和稳定排序。
|
||||
|
||||
排序约定:
|
||||
|
||||
- `position` 由导出时确定,默认按歌曲名升序、`song_id` 升序生成稳定序号。
|
||||
|
||||
### 4.2 导出策略
|
||||
|
||||
`Music_Server/scripts/export_catalog_read.py` 在现有导出流程基础上新增歌手导出:
|
||||
|
||||
1. 从源库 `artists` 读取歌手主体。
|
||||
2. 通过 `artist_songs` 关联到 `songs`。
|
||||
3. 通过 `file_assets + file_locations(status='active')` 过滤出当前可播歌曲。
|
||||
4. 仅保留 `playable_song_count > 0` 的歌手进入 `catalog_artists`。
|
||||
5. 将这些可播歌曲关系写入 `catalog_artist_tracks`。
|
||||
|
||||
补充规则:
|
||||
|
||||
- 优先使用源库 `artists` 关系,不从 `songs.singers` 反推歌手。
|
||||
- 如果歌手缺少头像或简介,允许写空。
|
||||
- 如果歌手没有可播歌曲,不进入只读库,这样搜索和详情天然只面向可用结果。
|
||||
|
||||
### 4.3 服务端接口
|
||||
|
||||
在现有 `/mf/v1` 之下新增四个接口:
|
||||
|
||||
#### `GET /mf/v1/search/artists`
|
||||
|
||||
参数:
|
||||
|
||||
- `q`
|
||||
- `page`
|
||||
- `page_size`
|
||||
|
||||
返回 MusicFree 兼容形状:
|
||||
|
||||
- `isEnd`
|
||||
- `data`
|
||||
|
||||
单个结果字段:
|
||||
|
||||
- `id`: `catalogsync:artist:{artist_id}`
|
||||
- `platform`: 平台名,例如 `netease` / `qq` / `kuwo`
|
||||
- `name`
|
||||
- `avatar`
|
||||
- `worksNum`: 可播歌曲数
|
||||
- `description`
|
||||
- `supportedArtistTabs`: `["music"]`
|
||||
|
||||
#### `GET /mf/v1/artists/{artist_id}`
|
||||
|
||||
返回歌手详情头部字段,至少包含:
|
||||
|
||||
- `id`
|
||||
- `platform`
|
||||
- `name`
|
||||
- `avatar`
|
||||
- `worksNum`
|
||||
- `description`
|
||||
- `supportedArtistTabs`
|
||||
|
||||
#### `GET /mf/v1/artists/{artist_id}/tracks`
|
||||
|
||||
参数:
|
||||
|
||||
- `page`
|
||||
- `page_size`
|
||||
|
||||
返回:
|
||||
|
||||
- `isEnd`
|
||||
- `musicList`
|
||||
|
||||
歌曲仍复用当前单曲对象结构和播放链路。
|
||||
|
||||
#### `GET /mf/v1/search/sheets`
|
||||
|
||||
参数:
|
||||
|
||||
- `q`
|
||||
- `page`
|
||||
- `page_size`
|
||||
|
||||
语义:
|
||||
|
||||
- 同时搜索 `catalog_playlists` 和 `catalog_toplists`
|
||||
- 将两类结果统一映射成 MusicFree 的 sheet 形状
|
||||
|
||||
单个结果字段:
|
||||
|
||||
- `id`
|
||||
- 普通歌单:`catalogsync:playlist:{playlist_id}`
|
||||
- 榜单:`catalogsync:toplist:{toplist_id}`
|
||||
- `platform`
|
||||
- `title`
|
||||
- `coverImg`
|
||||
- `description`
|
||||
- `worksNum`
|
||||
- `playableSongCount`
|
||||
- `playCount`
|
||||
|
||||
### 4.4 查询语义与排序
|
||||
|
||||
#### 单曲搜索
|
||||
|
||||
保持现有语义:
|
||||
|
||||
- 只返回有 `active` 文件位置的歌曲。
|
||||
- 排序为:
|
||||
1. 歌名精确匹配
|
||||
2. 歌名前缀匹配
|
||||
3. 歌名模糊匹配
|
||||
4. 歌手名模糊匹配
|
||||
5. `lower(name)` 升序
|
||||
6. `song_id` 升序
|
||||
|
||||
#### 歌手搜索
|
||||
|
||||
只搜索 `catalog_artists`,并保持平台内独立:
|
||||
|
||||
- 不做跨平台合并。
|
||||
- 只返回 `playable_song_count > 0` 的歌手。
|
||||
- 排序为:
|
||||
1. 歌手名精确匹配
|
||||
2. 歌手名前缀匹配
|
||||
3. 歌手名模糊匹配
|
||||
4. `playable_song_count desc`
|
||||
5. `lower(name) asc`
|
||||
6. `artist_id asc`
|
||||
|
||||
#### 歌单搜索
|
||||
|
||||
同时搜索 `catalog_playlists.name` 和 `catalog_toplists.name`:
|
||||
|
||||
- 只返回 `playable_song_count > 0` 的结果。
|
||||
- 结果合并后按以下规则排序:
|
||||
1. 标题精确匹配
|
||||
2. 标题前缀匹配
|
||||
3. 标题模糊匹配
|
||||
4. `play_count desc`
|
||||
5. 类型稳定顺序:普通歌单优先于榜单
|
||||
6. 稳定 id 升序
|
||||
|
||||
### 4.5 插件适配
|
||||
|
||||
`Music_Server` 的两个插件资产都要同步修改:
|
||||
|
||||
- `src/music_server/plugin_assets/music_server.js`
|
||||
- `src/music_server/plugin_assets/music_server_lan.js`
|
||||
|
||||
#### 搜索能力
|
||||
|
||||
- `supportedSearchType` 改为 `["music", "artist", "sheet"]`
|
||||
- `search(query, page, type)` 改为按类型分发:
|
||||
- `music` -> `/mf/v1/search/songs`
|
||||
- `artist` -> `/mf/v1/search/artists`
|
||||
- `sheet` -> `/mf/v1/search/sheets`
|
||||
|
||||
#### 歌手映射
|
||||
|
||||
新增 `mapArtistItem(...)`,负责把服务端歌手结果映射到 MusicFree 形状:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `avatar`
|
||||
- `worksNum`
|
||||
- `platform`
|
||||
- `description`
|
||||
- `supportedArtistTabs`
|
||||
|
||||
#### 歌手详情
|
||||
|
||||
新增 `getArtistWorks(artistItem, page, type)`:
|
||||
|
||||
- 当 `type === "music"` 时,请求 `/mf/v1/artists/{artist_id}/tracks`
|
||||
- 其余类型返回空列表
|
||||
|
||||
#### 歌单详情入口识别榜单
|
||||
|
||||
增强 `getMusicSheetInfo(sheetItem, page)`:
|
||||
|
||||
- 若 id 前缀是 `catalogsync:playlist:`,继续走 `/mf/v1/playlists/*`
|
||||
- 若 id 前缀是 `catalogsync:toplist:`,自动走 `/mf/v1/toplists/*`
|
||||
|
||||
这样榜单即使从“歌单搜索”结果页点入,也能正常打开详情。
|
||||
|
||||
### 4.6 MusicFree 最小兼容改动
|
||||
|
||||
当前 MusicFree 的歌手详情页固定渲染 `music` 和 `album` 两个 tab。
|
||||
本轮只对歌手详情页做最小改动:
|
||||
|
||||
1. 如果 `artistItem.supportedArtistTabs` 存在,则使用该数组作为当前歌手详情页 tab 集合。
|
||||
2. 否则继续回退到默认 `["music", "album"]`。
|
||||
3. 当 tab 只有一个时,不渲染 tab 栏,直接展示对应列表。
|
||||
|
||||
预期效果:
|
||||
|
||||
- `Music_Server` 歌手详情页直接显示“全部歌曲”。
|
||||
- 其它插件继续维持原本的“单曲 / 专辑”体验,不受影响。
|
||||
|
||||
## 5. 数据流
|
||||
|
||||
目标数据流如下:
|
||||
|
||||
1. `catalog-sync` 持续维护源库中的 `artists`、`artist_songs`、`songs`、`playlist_songs`、文件位置等数据。
|
||||
2. `Music_Server` 执行导出脚本,把源库转换成包含歌手读模型的 `catalog_read.db`。
|
||||
3. `Music_Server` 通过 `CatalogReader` 对三类搜索与详情提供只读查询。
|
||||
4. `Music_Server` 插件按 `music / artist / sheet` 三类请求分发到对应接口。
|
||||
5. MusicFree 搜索页展示三类结果。
|
||||
6. 用户点击歌手结果后,插件只拉取该歌手的歌曲列表;点击歌单结果时,普通歌单和榜单都能按各自详情接口打开。
|
||||
|
||||
## 6. 错误处理与兼容性
|
||||
|
||||
### 6.1 服务端
|
||||
|
||||
- 空查询直接返回空列表。
|
||||
- 不存在的歌手、歌单、榜单返回 `404`。
|
||||
- 非法 id 返回 `400` 或 `404`,与现有公开 id 处理风格保持一致。
|
||||
- 歌手详情和歌曲列表都只面向只读库中已导出的歌手 id,不做运行时兜底推断。
|
||||
|
||||
### 6.2 插件
|
||||
|
||||
- `artist` 类型的非 `music` 详情请求直接返回空结果,不抛异常。
|
||||
- 歌单详情里若识别到榜单 id,则自动转发到榜单详情,不暴露给上层页面额外判断。
|
||||
- 若服务端返回空列表,插件返回 `isEnd: true` 的空结果,保证 MusicFree 页面可正常结束加载。
|
||||
|
||||
### 6.3 客户端
|
||||
|
||||
- 对不带 `supportedArtistTabs` 的旧插件保持原行为。
|
||||
- 对 `Music_Server` 插件,只有一个 tab 时隐藏 tab 栏,不影响列表分页和批量操作。
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
### 7.1 `Music_Server`
|
||||
|
||||
需要覆盖以下测试层次:
|
||||
|
||||
1. 导出脚本测试
|
||||
- 验证 `artists` / `artist_songs` 被正确导出到 `catalog_artists` / `catalog_artist_tracks`
|
||||
- 验证没有可播歌曲的歌手不会被导出
|
||||
- 验证歌单搜索联合结果可覆盖普通歌单和榜单
|
||||
|
||||
2. `CatalogReader` 测试
|
||||
- `search_artists(...)`
|
||||
- `get_artist(...)`
|
||||
- `list_artist_tracks(...)`
|
||||
- `search_sheets(...)`
|
||||
- 排序、分页、空查询和边界条件
|
||||
|
||||
3. 路由测试
|
||||
- `/mf/v1/search/artists`
|
||||
- `/mf/v1/artists/{artist_id}`
|
||||
- `/mf/v1/artists/{artist_id}/tracks`
|
||||
- `/mf/v1/search/sheets`
|
||||
- token 鉴权兼容现有行为
|
||||
|
||||
### 7.2 插件与客户端
|
||||
|
||||
至少覆盖以下验证:
|
||||
|
||||
1. 插件搜索分发
|
||||
- `music`
|
||||
- `artist`
|
||||
- `sheet`
|
||||
|
||||
2. 插件详情分发
|
||||
- 歌手详情只拉歌曲
|
||||
- 搜索结果中的榜单通过 `getMusicSheetInfo(...)` 正确落到榜单详情接口
|
||||
|
||||
3. MusicFree 页面行为
|
||||
- `Music_Server` 歌手详情只显示歌曲列表
|
||||
- 其它插件歌手详情仍保留 `单曲 / 专辑`
|
||||
|
||||
## 8. 实施范围
|
||||
|
||||
预计涉及以下文件:
|
||||
|
||||
### `Music_Server`
|
||||
|
||||
- `scripts/export_catalog_read.py`
|
||||
- `src/music_server/services/catalog_reader.py`
|
||||
- `src/music_server/routes/mf_catalog.py`
|
||||
- `src/music_server/plugin_assets/music_server.js`
|
||||
- `src/music_server/plugin_assets/music_server_lan.js`
|
||||
- `tests/test_export_catalog_read.py`
|
||||
- `tests/test_catalog_reader.py`
|
||||
- `tests/test_mf_catalog_routes.py`
|
||||
- `tests/test_plugin_routes.py`(如需校验插件导出字段)
|
||||
|
||||
### `MusicFree`
|
||||
|
||||
- `src/pages/artistDetail/components/body.tsx`
|
||||
- 可能附带 `src/pages/artistDetail/store/atoms.ts`
|
||||
- 可能附带 `src/pages/artistDetail/components/resultList.tsx`
|
||||
|
||||
原则:
|
||||
|
||||
- 只为支持单 tab 歌手详情做最小必要改动。
|
||||
- 不改动全局搜索框架和其它插件约定。
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
满足以下条件即视为完成:
|
||||
|
||||
1. MusicFree 中 `Music_Server` 插件搜索页可切换并返回 `单曲 / 歌手 / 歌单` 三类结果。
|
||||
2. `歌手` 结果按平台分别显示。
|
||||
3. 点进歌手后直接看到该歌手的全部可播歌曲,并可正常分页与播放。
|
||||
4. 歌手详情页不再展示专辑 tab。
|
||||
5. `歌单` 搜索可同时返回普通歌单和榜单。
|
||||
6. 从搜索结果点进榜单时,详情页和歌曲播放都正常。
|
||||
7. 其它插件的歌手详情行为不发生变化。
|
||||
@@ -0,0 +1,52 @@
|
||||
# Music_Server Inline Lyrics Design
|
||||
|
||||
日期:2026-05-01
|
||||
状态:已确认,可实现
|
||||
|
||||
## 背景
|
||||
|
||||
`catalog-sync` 现在会在下载歌曲时保存同名 `.lrc`,并且 NAS 上也已经启动历史歌曲补词任务。`Music_Server` 当前会把歌曲元数据分发给 MusicFree,但歌曲对象里没有歌词字段,导致客户端无法直接复用这些已落盘歌词。
|
||||
|
||||
## 目标
|
||||
|
||||
- `Music_Server` 在现有歌曲分发结果里内联歌词内容
|
||||
- 返回字段使用 MusicFree 已支持的 `rawLrc`
|
||||
- 直接复用 NAS 本地音乐库里与音频同目录、同名的 `.lrc`
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不新增独立歌词接口
|
||||
- 不新增歌词搜索、翻译歌词、远程歌词回源
|
||||
- 不修改 `catalog-sync` 的下载逻辑
|
||||
|
||||
## 设计
|
||||
|
||||
### 数据来源
|
||||
|
||||
- 歌曲文件定位仍以 `catalog_read.db` 的 `catalog_track_files` 为准
|
||||
- 仅当歌曲存在 `backend_type = 'local_fs'` 的可播放文件定位时,尝试读取歌词
|
||||
- 歌词路径规则:`<audio_path 去掉扩展名>.lrc`
|
||||
|
||||
### 返回形态
|
||||
|
||||
- 在现有 `musicItem` 上新增可选字段 `rawLrc`
|
||||
- 有歌词时返回字符串
|
||||
- 没有歌词时不返回或为 `null` 均可;实现上优先省略空值
|
||||
|
||||
### 读取约束
|
||||
|
||||
- 仅允许在 `LOCAL_LIBRARY_ROOT` 根目录下解析歌词文件
|
||||
- 使用与本地音频流相同的安全路径约束,防止越界访问
|
||||
- 读取失败、文件不存在、编码异常时按“无歌词”处理,不影响歌曲列表接口
|
||||
|
||||
### 影响面
|
||||
|
||||
- 后端歌曲映射:搜索、歌单歌曲、榜单歌曲、歌手作品等所有 `_to_music_item()` 输出
|
||||
- 插件资产:`music_server.js`、`music_server_lan.js` 需要透传 `rawLrc`
|
||||
|
||||
## 测试
|
||||
|
||||
- 路由测试:存在同名 `.lrc` 时,`/mf/v1/search/songs` 与 `tracks` 类接口返回 `rawLrc`
|
||||
- 路由测试:未配置 `LOCAL_LIBRARY_ROOT` 或无 `.lrc` 时,不报错
|
||||
- 插件资产测试:两个插件文件都包含 `rawLrc` 映射逻辑
|
||||
|
||||
Reference in New Issue
Block a user