Initial import: Music_Server, MusicFree, catalog-sync

This commit is contained in:
2026-05-23 16:51:14 +08:00
commit 069af30dba
847 changed files with 179878 additions and 0 deletions
@@ -0,0 +1,131 @@
# Music_Server Token Binding Pause Handoff
日期:2026-04-20
## 项目快照
- 仓库:`D:\source\musicdl-catalog-sync-worktrees\Music_Server`
- 分支:`feature/musicfree-music-server-plugin`
- remote:未配置
- 当前主跟踪文档:
- `docs/superpowers/specs/2026-04-20-music-server-token-binding-design.md`
- `docs/superpowers/plans/2026-04-20-music-server-token-binding-implementation.md`
## 已验证进度
### Task 1: Build Music_Server TokenService
已完成,且经过两轮 review 后通过。
已落地文件:
- `src/music_server/services/token_service.py`
- `tests/test_token_service.py`
本会话最终验证:
- `python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_service.py -q`
- 结果:`7 passed`
已确认处理的问题:
- 首绑竞态不会让两个客户端都 `valid=True`
- 过期判断不再做字符串比较
- `status()` 使用最终状态构造响应,避免前后不一致
- 已补“读取后、绑定前被撤销”回归测试
### Task 2: Wire FastAPI auth dependency and token status route
实现已完成到“功能可跑 + spec review 通过”的阶段,但尚未完成最终 code quality 修复闭环。
已落地文件:
- `src/music_server/services/catalog_reader.py`
- `src/music_server/routes/auth.py`
- `src/music_server/auth.py`
- `src/music_server/app.py`
- `tests/support.py`
- `tests/test_auth_routes.py`
- `tests/test_catalog_reader.py`
- `tests/test_health.py`
- `tests/test_mf_catalog_routes.py`
- `tests/test_mf_media_routes.py`
- `tests/test_mf_detail_routes.py`
- `tests/test_player_routes.py`
- `tests/test_player_history_routes.py`
已验证命令:
- `python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_auth_routes.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_health.py -q`
- 结果:`16 passed`
- `python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_auth_routes.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_health.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_catalog_routes.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_media_routes.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_detail_routes.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_player_routes.py D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_player_history_routes.py -q`
- 结果:`39 passed, 1 warning`
spec review 结论:
- `PASS`
code quality review 结论:
- `CHANGES_REQUIRED`
未收尾的问题:
1. `src/music_server/routes/auth.py`
- `/auth/v1/token-status` 在 active token 场景下,如果 `catalog_read.db` 缺少 `catalog_track_files` 表,当前会因为 `count_playable_tracks()` 抛 sqlite 异常而返回 500。
- 期望修复:降级为 `playableSongCount = null`,不要把 token 状态查询耦合成目录库健康检查。
2. `src/music_server/services/catalog_reader.py`
- `list_playlists(platform=...)` 当前对未知平台会静默回退为“全量列表”。
- 期望修复:只要传了 platform,就按该 platform 过滤;未知平台应返回空集而不是全量。
3. `src/music_server/services/catalog_reader.py`
- `list_playlist_tracks()` 只按 `position` 排序,没有稳定二级 key。
- 期望修复:补二级排序,避免 position 重复时跨页抖动。
4. `tests/support.py` 及相关路由测试
- `auth_headers()` 默认每次都新发 token,会把一次用例里的多次请求弱化成“多 token”场景。
- 期望修复:让重复请求更容易复用同一个 token。
5. `tests/test_mf_catalog_routes.py`
- 有一个 `total == page_size` 时断言 `isEnd == false` 的测试,容易固化当前可争议分页语义。
- 期望修复:放宽或改写这个断言,不把潜在缺陷锁死。
## 中断说明
- 针对上面 5 个问题,已派出 implementer 继续修复。
- 该 implementer 在用户切换任务前被中途打断,状态为 `interrupted`
- 因此“Task 2 修复版”目前没有经过本会话验证,不应视为完成。
## 当前工作树提醒
- 工作树为 dirty 状态,包含本会话 token binding 相关改动,也包含其它既有改动。
- 暂停这条线时不要回滚无关文件。
## 恢复此任务时建议顺序
1. 先打开:
- `docs/superpowers/plans/2026-04-20-music-server-token-binding-implementation.md`
- `docs/superpowers/handoffs/2026-04-20-token-binding-pause.md`
2. 重新检查 Task 2 当前文件状态,确认中断 implementer 没有留下未验证半成品。
3. 仅修复本 handoff 列出的 5 个 code-quality 问题。
4. 重新跑 Task 2 的 39 个相关测试。
5. 再做一次 code quality re-review。
6. Task 2 过关后,再继续 Task 3。
## 下次续做可直接用的提示词
```text
继续处理 D:\source\musicdl-catalog-sync-worktrees\Music_Server 的 token binding implementation。
先打开:
1. docs/superpowers/plans/2026-04-20-music-server-token-binding-implementation.md
2. docs/superpowers/handoffs/2026-04-20-token-binding-pause.md
当前状态:
- Task 1 已完成并验证通过
- Task 2 功能与 spec review 已通过
- Task 2 code quality review 还剩 5 个问题待修
请先核对当前文件状态,再只修复 handoff 中列出的 5 个 Task 2 问题,跑完 39 个相关测试,并完成 code quality re-review。
```
@@ -0,0 +1,774 @@
# Music_Server Search And Range Streaming Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `Music_Server` song-name search plus real single-range local streaming with correct MIME headers so MusicFree and future clients can search playable songs and seek within local files reliably.
**Architecture:** Extend `CatalogReader` with an active-file-aware `search_tracks(...)` query that prioritizes song-name matches over singer weak matches, then expose it via `/mf/v1/search/songs`. Keep byte-range parsing and MIME inference in a focused `local_streaming` service so `mf_media.py` stays thin and only decides between local streaming and object-storage redirect.
**Tech Stack:** Python 3.11, FastAPI, `sqlite3`, `unittest`
---
Repository root: `D:\source\musicdl-catalog-sync-worktrees\Music_Server`
## Scope Split
This plan intentionally covers only the first executable slice of the previously deferred feature line:
1. `Music_Server` song search
2. `Music_Server` local Range / MIME correctness
The remaining two slices stay as follow-up work after this contract is stable:
1. MusicFree pure `Music_Server` plugin conversion
2. `catalog-sync` artist enhancement and tests
## File Structure
- Create: `src/music_server/services/local_streaming.py`
- Create: `tests/test_local_streaming.py`
- Modify: `src/music_server/services/catalog_reader.py`
- Modify: `src/music_server/routes/mf_catalog.py`
- Modify: `src/music_server/routes/mf_media.py`
- Modify: `tests/test_catalog_reader.py`
- Modify: `tests/test_mf_catalog_routes.py`
- Modify: `tests/test_mf_media_routes.py`
### Task 1: Add active-file-aware song search to `CatalogReader`
**Files:**
- Modify: `src/music_server/services/catalog_reader.py`
- Modify: `tests/test_catalog_reader.py`
- [ ] **Step 1: Write the failing test**
Append this test to `tests/test_catalog_reader.py` inside `CatalogReaderTests` and extend `setUp()` so the same temp DB also creates `catalog_tracks` and `catalog_track_files`:
```python
def test_search_tracks_prefers_name_match_and_requires_active_file(self):
conn = sqlite3.connect(self._db_path)
conn.executescript(
"""
create table catalog_tracks (
song_id integer primary key,
platform text not null,
remote_song_id text not null,
name text not null,
singers text,
album text,
cover_url text,
duration_ms integer,
metadata_json text
);
create table catalog_track_files (
song_id integer not null,
quality_label text not null,
ext text not null,
file_size_bytes integer,
backend_type text not null,
backend_name text not null,
locator text not null,
public_url text,
status text not null,
is_primary integer not null
);
"""
)
conn.executemany(
"""
insert into catalog_tracks (
song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(1, "netease", "n1", "Moonlight", "Artist A", "A", "https://img/1.jpg", 210000, "{}"),
(2, "netease", "n2", "Moonlight Demo", "Artist B", "B", "https://img/2.jpg", 180000, "{}"),
(3, "qq", "q3", "Sunrise", "Moonlight Singer", "C", "https://img/3.jpg", 200000, "{}"),
],
)
conn.executemany(
"""
insert into catalog_track_files (
song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(1, "super", "flac", 100, "object_storage", "cdn", "song-1.flac", "https://cdn/1.flac", "active", 1),
(2, "super", "flac", 101, "object_storage", "cdn", "song-2.flac", "https://cdn/2.flac", "inactive", 1),
(3, "standard", "mp3", 99, "object_storage", "cdn", "song-3.mp3", "https://cdn/3.mp3", "active", 1),
],
)
conn.commit()
conn.close()
reader = CatalogReader(db_path=str(self._db_path))
rows = reader.search_tracks(query="Moonlight", page=1, page_size=10)
self.assertEqual([1, 3], [row["song_id"] for row in rows])
self.assertEqual("Moonlight", rows[0]["name"])
self.assertEqual("Moonlight Singer", rows[1]["singers"])
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```powershell
python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_tracks_prefers_name_match_and_requires_active_file -v
```
Expected:
- `ERROR`
- message includes `AttributeError: 'CatalogReader' object has no attribute 'search_tracks'`
- [ ] **Step 3: Write minimal implementation**
In `src/music_server/services/catalog_reader.py`, add a search row type and `search_tracks(...)`:
```python
class SearchTrackRow(TypedDict):
song_id: int
name: str
singers: str | None
album: str | None
cover_url: str | None
duration_ms: int
def search_tracks(self, query: str, page: int, page_size: int) -> list[SearchTrackRow]:
page, page_size = self._normalize_pagination(page, page_size)
term = str(query or "").strip()
if not term:
return []
offset = (page - 1) * page_size
exact_query = term.lower()
prefix_query = f"{exact_query}%"
like_query = f"%{exact_query}%"
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(
"""
select
t.song_id,
t.name,
t.singers,
t.album,
t.cover_url,
t.duration_ms
from catalog_tracks t
where exists (
select 1
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
)
and (
lower(t.name) like ?
or lower(coalesce(t.singers, '')) like ?
)
order by
case
when lower(t.name) = ? then 0
when lower(t.name) like ? then 1
when lower(t.name) like ? then 2
when lower(coalesce(t.singers, '')) like ? then 3
else 9
end,
lower(t.name) asc,
t.song_id asc
limit ? offset ?
""",
(
like_query,
like_query,
exact_query,
prefix_query,
like_query,
like_query,
page_size,
offset,
),
).fetchall()
return [cast(SearchTrackRow, dict(row)) for row in rows]
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```powershell
python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_tracks_prefers_name_match_and_requires_active_file -v
```
Expected:
- `OK`
- [ ] **Step 5: Commit**
```bash
git add src/music_server/services/catalog_reader.py tests/test_catalog_reader.py
git commit -m "feat: add active file aware catalog song search"
```
### Task 2: Expose `GET /mf/v1/search/songs` for MusicFree-compatible song search
**Files:**
- Modify: `src/music_server/routes/mf_catalog.py`
- Modify: `tests/test_mf_catalog_routes.py`
- [ ] **Step 1: Write the failing test**
Append this test to `tests/test_mf_catalog_routes.py`:
```python
def test_search_songs_returns_musicfree_shape(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table catalog_tracks (
song_id integer primary key,
platform text not null,
remote_song_id text not null,
name text not null,
singers text,
album text,
cover_url text,
duration_ms integer,
metadata_json text
);
create table catalog_track_files (
song_id integer not null,
quality_label text not null,
ext text not null,
file_size_bytes integer,
backend_type text not null,
backend_name text not null,
locator text not null,
public_url text,
status text not null,
is_primary integer not null
);
"""
)
conn.execute(
"""
insert into catalog_tracks (
song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(3476, "netease", "65800", "Moonlight", "Ariana", "Album A", "https://img/3476.jpg", 245000, "{}"),
)
conn.execute(
"""
insert into catalog_track_files (
song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(3476, "super", "flac", 123456, "object_storage", "cdn", "moonlight.flac", "https://cdn/moonlight.flac", "active", 1),
)
conn.commit()
conn.close()
with patch.dict("os.environ", {"CATALOG_DB_PATH": str(db_path)}, clear=False):
client = TestClient(create_app())
response = client.get(
"/mf/v1/search/songs?q=Moonlight&page=1&page_size=20",
headers={"Authorization": "Bearer dev-token"},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertFalse(payload["isEnd"])
self.assertEqual("catalogsync:song:3476", payload["data"][0]["id"])
self.assertEqual("Moonlight", payload["data"][0]["title"])
self.assertEqual("Ariana", payload["data"][0]["artist"])
self.assertEqual("Album A", payload["data"][0]["album"])
self.assertEqual(245, payload["data"][0]["duration"])
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```powershell
python -m unittest tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_songs_returns_musicfree_shape -v
```
Expected:
- `FAIL` or `ERROR`
- route `/mf/v1/search/songs` does not exist yet, so the response is not `200`
- [ ] **Step 3: Write minimal implementation**
In `src/music_server/routes/mf_catalog.py`, add the route:
```python
@router.get("/search/songs")
def search_songs(
q: str = Query(..., min_length=1),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> dict:
query = q.strip()
if not query:
raise HTTPException(status_code=400, detail="q is required")
rows = _reader().search_tracks(query=query, page=page, page_size=page_size)
return {
"isEnd": len(rows) < page_size,
"data": [_to_music_item(row) for row in rows],
}
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```powershell
python -m unittest tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_songs_returns_musicfree_shape -v
```
Expected:
- `OK`
- [ ] **Step 5: Commit**
```bash
git add src/music_server/routes/mf_catalog.py tests/test_mf_catalog_routes.py
git commit -m "feat: expose musicfree song search endpoint"
```
### Task 3: Add reusable single-range parsing and MIME inference helpers
**Files:**
- Create: `src/music_server/services/local_streaming.py`
- Create: `tests/test_local_streaming.py`
- [ ] **Step 1: Write the failing test**
Create `tests/test_local_streaming.py`:
```python
import unittest
from music_server.services.local_streaming import (
RangeNotSatisfiable,
guess_audio_media_type,
parse_single_range,
)
class LocalStreamingTests(unittest.TestCase):
def test_guess_audio_media_type_maps_known_extensions(self):
self.assertEqual("audio/flac", guess_audio_media_type("track.flac"))
self.assertEqual("audio/mpeg", guess_audio_media_type("track.mp3"))
self.assertEqual("audio/mp4", guess_audio_media_type("track.m4a"))
self.assertEqual("audio/wav", guess_audio_media_type("track.wav"))
self.assertEqual("audio/ogg", guess_audio_media_type("track.ogg"))
self.assertEqual("audio/ape", guess_audio_media_type("track.ape"))
def test_parse_single_range_accepts_open_and_suffix_ranges(self):
self.assertEqual((2, 5), parse_single_range("bytes=2-5", file_size=10))
self.assertEqual((6, 9), parse_single_range("bytes=-4", file_size=10))
self.assertEqual((2, 9), parse_single_range("bytes=2-", file_size=10))
self.assertIsNone(parse_single_range(None, file_size=10))
def test_parse_single_range_rejects_invalid_or_multi_ranges(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=99-100", file_size=10)
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=5-2", file_size=10)
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=0-1,4-5", file_size=10)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```powershell
python -m unittest tests.test_local_streaming -v
```
Expected:
- `ERROR`
- `ModuleNotFoundError` because `music_server.services.local_streaming` does not exist yet
- [ ] **Step 3: Write minimal implementation**
Create `src/music_server/services/local_streaming.py`:
```python
from pathlib import Path
class RangeNotSatisfiable(ValueError):
pass
_AUDIO_MIME_BY_SUFFIX = {
".flac": "audio/flac",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".wav": "audio/wav",
".ogg": "audio/ogg",
".ape": "audio/ape",
}
def guess_audio_media_type(path_like: str | Path) -> str:
suffix = Path(str(path_like)).suffix.lower()
return _AUDIO_MIME_BY_SUFFIX.get(suffix, "application/octet-stream")
def parse_single_range(range_header: str | None, file_size: int) -> tuple[int, int] | None:
if range_header is None:
return None
raw = str(range_header).strip()
if not raw:
return None
if not raw.startswith("bytes="):
raise RangeNotSatisfiable("unsupported range unit")
spec = raw[len("bytes="):]
if "," in spec:
raise RangeNotSatisfiable("multiple ranges not supported")
start_text, sep, end_text = spec.partition("-")
if not sep:
raise RangeNotSatisfiable("invalid range")
if start_text == "":
if not end_text.isdigit():
raise RangeNotSatisfiable("invalid suffix range")
suffix_length = int(end_text)
if suffix_length <= 0:
raise RangeNotSatisfiable("invalid suffix range")
start = max(file_size - suffix_length, 0)
end = file_size - 1
return (start, end)
if not start_text.isdigit():
raise RangeNotSatisfiable("invalid range start")
start = int(start_text)
if end_text == "":
end = file_size - 1
else:
if not end_text.isdigit():
raise RangeNotSatisfiable("invalid range end")
end = int(end_text)
if file_size <= 0 or start >= file_size or start > end:
raise RangeNotSatisfiable("range outside file")
return (start, min(end, file_size - 1))
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```powershell
python -m unittest tests.test_local_streaming -v
```
Expected:
- `OK`
- [ ] **Step 5: Commit**
```bash
git add src/music_server/services/local_streaming.py tests/test_local_streaming.py
git commit -m "feat: add local stream range and mime helpers"
```
### Task 4: Wire local byte-range streaming into `/mf/v1/media/stream/{token}`
**Files:**
- Modify: `src/music_server/routes/mf_media.py`
- Modify: `src/music_server/services/local_streaming.py`
- Modify: `tests/test_mf_media_routes.py`
- [ ] **Step 1: Write the failing test**
Append these tests to `tests/test_mf_media_routes.py`:
```python
def test_media_stream_returns_206_and_content_range_for_local_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
library_root = Path(tmpdir) / "library"
local_file = library_root / "music" / "netease" / "test.flac"
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(b"flac-bytes")
self._prepare_catalog_db(
db_path,
backend_type="local_fs",
backend_name="default-local",
locator="music/netease/test.flac",
public_url=None,
file_size_bytes=10,
)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"LOCAL_LIBRARY_ROOT": str(library_root),
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers={"Authorization": "Bearer dev-token"},
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
stream_url = resolve_response.json()["stream"]["url"]
stream_response = client.get(
stream_url,
headers={"Range": "bytes=2-5"},
)
self.assertEqual(206, stream_response.status_code)
self.assertEqual("bytes", stream_response.headers.get("accept-ranges"))
self.assertEqual("bytes 2-5/10", stream_response.headers.get("content-range"))
self.assertEqual("4", stream_response.headers.get("content-length"))
self.assertEqual("audio/flac", stream_response.headers.get("content-type"))
self.assertEqual(b"ac-b", stream_response.content)
def test_media_stream_returns_416_for_invalid_local_range(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
library_root = Path(tmpdir) / "library"
local_file = library_root / "music" / "netease" / "test.flac"
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(b"flac-bytes")
self._prepare_catalog_db(
db_path,
backend_type="local_fs",
backend_name="default-local",
locator="music/netease/test.flac",
public_url=None,
file_size_bytes=10,
)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"LOCAL_LIBRARY_ROOT": str(library_root),
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers={"Authorization": "Bearer dev-token"},
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
stream_url = resolve_response.json()["stream"]["url"]
stream_response = client.get(
stream_url,
headers={"Range": "bytes=99-100"},
)
self.assertEqual(416, stream_response.status_code)
self.assertEqual("bytes */10", stream_response.headers.get("content-range"))
self.assertEqual("bytes", stream_response.headers.get("accept-ranges"))
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```powershell
python -m unittest tests.test_mf_media_routes.MfMediaRouteTests.test_media_stream_returns_206_and_content_range_for_local_file tests.test_mf_media_routes.MfMediaRouteTests.test_media_stream_returns_416_for_invalid_local_range -v
```
Expected:
- first test fails because current route returns `200` full-file `FileResponse`
- second test fails because current route does not return `416`
- [ ] **Step 3: Write minimal implementation**
First, extend `src/music_server/services/local_streaming.py` with a range file iterator:
```python
def iter_file_range(file_path: Path, start: int, end: int, chunk_size: int = 64 * 1024):
with Path(file_path).open("rb") as handle:
handle.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = handle.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
```
Then replace the local-file branch in `src/music_server/routes/mf_media.py`:
```python
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import RedirectResponse, Response, StreamingResponse
from ..services.local_streaming import (
RangeNotSatisfiable,
guess_audio_media_type,
iter_file_range,
parse_single_range,
)
@stream_router.get("/media/stream/{token}")
def stream_media(token: str, range_header: str | None = Header(default=None, alias="Range")):
settings = get_settings()
try:
parsed = parse_stream_token(secret=settings.access_token, token=token)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
resolved = MediaResolver(db_path=settings.catalog_db_path).resolve_by_locator(
song_id=int(parsed["song_id"]),
locator=str(parsed["locator"]),
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
if resolved.get("backend_type") == "local_fs":
file_path = _resolve_local_stream_path(str(resolved["locator"]))
file_size = file_path.stat().st_size
media_type = guess_audio_media_type(file_path)
try:
byte_window = parse_single_range(range_header, file_size=file_size)
except RangeNotSatisfiable:
return Response(
status_code=416,
headers={
"Accept-Ranges": "bytes",
"Content-Range": f"bytes */{file_size}",
},
)
if byte_window is None:
return StreamingResponse(
iter_file_range(file_path, 0, file_size - 1),
media_type=media_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
},
)
start, end = byte_window
return StreamingResponse(
iter_file_range(file_path, start, end),
status_code=206,
media_type=media_type,
headers={
"Accept-Ranges": "bytes",
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(end - start + 1),
},
)
public_url = resolved.get("public_url")
if not public_url:
raise HTTPException(status_code=404, detail="public stream url not found")
return RedirectResponse(url=str(public_url), status_code=307)
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```powershell
python -m unittest tests.test_mf_media_routes.MfMediaRouteTests.test_media_stream_returns_206_and_content_range_for_local_file tests.test_mf_media_routes.MfMediaRouteTests.test_media_stream_returns_416_for_invalid_local_range -v
```
Expected:
- `OK`
- [ ] **Step 5: Commit**
```bash
git add src/music_server/routes/mf_media.py src/music_server/services/local_streaming.py tests/test_mf_media_routes.py
git commit -m "feat: add range aware local media streaming"
```
### Task 5: Run the focused regression suite for the new contract
**Files:**
- Modify: none
- Test: `tests/test_catalog_reader.py`
- Test: `tests/test_mf_catalog_routes.py`
- Test: `tests/test_local_streaming.py`
- Test: `tests/test_mf_media_routes.py`
- [ ] **Step 1: Run the search-related tests**
Run:
```powershell
python -m unittest tests.test_catalog_reader tests.test_mf_catalog_routes -v
```
Expected:
- all catalog-reader and catalog-route tests pass
- [ ] **Step 2: Run the range/MIME tests**
Run:
```powershell
python -m unittest tests.test_local_streaming tests.test_mf_media_routes -v
```
Expected:
- all local streaming and media route tests pass
- [ ] **Step 3: Run the combined focused suite**
Run:
```powershell
python -m unittest tests.test_catalog_reader tests.test_mf_catalog_routes tests.test_local_streaming tests.test_mf_media_routes -v
```
Expected:
- `OK`
- no search-route regressions
- no media-route regressions
- [ ] **Step 4: Commit**
```bash
git add src/music_server/services/catalog_reader.py src/music_server/routes/mf_catalog.py src/music_server/services/local_streaming.py src/music_server/routes/mf_media.py tests/test_catalog_reader.py tests/test_mf_catalog_routes.py tests/test_local_streaming.py tests/test_mf_media_routes.py
git commit -m "test: lock music server search and range streaming behavior"
```
@@ -0,0 +1,598 @@
# MusicFree Catalogsync Plugin Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a thin MusicFree plugin that talks only to the public music service, loads recommended playlists and toplists, paginates playlist details, and resolves playable tracks via `/mf/v1/media/resolve`.
**Architecture:** Keep the plugin as a single-distribution JavaScript artifact with a couple of small helper modules for ID parsing and HTTP calls. The plugin should not know anything about SQLite, NAS paths, or multi-platform fallback logic; it should translate MusicFree method calls into HTTP requests and map the service response into MusicFree's expected object shape.
**Tech Stack:** JavaScript, CommonJS, Axios, Node.js built-in test runner
---
Repository root: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\integrations\musicfree-plugin`
## File Structure
- Create: `package.json`
- Create: `src/http.js`
- Create: `src/ids.js`
- Create: `src/catalogsync.plugin.js`
- Create: `dist/catalogsync_musicfree.js`
- Create: `tests/plugin.test.cjs`
### Task 1: Scaffold the plugin metadata, config variables, and request helper
**Files:**
- Create: `package.json`
- Create: `src/http.js`
- Create: `src/ids.js`
- Create: `src/catalogsync.plugin.js`
- Test: `tests/plugin.test.cjs`
- [ ] **Step 1: Write the failing test**
```javascript
const test = require("node:test");
const assert = require("node:assert/strict");
const plugin = require("../src/catalogsync.plugin");
test("plugin exposes metadata and user variables", () => {
assert.equal(plugin.platform, "catalogsync");
assert.deepEqual(
plugin.userVariables.map((item) => item.key),
["apiBase", "accessToken"],
);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/plugin.test.cjs`
Expected: `ERR_MODULE_NOT_FOUND` or `Cannot find module '../src/catalogsync.plugin'`
- [ ] **Step 3: Write minimal implementation**
`package.json`
```json
{
"name": "musicfree-catalogsync-plugin",
"version": "0.1.0",
"type": "commonjs",
"scripts": {
"test": "node --test tests/plugin.test.cjs",
"build": "node -e \"require('fs').copyFileSync('src/catalogsync.plugin.js', 'dist/catalogsync_musicfree.js')\""
},
"dependencies": {
"axios": "^1.7.7"
}
}
```
`src/http.js`
```javascript
const axios = require("axios");
function createClient(apiBase, accessToken) {
return axios.create({
baseURL: String(apiBase || "").replace(/\/+$/, ""),
timeout: 10000,
headers: {
Authorization: `Bearer ${accessToken || ""}`,
},
});
}
module.exports = { createClient };
```
`src/ids.js`
```javascript
function parsePublicId(publicId) {
return String(publicId || "").split(":").pop();
}
module.exports = { parsePublicId };
```
`src/catalogsync.plugin.js`
```javascript
const plugin = {
platform: "catalogsync",
version: "0.1.0",
author: "Codex",
userVariables: [
{ key: "apiBase", name: "API Base", hint: "https://your-host" },
{ key: "accessToken", name: "Access Token", hint: "Bearer token value only" },
],
};
module.exports = plugin;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/plugin.test.cjs`
Expected: `pass 1`
- [ ] **Step 5: Commit**
```bash
git add package.json src/http.js src/ids.js src/catalogsync.plugin.js tests/plugin.test.cjs
git commit -m "feat: scaffold musicfree catalogsync plugin"
```
### Task 2: Implement recommend tags and recommend sheet listing
**Files:**
- Modify: `src/catalogsync.plugin.js`
- Test: `tests/plugin.test.cjs`
- [ ] **Step 1: Write the failing test**
```javascript
const test = require("node:test");
const assert = require("node:assert/strict");
const plugin = require("../src/catalogsync.plugin");
test("getRecommendSheetsByTag maps playlist rows into MusicFree sheet items", async () => {
plugin.__setHttpClientForTests({
get: async (path) => {
if (path === "/mf/v1/recommend/sheets") {
return {
data: {
isEnd: false,
data: [
{
id: "catalogsync:playlist:18165",
title: "娴嬭瘯姝屽崟",
coverImg: "https://img/1.jpg",
description: "netease / 姝屽崟骞垮満",
worksNum: 5,
},
],
},
};
}
throw new Error(`unexpected path ${path}`);
},
});
const result = await plugin.getRecommendSheetsByTag({ id: "all" }, 1);
assert.equal(result.isEnd, false);
assert.equal(result.data[0].platform, "catalogsync");
assert.equal(result.data[0].title, "娴嬭瘯姝屽崟");
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/plugin.test.cjs`
Expected: `TypeError: plugin.getRecommendSheetsByTag is not a function`
- [ ] **Step 3: Write minimal implementation**
`src/catalogsync.plugin.js`
```javascript
const { createClient } = require("./http");
let testClient = null;
function getClient() {
if (testClient) {
return testClient;
}
return createClient("http://127.0.0.1:18081", "dev-token");
}
function mapSheetItem(item) {
return {
id: item.id,
platform: "catalogsync",
title: item.title || "",
coverImg: item.coverImg || "",
description: item.description || "",
worksNum: item.worksNum || 0,
};
}
const plugin = {
platform: "catalogsync",
version: "0.1.0",
author: "Codex",
userVariables: [
{ key: "apiBase", name: "API Base", hint: "https://your-host" },
{ key: "accessToken", name: "Access Token", hint: "Bearer token value only" },
],
__setHttpClientForTests(client) {
testClient = client;
},
async getRecommendSheetTags() {
const response = await getClient().get("/mf/v1/recommend/tags");
return response.data;
},
async getRecommendSheetsByTag(tag, page = 1) {
const response = await getClient().get("/mf/v1/recommend/sheets", {
params: { tag: tag.id, page, page_size: 60 },
});
return {
isEnd: Boolean(response.data.isEnd),
data: (response.data.data || []).map(mapSheetItem),
};
},
};
module.exports = plugin;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/plugin.test.cjs`
Expected: `pass 2`
- [ ] **Step 5: Commit**
```bash
git add src/catalogsync.plugin.js tests/plugin.test.cjs
git commit -m "feat: add musicfree recommend sheet methods"
```
### Task 3: Implement playlist detail pagination and toplist methods
**Files:**
- Modify: `src/catalogsync.plugin.js`
- Test: `tests/plugin.test.cjs`
- [ ] **Step 1: Write the failing test**
```javascript
const test = require("node:test");
const assert = require("node:assert/strict");
const plugin = require("../src/catalogsync.plugin");
test("getMusicSheetInfo returns sheetItem on page 1 and musicList for all pages", async () => {
plugin.__setHttpClientForTests({
get: async (path) => {
if (path === "/mf/v1/playlists/18165") {
return { data: { title: "娴嬭瘯姝屽崟", coverImg: "https://img/1.jpg", worksNum: 2 } };
}
if (path === "/mf/v1/playlists/18165/tracks") {
return {
data: {
isEnd: true,
musicList: [
{
id: "catalogsync:song:3476",
title: "娴峰笨浣?,
artist: " / Crabbit",
artwork: "https://img/song.jpg",
duration: 0,
},
],
},
};
}
throw new Error(`unexpected path ${path}`);
},
});
const result = await plugin.getMusicSheetInfo({ id: "catalogsync:playlist:18165" }, 1);
assert.equal(result.sheetItem.title, "娴嬭瘯姝屽崟");
assert.equal(result.musicList[0].id, "catalogsync:song:3476");
assert.equal(result.isEnd, true);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/plugin.test.cjs`
Expected: `TypeError: plugin.getMusicSheetInfo is not a function`
- [ ] **Step 3: Write minimal implementation**
`src/catalogsync.plugin.js`
```javascript
const { createClient } = require("./http");
const { parsePublicId } = require("./ids");
let testClient = null;
function getClient() {
if (testClient) {
return testClient;
}
return createClient("http://127.0.0.1:18081", "dev-token");
}
function mapSheetItem(item) {
return {
id: item.id,
platform: "catalogsync",
title: item.title || "",
coverImg: item.coverImg || "",
description: item.description || "",
worksNum: item.worksNum || 0,
};
}
function mapMusicItem(item) {
return {
id: item.id,
platform: "catalogsync",
title: item.title || "",
artist: item.artist || "",
album: item.album || "",
artwork: item.artwork || "",
duration: item.duration || 0,
};
}
const plugin = {
platform: "catalogsync",
version: "0.1.0",
author: "Codex",
userVariables: [
{ key: "apiBase", name: "API Base", hint: "https://your-host" },
{ key: "accessToken", name: "Access Token", hint: "Bearer token value only" },
],
__setHttpClientForTests(client) {
testClient = client;
},
async getRecommendSheetTags() {
const response = await getClient().get("/mf/v1/recommend/tags");
return response.data;
},
async getRecommendSheetsByTag(tag, page = 1) {
const response = await getClient().get("/mf/v1/recommend/sheets", {
params: { tag: tag.id, page, page_size: 60 },
});
return {
isEnd: Boolean(response.data.isEnd),
data: (response.data.data || []).map(mapSheetItem),
};
},
async getMusicSheetInfo(sheetItem, page = 1) {
const playlistId = parsePublicId(sheetItem.id);
const tracksResponse = await getClient().get(`/mf/v1/playlists/${playlistId}/tracks`, {
params: { page, page_size: 100 },
});
let resolvedSheetItem = undefined;
if (page === 1) {
const playlistResponse = await getClient().get(`/mf/v1/playlists/${playlistId}`);
resolvedSheetItem = mapSheetItem({
id: `catalogsync:playlist:${playlistId}`,
...playlistResponse.data,
});
}
return {
isEnd: Boolean(tracksResponse.data.isEnd),
sheetItem: resolvedSheetItem,
musicList: (tracksResponse.data.musicList || []).map(mapMusicItem),
};
},
async getTopLists() {
const response = await getClient().get("/mf/v1/toplists");
return response.data;
},
async getTopListDetail(topListItem, page = 1) {
const toplistId = parsePublicId(topListItem.id);
const tracksResponse = await getClient().get(`/mf/v1/toplists/${toplistId}/tracks`, {
params: { page, page_size: 100 },
});
return {
isEnd: Boolean(tracksResponse.data.isEnd),
topListItem,
musicList: (tracksResponse.data.musicList || []).map(mapMusicItem),
};
},
};
module.exports = plugin;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/plugin.test.cjs`
Expected: `pass 3`
- [ ] **Step 5: Commit**
```bash
git add src/catalogsync.plugin.js tests/plugin.test.cjs
git commit -m "feat: add musicfree playlist and toplist detail methods"
```
### Task 4: Implement `getMediaSource` and build the distributable plugin file
**Files:**
- Modify: `src/catalogsync.plugin.js`
- Create: `dist/catalogsync_musicfree.js`
- Test: `tests/plugin.test.cjs`
- [ ] **Step 1: Write the failing test**
```javascript
const test = require("node:test");
const assert = require("node:assert/strict");
const plugin = require("../src/catalogsync.plugin");
test("getMediaSource maps resolve response into MusicFree media source format", async () => {
plugin.__setHttpClientForTests({
get: async () => {
throw new Error("unexpected GET");
},
post: async (path) => {
if (path === "/mf/v1/media/resolve") {
return {
data: {
stream: {
url: "https://public-host/mf/v1/media/stream/token-123",
headers: { Range: "bytes=0-" },
},
selected_source: {
quality: "super",
},
},
};
}
throw new Error(`unexpected POST ${path}`);
},
});
const result = await plugin.getMediaSource({ id: "catalogsync:song:3476" }, "super");
assert.equal(result.url, "https://public-host/mf/v1/media/stream/token-123");
assert.equal(result.quality, "super");
assert.deepEqual(result.headers, { Range: "bytes=0-" });
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/plugin.test.cjs`
Expected: `TypeError: plugin.getMediaSource is not a function`
- [ ] **Step 3: Write minimal implementation**
`src/catalogsync.plugin.js`
```javascript
const { createClient } = require("./http");
const { parsePublicId } = require("./ids");
let testClient = null;
function getClient() {
if (testClient) {
return testClient;
}
return createClient("http://127.0.0.1:18081", "dev-token");
}
function mapSheetItem(item) {
return {
id: item.id,
platform: "catalogsync",
title: item.title || "",
coverImg: item.coverImg || "",
description: item.description || "",
worksNum: item.worksNum || 0,
};
}
function mapMusicItem(item) {
return {
id: item.id,
platform: "catalogsync",
title: item.title || "",
artist: item.artist || "",
album: item.album || "",
artwork: item.artwork || "",
duration: item.duration || 0,
};
}
const plugin = {
platform: "catalogsync",
version: "0.1.0",
author: "Codex",
userVariables: [
{ key: "apiBase", name: "API Base", hint: "https://your-host" },
{ key: "accessToken", name: "Access Token", hint: "Bearer token value only" },
],
__setHttpClientForTests(client) {
testClient = client;
},
async getRecommendSheetTags() {
const response = await getClient().get("/mf/v1/recommend/tags");
return response.data;
},
async getRecommendSheetsByTag(tag, page = 1) {
const response = await getClient().get("/mf/v1/recommend/sheets", {
params: { tag: tag.id, page, page_size: 60 },
});
return {
isEnd: Boolean(response.data.isEnd),
data: (response.data.data || []).map(mapSheetItem),
};
},
async getMusicSheetInfo(sheetItem, page = 1) {
const playlistId = parsePublicId(sheetItem.id);
const tracksResponse = await getClient().get(`/mf/v1/playlists/${playlistId}/tracks`, {
params: { page, page_size: 100 },
});
let resolvedSheetItem = undefined;
if (page === 1) {
const playlistResponse = await getClient().get(`/mf/v1/playlists/${playlistId}`);
resolvedSheetItem = mapSheetItem({
id: `catalogsync:playlist:${playlistId}`,
...playlistResponse.data,
});
}
return {
isEnd: Boolean(tracksResponse.data.isEnd),
sheetItem: resolvedSheetItem,
musicList: (tracksResponse.data.musicList || []).map(mapMusicItem),
};
},
async getTopLists() {
const response = await getClient().get("/mf/v1/toplists");
return response.data;
},
async getTopListDetail(topListItem, page = 1) {
const toplistId = parsePublicId(topListItem.id);
const tracksResponse = await getClient().get(`/mf/v1/toplists/${toplistId}/tracks`, {
params: { page, page_size: 100 },
});
return {
isEnd: Boolean(tracksResponse.data.isEnd),
topListItem,
musicList: (tracksResponse.data.musicList || []).map(mapMusicItem),
};
},
async getMediaSource(musicItem, quality) {
const response = await getClient().post("/mf/v1/media/resolve", {
song_id: musicItem.id,
quality,
});
return {
url: response.data.stream.url,
headers: response.data.stream.headers || {},
quality: response.data.selected_source.quality || quality,
};
},
};
module.exports = plugin;
```
`dist/catalogsync_musicfree.js`
```javascript
module.exports = require("../src/catalogsync.plugin");
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/plugin.test.cjs && npm run build`
Expected: tests all pass and `dist/catalogsync_musicfree.js` exists
- [ ] **Step 5: Commit**
```bash
git add src/catalogsync.plugin.js dist/catalogsync_musicfree.js tests/plugin.test.cjs
git commit -m "feat: add musicfree media resolve method"
```
@@ -0,0 +1,50 @@
# Music Server LAN Plugin Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a second MusicFree plugin that prefers a NAS LAN endpoint when reachable, while keeping the existing Music_Server plugin unchanged and publish both through the subscription manifest.
**Architecture:** Keep the current `music_server.js` path and behavior intact. Add a new `music_server_lan.js` asset with its own platform/name and LAN-first endpoint selection logic, then expose both assets from `Music_Server` plugin routes and the subscription manifest.
**Tech Stack:** Node-style MusicFree plugin JS, Node test runner, FastAPI plugin routes, unittest.
---
### Task 1: Lock route publishing behavior with tests
**Files:**
- Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_plugin_routes.py`
- [ ] Add failing tests for a second plugin asset route and a manifest that returns both plugin entries.
- [ ] Run `python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_plugin_routes.py -q` and confirm failure.
### Task 2: Lock LAN-first plugin behavior with tests
**Files:**
- Create: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\keep-alive-master\Music_Free\music_server_lan.test.cjs`
- [ ] Add failing tests covering `lanBaseUrl` exposure, LAN probe success choosing LAN base URL, LAN probe failure falling back to public base URL, and relative media URL joining against the chosen active base URL.
- [ ] Run `node --test D:\source\musicdl-catalog-sync-worktrees\MusicFree\keep-alive-master\Music_Free\music_server_lan.test.cjs` and confirm failure.
### Task 3: Implement and publish the new plugin
**Files:**
- Create: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\keep-alive-master\Music_Free\music_server_lan.js`
- Create: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server_lan.js`
- Create: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\release\music_server_lan_latest.js`
- Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\routes\plugins.py`
- Modify: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\release\music_server_subscription.json`
- [ ] Implement LAN-first base URL resolution in the new plugin only, using `GET /healthz` reachability with short cache and fallback to the public base URL.
- [ ] Expose the new plugin from `Music_Server` at its own JS route and add it to the shared subscription manifest without removing the original plugin.
- [ ] Copy the released LAN plugin file into the subscription-facing release location.
### Task 4: Verify end to end
**Files:**
- Verify only
- [ ] Run the focused MusicFree LAN plugin tests.
- [ ] Run the plugin route tests.
- [ ] Fetch the manifest locally and confirm it lists both plugin URLs.
- [ ] If requested, sync/deploy the updated `Music_Server` plugin asset and manifest to NAS and restart the service.
@@ -0,0 +1,53 @@
# Music_Server Inline Lyrics Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `Music_Server` include local `.lrc` content as `rawLrc` in distributed song items and keep the MusicFree plugins passing that field through.
**Architecture:** Extend catalog track queries to expose the best local file locator, resolve a sibling `.lrc` under `LOCAL_LIBRARY_ROOT`, and inject the text into the existing song payload builder. Update both served plugin assets so their `mapMusicItem()` output preserves `rawLrc`.
**Tech Stack:** Python 3.11, FastAPI, sqlite3, unittest, plain JavaScript plugin assets.
---
### Task 1: Add failing backend and plugin tests
**Files:**
- Modify: `tests/test_mf_catalog_routes.py`
- Modify: `tests/test_plugin_routes.py`
- [ ] Add route assertions that expect `rawLrc` when a same-name `.lrc` exists beside a local audio file.
- [ ] Add route assertions that verify requests still succeed without `LOCAL_LIBRARY_ROOT`.
- [ ] Add plugin asset assertions that both served JS assets contain `rawLrc` mapping code.
- [ ] Run only the new/updated tests and confirm they fail for the expected missing-lyrics behavior.
### Task 2: Implement inline lyric loading
**Files:**
- Modify: `src/music_server/services/catalog_reader.py`
- Modify: `src/music_server/routes/mf_catalog.py`
- [ ] Extend track row queries to expose the preferred active `local_fs` locator for each song.
- [ ] Add a helper that safely resolves a sibling `.lrc` under `LOCAL_LIBRARY_ROOT` and reads it as text.
- [ ] Inject `rawLrc` into `_to_music_item()` only when a lyric file is present.
- [ ] Re-run the focused backend tests and confirm they pass.
### Task 3: Preserve lyrics in plugin assets
**Files:**
- Modify: `src/music_server/plugin_assets/music_server.js`
- Modify: `src/music_server/plugin_assets/music_server_lan.js`
- Test: `tests/test_plugin_routes.py`
- [ ] Update `mapMusicItem()` in both plugin assets to copy `rawLrc` from the server payload.
- [ ] Re-run the plugin asset tests and confirm they pass.
### Task 4: Verify the feature end to end in repo tests
**Files:**
- Modify: none
- [ ] Run the focused unittest command for `test_mf_catalog_routes` and `test_plugin_routes`.
- [ ] If those pass, run `test_catalog_reader.py` as a regression check for reader query changes.
- [ ] Record any deployment-only follow-up separately; do not expand scope in code.
@@ -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 的唯一设计依据。
@@ -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` 客户端插件列表状态与可播歌曲数展示
@@ -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` 映射逻辑