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
+10
View File
@@ -0,0 +1,10 @@
.git
.pytest_cache
__pycache__
*.pyc
*.pyo
*.pyd
tests
docs
config/music_server.env
data
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
.pytest_cache/
config/music_server.env
data/
+20
View File
@@ -0,0 +1,20 @@
ARG BASE_IMAGE=docker.m.daocloud.io/library/python:3.11-slim
FROM ${BASE_IMAGE}
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app/src
WORKDIR /app
COPY requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY pyproject.toml ./pyproject.toml
COPY src ./src
COPY scripts ./scripts
COPY release ./release
EXPOSE 8000
CMD ["uvicorn", "music_server.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,12 @@
PUBLIC_MUSIC_ACCESS_TOKEN=replace-with-a-strong-token
CATALOG_DB_PATH=/app/data/catalog_read.db
PLAYER_DB_PATH=/app/data/player.db
LOCAL_LIBRARY_ROOT=/music_library
MUSIC_SERVER_DISABLE_AUTH=0
MUSIC_SERVER_CACHE_RELAY_ENABLED=1
MUSIC_SERVER_ADMIN_USERNAME=admin
MUSIC_SERVER_ADMIN_PASSWORD_HASH=sha256$replace-with-sha256-hex
MUSIC_SERVER_SECRET_ENCRYPTION_KEY=replace-with-a-strong-secret
MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS=600
MUSICFREE_VERSION_JSON=/app/release/version.json
MUSICFREE_APK_PATH=/app/release/MusicFree_latest_release_universal.apk
+24
View File
@@ -0,0 +1,24 @@
param(
[switch]$SkipHealthCheck
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$DelegateScript = Join-Path $RepoRoot "scripts\deploy_to_nas.ps1"
if (-not (Test-Path -LiteralPath $DelegateScript)) {
throw "Deploy script not found: $DelegateScript"
}
$arguments = @(
"-ExecutionPolicy", "Bypass",
"-File", $DelegateScript
)
if ($SkipHealthCheck) {
$arguments += "-SkipHealthCheck"
}
powershell @arguments
exit $LASTEXITCODE
+48
View File
@@ -0,0 +1,48 @@
version: "3.8"
services:
catalog-export:
build:
context: .
dockerfile: Dockerfile
image: local/music-server:latest
command:
[
"python",
"scripts/export_catalog_read.py",
"--source-db",
"/source/catalogsync.db",
"--target-db",
"/app/data/catalog_read.db"
]
volumes:
- ../data:/app/data
- /volume4/Music_Cloud/catalogsync/data/catalogsync.db:/source/catalogsync.db:ro
music-server:
build:
context: .
dockerfile: Dockerfile
image: local/music-server:latest
container_name: music-server
restart: unless-stopped
env_file:
- ../config/music_server.env
ports:
- "18081:8000"
volumes:
- ../config:/app/config:ro
- ../data:/app/data
- /volume4/Music_Cloud/library:/music_library:ro
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5).read()"
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
+126
View File
@@ -0,0 +1,126 @@
# NAS Docker Deployment
This deployment is tailored for the Synology NAS at `192.168.5.43`.
## Host Paths
- Source catalog DB: `/volume4/Music_Cloud/catalogsync/data/catalogsync.db`
- Source music library: `/volume4/Music_Cloud/library`
- App workspace: `/volume4/Music_Cloud/Music_Server`
- Repo checkout: `/volume4/Music_Cloud/Music_Server/app`
- Runtime config: `/volume4/Music_Cloud/Music_Server/config/music_server.env`
- Runtime data: `/volume4/Music_Cloud/Music_Server/data/catalog_read.db`
- Deploy script: `/volume4/Music_Cloud/Music_Server/bin/deploy_and_restart.sh`
## Tracked Files
- Docker image: [Dockerfile](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/Dockerfile)
- NAS compose: [docker-compose.nas.yml](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/docker-compose.nas.yml)
- Example env: [music_server.env.example](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/config/music_server.env.example)
- Catalog export script: [export_catalog_read.py](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/scripts/export_catalog_read.py)
- Local deploy entry: [deploy-music-server.ps1](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/deploy-music-server.ps1)
- Deploy delegate: [deploy_to_nas.ps1](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/scripts/deploy_to_nas.ps1)
- Deploy helper: [deploy_to_nas.py](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/scripts/deploy_to_nas.py)
- NAS deploy template: [deploy_and_restart.sh](/d:/source/musicdl-catalog-sync-worktrees/Music_Server/scripts/templates/deploy_and_restart.sh)
## Dependency on catalog-sync Post-Download Export
`Music_Server` depends on `catalog-sync` to refresh `/volume4/Music_Cloud/Music_Server/data/catalog_read.db` automatically after downloads.
Configure the following in `catalog-sync`:
- `CATALOG_EXPORT_COMMAND=bash /volume4/Music_Cloud/Music_Server/scripts/catalog-export.sh`
- `CATALOG_EXPORT_WORKDIR=/volume4/Music_Cloud/Music_Server`
If this automation is not configured, `Music_Server` will only see new catalog data after a manual export.
## First-Time Setup
1. Copy the repo to `/volume4/Music_Cloud/Music_Server/app`.
2. Create `/volume4/Music_Cloud/Music_Server/config` and copy `app/config/music_server.env.example` to `/volume4/Music_Cloud/Music_Server/config/music_server.env`.
3. Create `/volume4/Music_Cloud/Music_Server/data`.
4. Set a real `PUBLIC_MUSIC_ACCESS_TOKEN`.
5. Set `MUSIC_SERVER_DISABLE_AUTH=1` only if you explicitly want to disable token auth.
6. Run the export job:
```bash
sudo docker-compose -f docker-compose.nas.yml run --rm catalog-export
```
7. Start the service:
```bash
sudo docker-compose -f docker-compose.nas.yml up -d music-server
```
The NAS compose file is intentionally wired to `../config` and `../data`, so runtime state survives app-checkout replacement during deploy.
## Standard Deploy Flow
From this Windows workstation, run:
```powershell
powershell -ExecutionPolicy Bypass -File .\deploy-music-server.ps1
```
What it does:
- Upload the repository into NAS staging: `/volume4/Music_Cloud/Music_Server/deploy/staging/music-server-app`
- Install/update `/volume4/Music_Cloud/Music_Server/bin/deploy_and_restart.sh`
- On NAS, move legacy runtime files from `app/config` and `app/data` into the standard sibling `config` and `data` directories when needed
- Rebuild images, rerun `catalog-export`, restart `music-server`, then probe `http://127.0.0.1:18081/healthz`
- Keep timestamped app backups under `/volume4/Music_Cloud/Music_Server/deploy/backups`
## Smoke Tests
- Health: `http://<nas-ip>:18081/healthz`
- Token status: `GET /auth/v1/token-status`
- Plugin manifest: `GET /plugins/music_server.json`
- Plugin asset: `GET /plugins/music_server.js`
- MusicFree catalog: `GET /mf/v1/recommend/sheets`
- Stream resolve: `POST /mf/v1/media/resolve`
The service can stream local files directly from `/volume4/Music_Cloud/library` through the mounted `/music_library` volume when `public_url` is absent.
## Token Operations
Issue a token:
```bash
python -m music_server.tools.issue_token --days 90 --label iphone16
```
List tokens:
```bash
python -m music_server.tools.list_tokens
```
Unbind a token from its current client:
```bash
python -m music_server.tools.unbind_token --token-id <token_id>
```
Revoke a token:
```bash
python -m music_server.tools.revoke_token --token-id <token_id> --reason replaced
```
Smoke check token status:
```bash
curl -H "Authorization: Bearer <token>" -H "X-Music-Client-Id: smoke-client" http://127.0.0.1:18081/auth/v1/token-status
```
Smoke check plugin manifest:
```bash
curl http://127.0.0.1:18081/plugins/music_server.json
```
## Notes
- The default `Dockerfile` base image uses `docker.m.daocloud.io/library/python:3.11-slim` so NAS builds do not depend on direct Docker Hub access.
- If your NAS has a different preferred mirror, override `BASE_IMAGE` during build.
@@ -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` 映射逻辑
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "music-server"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.115.0",
"uvicorn==0.32.0",
"httpx==0.27.2",
"itsdangerous==2.2.0",
"python-multipart==0.0.20",
"cryptography==43.0.3",
"paramiko==3.5.0",
"boto3==1.35.36",
]
[tool.setuptools.packages.find]
where = ["src"]
+8
View File
@@ -0,0 +1,8 @@
fastapi==0.115.0
uvicorn==0.32.0
httpx==0.27.2
itsdangerous==2.2.0
python-multipart==0.0.20
cryptography==43.0.3
paramiko==3.5.0
boto3==1.35.36
+33
View File
@@ -0,0 +1,33 @@
param(
[string]$HostName = "192.168.5.43",
[int]$Port = 222,
[string]$User = "xiaoming",
[string]$RemoteAppHome = "/volume4/Music_Cloud/Music_Server",
[string]$Password = $(if ($env:NAS_192168543_PASSWORD) { $env:NAS_192168543_PASSWORD } else { "Nie@159357" }),
[switch]$SkipHealthCheck
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$PythonScript = Join-Path $ScriptDir "deploy_to_nas.py"
if (-not (Test-Path -LiteralPath $PythonScript)) {
throw "Python script not found: $PythonScript"
}
$arguments = @(
$PythonScript,
"--host", $HostName,
"--port", "$Port",
"--user", $User,
"--remote-app-home", $RemoteAppHome
)
if ($Password) {
$arguments += @("--password", $Password)
}
if ($SkipHealthCheck) {
$arguments += "--skip-health-check"
}
python @arguments
exit $LASTEXITCODE
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import posixpath
import stat
import sys
from pathlib import Path
from typing import Iterable
DEFAULT_PASSWORD = "Nie@159357"
SKIP_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".git"}
SKIP_FILE_SUFFIXES = {".pyc", ".pyo", ".DS_Store"}
SKIP_FILE_NAMES = {"music_server_deploy.tar", "music_server_deploy.zip"}
def parse_args() -> argparse.Namespace:
script_dir = Path(__file__).resolve().parent
project_root = script_dir.parent
default_template_dir = script_dir / "templates"
parser = argparse.ArgumentParser(
description="Upload Music_Server to NAS staging and trigger deploy_and_restart.sh"
)
parser.add_argument("--host", default="192.168.5.43")
parser.add_argument("--port", type=int, default=222)
parser.add_argument("--user", default="xiaoming")
parser.add_argument(
"--password",
default=os.environ.get("NAS_192168543_PASSWORD") or DEFAULT_PASSWORD,
)
parser.add_argument("--remote-app-home", default="/volume4/Music_Cloud/Music_Server")
parser.add_argument("--source-dir", default=str(project_root))
parser.add_argument("--template-dir", default=str(default_template_dir))
parser.add_argument("--skip-health-check", action="store_true")
return parser.parse_args()
def to_sftp_path(shell_path: str) -> str:
normalized = shell_path.rstrip("/")
if normalized.startswith("/volume4"):
mapped = normalized[len("/volume4") :]
return mapped or "/"
return normalized or "/"
def shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def ensure_remote_dir(sftp, path: str) -> None:
path = path.rstrip("/")
if not path or path == "/":
return
parts: list[str] = []
current = path
while current not in ("", "/"):
parts.append(current)
current = posixpath.dirname(current)
for part in reversed(parts):
try:
sftp.stat(part)
except OSError:
sftp.mkdir(part)
def exists_remote(sftp, path: str) -> bool:
try:
sftp.stat(path)
return True
except OSError:
return False
def remove_remote_tree(sftp, root: str) -> None:
if not exists_remote(sftp, root):
return
for entry in sftp.listdir_attr(root):
child = posixpath.join(root, entry.filename)
if stat.S_ISDIR(entry.st_mode):
remove_remote_tree(sftp, child)
else:
sftp.remove(child)
sftp.rmdir(root)
def iter_local_files(local_root: Path) -> Iterable[Path]:
for current_root, dir_names, file_names in os.walk(local_root):
dir_names[:] = [name for name in dir_names if name not in SKIP_DIR_NAMES]
for file_name in file_names:
path = Path(current_root) / file_name
if path.suffix in SKIP_FILE_SUFFIXES:
continue
if path.name in SKIP_FILE_NAMES:
continue
yield path
def upload_tree(sftp, local_root: Path, remote_root: str) -> int:
ensure_remote_dir(sftp, remote_root)
uploaded = 0
for local_file in iter_local_files(local_root):
relative_path = local_file.relative_to(local_root).as_posix()
remote_file = posixpath.join(remote_root, relative_path)
remote_dir = posixpath.dirname(remote_file)
ensure_remote_dir(sftp, remote_dir)
sftp.put(str(local_file), remote_file)
uploaded += 1
return uploaded
def read_channel_text(channel_file) -> str:
data = channel_file.read()
if isinstance(data, bytes):
return data.decode("utf-8", "replace")
return str(data)
def run_remote_command(client, command: str, sudo_password: str | None = None) -> int:
remote_command = command
if sudo_password:
remote_command = f"sudo -S sh -lc {shell_quote(command)}"
stdin, stdout, stderr = client.exec_command(remote_command)
if sudo_password:
stdin.write(sudo_password + "\n")
stdin.flush()
out_text = read_channel_text(stdout)
err_text = read_channel_text(stderr)
if out_text:
print(out_text, end="")
if err_text:
print(err_text, file=sys.stderr, end="")
return stdout.channel.recv_exit_status()
def main() -> int:
import paramiko
args = parse_args()
source_dir = Path(args.source_dir).resolve()
template_dir = Path(args.template_dir).resolve()
if not source_dir.exists():
print(f"Source dir not found: {source_dir}", file=sys.stderr)
return 2
deploy_template = template_dir / "deploy_and_restart.sh"
if not deploy_template.exists():
print(f"Missing template: {deploy_template}", file=sys.stderr)
return 2
remote_app_home_shell = args.remote_app_home.rstrip("/")
remote_app_home_sftp = to_sftp_path(remote_app_home_shell)
remote_staging_shell = f"{remote_app_home_shell}/deploy/staging/music-server-app"
remote_staging_sftp = to_sftp_path(remote_staging_shell)
remote_bin_sftp = to_sftp_path(f"{remote_app_home_shell}/bin")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
print(
f"[deploy_to_nas] Connecting {args.user}@{args.host}:{args.port} ...",
flush=True,
)
client.connect(
hostname=args.host,
port=args.port,
username=args.user,
password=args.password,
timeout=15,
banner_timeout=15,
auth_timeout=15,
)
try:
sftp = client.open_sftp()
try:
ensure_remote_dir(sftp, remote_app_home_sftp)
ensure_remote_dir(sftp, remote_bin_sftp)
if exists_remote(sftp, remote_staging_sftp):
print(
f"[deploy_to_nas] Clearing staging: {remote_staging_sftp}",
flush=True,
)
remove_remote_tree(sftp, remote_staging_sftp)
ensure_remote_dir(sftp, remote_staging_sftp)
uploaded_count = upload_tree(sftp, source_dir, remote_staging_sftp)
print(
f"[deploy_to_nas] Uploaded {uploaded_count} files to staging.",
flush=True,
)
sftp.put(
str(deploy_template),
posixpath.join(remote_bin_sftp, "deploy_and_restart.sh"),
)
finally:
sftp.close()
chmod_cmd = (
f"chmod +x {shell_quote(remote_app_home_shell + '/bin/deploy_and_restart.sh')}"
)
chmod_exit = run_remote_command(client, chmod_cmd)
if chmod_exit != 0:
print(
f"[deploy_to_nas] chmod failed with exit code {chmod_exit}",
file=sys.stderr,
)
return chmod_exit
deploy_cmd = (
f"bash {shell_quote(remote_app_home_shell + '/bin/deploy_and_restart.sh')} "
f"--staging-dir {shell_quote(remote_staging_shell)}"
)
if args.skip_health_check:
deploy_cmd += " --skip-health-check"
print("[deploy_to_nas] Running deploy command...", flush=True)
deploy_exit = run_remote_command(client, deploy_cmd, sudo_password=args.password)
if deploy_exit != 0:
print(
f"[deploy_to_nas] Deploy failed with exit code {deploy_exit}",
file=sys.stderr,
)
return deploy_exit
print("[deploy_to_nas] Deploy completed successfully.", flush=True)
return 0
finally:
client.close()
if __name__ == "__main__":
raise SystemExit(main())
+597
View File
@@ -0,0 +1,597 @@
from __future__ import annotations
import argparse
import json
import os
import sqlite3
import tempfile
from pathlib import Path
TOPLIST_PARSE_STRATEGIES = {"netease_toplist", "qq_toplist", "kuwo_toplist"}
TOPLIST_GROUP_NAMES = {
"qq": "QQ音乐",
"netease": "网易云",
"kuwo": "酷我",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build catalog_read.db from catalogsync.db for Music_Server."
)
parser.add_argument("--source-db", required=True, help="Path to catalogsync.db")
parser.add_argument("--target-db", required=True, help="Path to catalog_read.db")
return parser.parse_args()
def connect(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
return conn
def create_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
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_playlist_tracks (
playlist_id integer not null,
song_id integer not null,
position integer not null
);
create table catalog_toplists (
toplist_id text primary key,
platform text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null,
group_name text not null
);
create table catalog_toplist_tracks (
toplist_id text not null,
song_id integer not null,
position integer not null
);
create table catalog_track_files (
song_id integer not null,
quality_label text,
ext text,
file_size_bytes integer,
backend_type text,
backend_name text,
locator text,
public_url text,
status text,
is_primary integer
);
create table 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
);
create table catalog_artist_tracks (
artist_id integer not null,
song_id integer not null,
position integer not null
);
create index idx_catalog_playlist_tracks_playlist on catalog_playlist_tracks (playlist_id, position);
create index idx_catalog_toplist_tracks_toplist on catalog_toplist_tracks (toplist_id, position);
create index idx_catalog_track_files_song on catalog_track_files (song_id, status, is_primary);
create index idx_catalog_artist_tracks_artist on catalog_artist_tracks (artist_id, position);
"""
)
def _extract_song_cover(metadata_json: str | None) -> str | None:
if not metadata_json:
return None
try:
payload = json.loads(metadata_json)
except json.JSONDecodeError:
return None
snapshot = payload.get("snapshot") or {}
raw_data = snapshot.get("raw_data") or {}
if isinstance(snapshot.get("cover_url"), str) and snapshot.get("cover_url"):
return snapshot["cover_url"]
search = raw_data.get("search") or {}
album = search.get("al") or {}
if isinstance(album.get("picUrl"), str) and album.get("picUrl"):
return album["picUrl"]
return None
def _extract_duration_ms(duration_seconds: int | None, metadata_json: str | None) -> int | None:
if duration_seconds is not None:
return int(duration_seconds) * 1000
if not metadata_json:
return None
try:
payload = json.loads(metadata_json)
except json.JSONDecodeError:
return None
snapshot = payload.get("snapshot") or {}
raw_data = snapshot.get("raw_data") or {}
search = raw_data.get("search") or {}
duration_ms = search.get("dt")
if duration_ms is None:
duration_s = snapshot.get("duration_s")
return int(duration_s) * 1000 if duration_s is not None else None
return int(duration_ms)
def _extract_artist_avatar(metadata_json: str | None) -> str | None:
if not metadata_json:
return None
try:
payload = json.loads(metadata_json)
except json.JSONDecodeError:
return None
avatar = payload.get("avatar") or payload.get("avatar_url") or payload.get("cover_url")
return avatar if isinstance(avatar, str) and avatar else None
def _extract_artist_description(metadata_json: str | None) -> str | None:
if not metadata_json:
return None
try:
payload = json.loads(metadata_json)
except json.JSONDecodeError:
return None
description = payload.get("description") or payload.get("desc")
return description if isinstance(description, str) and description else None
def export_playlists(source: sqlite3.Connection, target: sqlite3.Connection) -> None:
rows = source.execute(
"""
select
p.id,
p.platform,
p.remote_playlist_id,
p.name,
p.creator_name,
p.cover_url,
p.play_count,
coalesce(ps.song_count, p.collected_song_count, 0) as song_count,
coalesce(pps.playable_song_count, 0) as playable_song_count
from playlists p
left join (
select playlist_id, count(*) as song_count
from playlist_songs
group by playlist_id
) ps on ps.playlist_id = p.id
left join (
select
playlist_song_keys.playlist_id,
count(*) as playable_song_count
from (
select distinct playlist_id, song_id
from playlist_songs
) playlist_song_keys
where exists (
select 1
from file_assets fa
join file_locations fl on fl.file_asset_id = fa.id
where fa.song_id = playlist_song_keys.song_id
and fl.status = 'active'
)
group by playlist_song_keys.playlist_id
) pps on pps.playlist_id = p.id
where p.parse_strategy not in ({})
""".format(",".join("?" for _ in TOPLIST_PARSE_STRATEGIES)),
tuple(sorted(TOPLIST_PARSE_STRATEGIES)),
).fetchall()
target.executemany(
"""
insert into catalog_playlists (
playlist_id,
platform,
remote_playlist_id,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
int(row["id"]),
row["platform"],
row["remote_playlist_id"],
row["name"],
row["creator_name"],
row["cover_url"],
int(row["play_count"] or 0),
int(row["song_count"] or 0),
int(row["playable_song_count"] or 0),
)
for row in rows
],
)
def export_tracks(source: sqlite3.Connection, target: sqlite3.Connection) -> None:
rows = source.execute(
"""
select
id,
platform,
remote_song_id,
name,
singers,
album,
duration_seconds,
metadata_json
from songs
"""
).fetchall()
target.executemany(
"""
insert into catalog_tracks (
song_id,
platform,
remote_song_id,
name,
singers,
album,
cover_url,
duration_ms,
metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
int(row["id"]),
row["platform"],
row["remote_song_id"],
row["name"],
row["singers"],
row["album"],
_extract_song_cover(row["metadata_json"]),
_extract_duration_ms(row["duration_seconds"], row["metadata_json"]),
row["metadata_json"],
)
for row in rows
],
)
def export_artists(source: sqlite3.Connection, target: sqlite3.Connection) -> None:
exportable_rows = source.execute(
"""
select distinct
a.id as artist_id,
a.artist_key,
a.platform,
a.remote_artist_id,
a.name,
a.normalized_name,
a.metadata_json,
songs.id as song_id,
songs.name as song_name
from artists a
join artist_songs s on s.artist_id = a.id
join songs songs on songs.id = s.song_id
where exists (
select 1
from file_assets fa
join file_locations fl on fl.file_asset_id = fa.id
where fa.song_id = s.song_id
and fl.status = 'active'
)
order by a.id asc, lower(songs.name) asc, songs.id asc
"""
).fetchall()
artist_rows: dict[int, sqlite3.Row] = {}
playable_song_counts: dict[int, int] = {}
positions: dict[int, int] = {}
track_payload: list[tuple[int, int, int]] = []
for row in exportable_rows:
artist_id = int(row["artist_id"])
if artist_id not in artist_rows:
artist_rows[artist_id] = row
playable_song_counts[artist_id] = playable_song_counts.get(artist_id, 0) + 1
positions[artist_id] = positions.get(artist_id, 0) + 1
track_payload.append((artist_id, int(row["song_id"]), positions[artist_id]))
target.executemany(
"""
insert into catalog_artists (
artist_id,
artist_key,
platform,
remote_artist_id,
name,
normalized_name,
avatar_url,
description,
playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
artist_id,
artist_rows[artist_id]["artist_key"],
artist_rows[artist_id]["platform"],
artist_rows[artist_id]["remote_artist_id"],
artist_rows[artist_id]["name"],
artist_rows[artist_id]["normalized_name"],
_extract_artist_avatar(artist_rows[artist_id]["metadata_json"]),
_extract_artist_description(artist_rows[artist_id]["metadata_json"]),
playable_song_counts[artist_id],
)
for artist_id in sorted(artist_rows)
],
)
target.executemany(
"""
insert into catalog_artist_tracks (artist_id, song_id, position)
values (?, ?, ?)
""",
track_payload,
)
def export_playlist_tracks(source: sqlite3.Connection, target: sqlite3.Connection) -> None:
rows = source.execute(
"""
select ps.playlist_id, ps.song_id, coalesce(ps.position, 0) as position
from playlist_songs ps
join playlists p on p.id = ps.playlist_id
where p.parse_strategy not in ({})
""".format(",".join("?" for _ in TOPLIST_PARSE_STRATEGIES)),
tuple(sorted(TOPLIST_PARSE_STRATEGIES)),
).fetchall()
target.executemany(
"""
insert into catalog_playlist_tracks (playlist_id, song_id, position)
values (?, ?, ?)
""",
[(int(row["playlist_id"]), int(row["song_id"]), int(row["position"])) for row in rows],
)
def export_toplists(source: sqlite3.Connection, target: sqlite3.Connection) -> None:
toplist_rows = source.execute(
"""
select
p.id,
p.platform,
p.remote_playlist_id,
p.name,
p.creator_name,
p.cover_url,
p.play_count,
coalesce(ps.song_count, p.collected_song_count, 0) as song_count,
coalesce(pps.playable_song_count, 0) as playable_song_count,
p.parse_strategy
from playlists p
left join (
select playlist_id, count(*) as song_count
from playlist_songs
group by playlist_id
) ps on ps.playlist_id = p.id
left join (
select
playlist_song_keys.playlist_id,
count(*) as playable_song_count
from (
select distinct playlist_id, song_id
from playlist_songs
) playlist_song_keys
where exists (
select 1
from file_assets fa
join file_locations fl on fl.file_asset_id = fa.id
where fa.song_id = playlist_song_keys.song_id
and fl.status = 'active'
)
group by playlist_song_keys.playlist_id
) pps on pps.playlist_id = p.id
where p.parse_strategy in ({})
""".format(",".join("?" for _ in TOPLIST_PARSE_STRATEGIES)),
tuple(sorted(TOPLIST_PARSE_STRATEGIES)),
).fetchall()
target.executemany(
"""
insert into catalog_toplists (
toplist_id,
platform,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count,
group_name
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
f"{row['platform']}_top_{row['remote_playlist_id']}",
row["platform"],
row["name"],
row["creator_name"],
row["cover_url"],
int(row["play_count"] or 0),
int(row["song_count"] or 0),
int(row["playable_song_count"] or 0),
TOPLIST_GROUP_NAMES.get(row["platform"], row["platform"]),
)
for row in toplist_rows
],
)
target.executemany(
"""
insert into catalog_toplist_tracks (toplist_id, song_id, position)
values (?, ?, ?)
""",
[
(
f"{row['platform']}_top_{row['remote_playlist_id']}",
int(track["song_id"]),
int(track["position"] or 0),
)
for row in toplist_rows
for track in source.execute(
"""
select song_id, position
from playlist_songs
where playlist_id = ?
order by position asc
""",
(row["id"],),
).fetchall()
],
)
def export_track_files(source: sqlite3.Connection, target: sqlite3.Connection) -> None:
rows = source.execute(
"""
select
fa.song_id,
fa.quality_label,
fa.ext,
fa.file_size_bytes,
sb.backend_type,
sb.name as backend_name,
fl.locator,
coalesce(fl.public_url, fl.download_url) as public_url,
fl.status,
fl.is_primary
from file_locations fl
join file_assets fa on fa.id = fl.file_asset_id
join storage_backends sb on sb.id = fl.backend_id
"""
).fetchall()
target.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
int(row["song_id"]),
row["quality_label"],
row["ext"],
row["file_size_bytes"],
row["backend_type"],
row["backend_name"],
row["locator"],
row["public_url"],
row["status"],
int(row["is_primary"] or 0),
)
for row in rows
],
)
def build_catalog_read(source_db: str, target_db: str) -> None:
source_path = Path(source_db).resolve()
target_path = Path(target_db).resolve()
target_path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_path_str = tempfile.mkstemp(
prefix=target_path.stem + ".",
suffix=".tmp",
dir=str(target_path.parent),
)
os.close(fd)
temp_path = Path(temp_path_str)
source: sqlite3.Connection | None = None
target: sqlite3.Connection | None = None
try:
source = connect(str(source_path))
target = connect(str(temp_path))
create_schema(target)
export_playlists(source, target)
export_tracks(source, target)
export_artists(source, target)
export_playlist_tracks(source, target)
export_toplists(source, target)
export_track_files(source, target)
target.commit()
source.close()
source = None
target.close()
target = None
os.replace(temp_path, target_path)
finally:
if source is not None:
source.close()
if target is not None:
target.close()
if temp_path.exists():
temp_path.unlink()
def main() -> None:
args = parse_args()
build_catalog_read(source_db=args.source_db, target_db=args.target_db)
print(f"catalog_read.db written to {args.target_db}")
if __name__ == "__main__":
main()
@@ -0,0 +1,286 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_HOME="$(cd "${SCRIPT_DIR}/.." && pwd)"
RUN_DIR="${APP_HOME}/run"
DEPLOY_DIR="${APP_HOME}/deploy"
LOCK_DIR="${RUN_DIR}/deploy.lock"
APP_DIR="${APP_HOME}/app"
CONFIG_DIR="${APP_HOME}/config"
DATA_DIR="${APP_HOME}/data"
CONFIG_FILE="${CONFIG_DIR}/music_server.env"
LEGACY_CONFIG_FILE="${APP_DIR}/config/music_server.env"
LEGACY_CONFIG_EXAMPLE="${APP_DIR}/config/music_server.env.example"
LEGACY_DATA_DIR="${APP_DIR}/data"
DEFAULT_STAGING_DIR="${APP_HOME}/deploy/staging/music-server-app"
BACKUP_ROOT="${APP_HOME}/deploy/backups"
COMPOSE_FILE="${APP_DIR}/docker-compose.nas.yml"
DOCKER_BIN_DIR="/var/packages/Docker/target/usr/bin"
STAGING_DIR="${DEFAULT_STAGING_DIR}"
HEALTH_URL="http://127.0.0.1:18081/healthz"
HEALTH_RETRIES=45
HEALTH_INTERVAL_SECONDS=2
KEEP_BACKUPS=3
SKIP_HEALTH_CHECK=0
BACKUP_DIR=""
HAS_BACKUP=0
usage() {
cat <<EOF
Usage:
$(basename "$0") [options]
Options:
--staging-dir PATH Repo staging directory to deploy into ${APP_HOME}/app
--health-url URL Health-check URL (default: ${HEALTH_URL})
--health-retries N Max health-check retries (default: 45)
--health-interval-sec N Health-check interval seconds (default: 2)
--keep-backups N Number of app backups to keep (default: 3)
--skip-health-check Skip HTTP health check
-h, --help Show help
EOF
}
log() {
echo "[deploy_and_restart.sh] $*"
}
fail() {
echo "[deploy_and_restart.sh] ERROR: $*" >&2
exit 1
}
validate_positive_integer() {
local value="$1"
local name="$2"
if ! [[ "${value}" =~ ^[0-9]+$ ]] || (( value < 1 )); then
fail "${name} must be a positive integer: ${value}"
fi
}
acquire_deploy_lock() {
mkdir -p "${RUN_DIR}"
if mkdir "${LOCK_DIR}" 2>/dev/null; then
echo "$$" > "${LOCK_DIR}/owner_pid"
return 0
fi
local owner_pid=""
if [[ -f "${LOCK_DIR}/owner_pid" ]]; then
owner_pid="$(cat "${LOCK_DIR}/owner_pid" 2>/dev/null || true)"
fi
if [[ -n "${owner_pid}" ]] && kill -0 "${owner_pid}" 2>/dev/null; then
fail "Another deploy is running (owner_pid=${owner_pid})"
fi
rm -rf "${LOCK_DIR}"
if ! mkdir "${LOCK_DIR}" 2>/dev/null; then
fail "Cannot acquire deploy lock: ${LOCK_DIR}"
fi
echo "$$" > "${LOCK_DIR}/owner_pid"
}
cleanup_lock() {
rm -rf "${LOCK_DIR}"
}
ensure_runtime_layout() {
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}" "${DEPLOY_DIR}" "${BACKUP_ROOT}" "${RUN_DIR}"
if [[ ! -f "${CONFIG_FILE}" ]]; then
if [[ -f "${LEGACY_CONFIG_FILE}" ]]; then
cp -a "${LEGACY_CONFIG_FILE}" "${CONFIG_FILE}"
log "Copied legacy runtime config to ${CONFIG_FILE}"
elif [[ -f "${LEGACY_CONFIG_EXAMPLE}" ]]; then
cp -a "${LEGACY_CONFIG_EXAMPLE}" "${CONFIG_FILE}"
log "Bootstrapped runtime config from example: ${CONFIG_FILE}"
fi
fi
if [[ -d "${LEGACY_DATA_DIR}" ]] && [[ -z "$(ls -A "${DATA_DIR}" 2>/dev/null || true)" ]]; then
cp -a "${LEGACY_DATA_DIR}/." "${DATA_DIR}/"
log "Copied legacy runtime data into ${DATA_DIR}"
fi
}
require_staging_checkout() {
if [[ ! -d "${STAGING_DIR}" ]]; then
fail "Staging directory not found: ${STAGING_DIR}"
fi
if [[ ! -f "${STAGING_DIR}/docker-compose.nas.yml" ]]; then
fail "Invalid staging checkout (missing docker-compose.nas.yml): ${STAGING_DIR}"
fi
if [[ ! -f "${STAGING_DIR}/Dockerfile" ]]; then
fail "Invalid staging checkout (missing Dockerfile): ${STAGING_DIR}"
fi
}
sync_app_checkout() {
require_staging_checkout
BACKUP_DIR="${BACKUP_ROOT}/app_$(date +%Y%m%d_%H%M%S)"
if [[ -d "${APP_DIR}" ]]; then
mv "${APP_DIR}" "${BACKUP_DIR}"
HAS_BACKUP=1
log "Backed up current app to ${BACKUP_DIR}"
fi
cp -a "${STAGING_DIR}" "${APP_DIR}"
log "Synced new app checkout from ${STAGING_DIR} -> ${APP_DIR}"
}
run_compose() {
export PATH="${DOCKER_BIN_DIR}:${PATH}"
cd "${APP_DIR}"
docker-compose -f "${COMPOSE_FILE}" "$@"
}
remove_conflicting_named_containers() {
export PATH="${DOCKER_BIN_DIR}:${PATH}"
docker rm -f music-server >/dev/null 2>&1 || true
}
restart_service() {
run_compose build catalog-export music-server
remove_conflicting_named_containers
run_compose down --remove-orphans || true
run_compose run --rm catalog-export
run_compose up -d music-server
}
wait_health() {
if (( SKIP_HEALTH_CHECK == 1 )); then
log "Health check skipped by --skip-health-check"
return 0
fi
if ! command -v curl >/dev/null 2>&1; then
fail "curl is required for health check"
fi
log "Health checking: ${HEALTH_URL}"
for _ in $(seq 1 "${HEALTH_RETRIES}"); do
if curl -fsS "${HEALTH_URL}" >/dev/null; then
log "Health check passed"
return 0
fi
sleep "${HEALTH_INTERVAL_SECONDS}"
done
log "Health check failed: ${HEALTH_URL}"
return 1
}
rollback() {
log "Starting rollback..."
if (( HAS_BACKUP == 0 )) || [[ ! -d "${BACKUP_DIR}" ]]; then
log "No backup available; rollback skipped"
return 1
fi
rm -rf "${APP_DIR}"
mv "${BACKUP_DIR}" "${APP_DIR}"
HAS_BACKUP=0
log "Restored backup to ${APP_DIR}"
restart_service
wait_health
}
prune_backups() {
if (( KEEP_BACKUPS < 1 )) || [[ ! -d "${BACKUP_ROOT}" ]]; then
return 0
fi
mapfile -t backups < <(ls -1dt "${BACKUP_ROOT}"/app_* 2>/dev/null || true)
if (( ${#backups[@]} <= KEEP_BACKUPS )); then
return 0
fi
for old_backup in "${backups[@]:KEEP_BACKUPS}"; do
rm -rf "${old_backup}"
log "Pruned old backup: ${old_backup}"
done
}
while [[ $# -gt 0 ]]; do
case "$1" in
--staging-dir)
STAGING_DIR="${2:-}"
shift 2
;;
--health-url)
HEALTH_URL="${2:-}"
shift 2
;;
--health-retries)
HEALTH_RETRIES="${2:-}"
shift 2
;;
--health-interval-sec)
HEALTH_INTERVAL_SECONDS="${2:-}"
shift 2
;;
--keep-backups)
KEEP_BACKUPS="${2:-}"
shift 2
;;
--skip-health-check)
SKIP_HEALTH_CHECK=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
validate_positive_integer "${HEALTH_RETRIES}" "HEALTH_RETRIES"
validate_positive_integer "${HEALTH_INTERVAL_SECONDS}" "HEALTH_INTERVAL_SECONDS"
validate_positive_integer "${KEEP_BACKUPS}" "KEEP_BACKUPS"
ensure_runtime_layout
acquire_deploy_lock
trap cleanup_lock EXIT INT TERM
LOG_FILE="${DEPLOY_DIR}/deploy_$(date +%Y%m%d_%H%M%S).log"
exec > >(tee -a "${LOG_FILE}") 2>&1
log "Starting deploy. staging=${STAGING_DIR}"
log "App home: ${APP_HOME}"
log "Runtime config: ${CONFIG_FILE}"
log "Runtime data: ${DATA_DIR}"
log "Deploy log: ${LOG_FILE}"
if ! sync_app_checkout; then
fail "Sync step failed"
fi
ensure_runtime_layout
if [[ ! -f "${CONFIG_FILE}" ]]; then
fail "Runtime config not found: ${CONFIG_FILE}"
fi
if ! restart_service; then
log "Restart failed; attempting rollback."
if rollback; then
fail "Deploy failed during restart; rollback succeeded."
fi
fail "Deploy failed during restart; rollback failed."
fi
if ! wait_health; then
log "New version failed health check; attempting rollback."
if rollback; then
fail "Deploy failed during health check; rollback succeeded."
fi
fail "Deploy failed during health check; rollback failed."
fi
prune_backups
log "Deploy succeeded."
@@ -0,0 +1 @@
+79
View File
@@ -0,0 +1,79 @@
from contextlib import asynccontextmanager
import threading
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from .routes.admin_cache import api_router as admin_cache_api_router
from .routes.admin_cache import router as admin_cache_router
from .routes.admin_session import router as admin_session_router
from .routes.app_update import router as app_update_router
from .routes.auth import router as auth_router
from .routes.covers import router as covers_router
from .routes.health import router as health_router
from .routes.mf_catalog import router as mf_catalog_router
from .routes.mf_media import router as mf_media_router
from .routes.mf_media import stream_router as mf_media_stream_router
from .routes.player import router as player_router
from .routes.plugins import router as plugins_router
from .services.cache_service import CacheService
from .settings import get_settings
def _cache_worker(stop_event: threading.Event) -> None:
settings = get_settings()
if not settings.cache_relay_enabled:
return
while not stop_event.wait(settings.cache_reconcile_interval_seconds):
try:
service = CacheService(
player_db_path=settings.player_db_path,
catalog_db_path=settings.catalog_db_path,
secret_encryption_key=settings.secret_encryption_key,
local_library_root=settings.local_library_root,
cache_relay_enabled=settings.cache_relay_enabled,
)
service.reconcile_cache_assignments()
service.process_transfer_tasks()
except Exception:
# Keep the service available even if background cache maintenance fails.
continue
@asynccontextmanager
async def _lifespan(app: FastAPI):
settings = get_settings()
stop_event = threading.Event()
worker = threading.Thread(target=_cache_worker, args=(stop_event,), daemon=True)
if settings.cache_relay_enabled and settings.cache_reconcile_interval_seconds > 0:
worker.start()
try:
yield
finally:
stop_event.set()
if worker.is_alive():
worker.join(timeout=1)
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title="Public Music Service", lifespan=_lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_encryption_key,
same_site="lax",
https_only=False,
)
app.include_router(admin_session_router)
app.include_router(admin_cache_router)
app.include_router(admin_cache_api_router)
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(app_update_router)
app.include_router(mf_catalog_router)
app.include_router(mf_media_router)
app.include_router(mf_media_stream_router)
app.include_router(covers_router)
app.include_router(player_router)
app.include_router(plugins_router)
return app
+66
View File
@@ -0,0 +1,66 @@
from dataclasses import dataclass
from fastapi import Header, HTTPException
from .settings import get_settings
from .services.token_service import TokenService
def parse_bearer_token(authorization: str | None) -> str:
if authorization is None:
raise HTTPException(status_code=401, detail="authorization_missing")
parts = authorization.strip().split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(status_code=401, detail="authorization_invalid")
token = parts[1].strip()
if not token:
raise HTTPException(status_code=401, detail="authorization_invalid")
return token
@dataclass(frozen=True)
class AuthenticatedClientContext:
token_id: str
client_id: str | None
client_label: str | None
def require_authenticated_client(
authorization: str | None = Header(default=None),
x_music_client_id: str | None = Header(default=None, alias="X-Music-Client-Id"),
x_music_client_label: str | None = Header(default=None, alias="X-Music-Client-Label"),
) -> AuthenticatedClientContext:
if get_settings().disable_auth:
return AuthenticatedClientContext(
token_id="auth_disabled",
client_id=x_music_client_id,
client_label=x_music_client_label,
)
token = parse_bearer_token(authorization)
auth_result = TokenService(get_settings().player_db_path).authenticate(
plaintext_token=token,
client_id=x_music_client_id,
client_label=x_music_client_label,
)
if not auth_result.valid:
raise HTTPException(status_code=401, detail=auth_result.error_code or "unauthorized")
return AuthenticatedClientContext(
token_id=auth_result.token_id or "",
client_id=x_music_client_id,
client_label=x_music_client_label,
)
def require_bearer_token(
authorization: str | None = Header(default=None),
x_music_client_id: str | None = Header(default=None, alias="X-Music-Client-Id"),
x_music_client_label: str | None = Header(default=None, alias="X-Music-Client-Label"),
) -> None:
require_authenticated_client(
authorization=authorization,
x_music_client_id=x_music_client_id,
x_music_client_label=x_music_client_label,
)
+7
View File
@@ -0,0 +1,7 @@
import sqlite3
def connect_sqlite(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,749 @@
from __future__ import annotations
from html import escape
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import HTMLResponse
from ..services.cache_service import CacheService
from ..settings import get_settings
router = APIRouter(prefix="/admin")
api_router = APIRouter(prefix="/admin/api/cache")
def _cache_service() -> CacheService:
settings = get_settings()
return CacheService(
player_db_path=settings.player_db_path,
catalog_db_path=settings.catalog_db_path,
secret_encryption_key=settings.secret_encryption_key,
local_library_root=settings.local_library_root,
cache_relay_enabled=settings.cache_relay_enabled,
)
def _mask_target(target: dict) -> dict:
secrets = target.pop("secrets", {}) or {}
target["enabled"] = bool(target.get("enabled"))
target["has_secrets"] = bool(secrets)
target["secret_fields"] = sorted(secrets.keys())
return target
def require_admin_session(request: Request) -> None:
if not request.session.get("admin_authenticated"):
raise HTTPException(status_code=401, detail="admin_auth_required")
def _login_page(error_message: str | None = None) -> str:
error_html = ""
if error_message:
error_html = f"<p style='color:#b42318'>{escape(error_message)}</p>"
return f"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Music Server Cache Admin</title>
<style>
body {{ font-family: 'Segoe UI', sans-serif; margin: 40px; background: #f6f7fb; color: #182230; }}
.card {{ max-width: 420px; background: white; padding: 24px; border-radius: 16px; box-shadow: 0 10px 30px rgba(16,24,40,0.08); }}
label {{ display: block; margin-top: 12px; font-size: 14px; }}
input {{ width: 100%; padding: 10px 12px; margin-top: 6px; box-sizing: border-box; }}
button {{ margin-top: 16px; padding: 10px 16px; border: none; border-radius: 10px; background: #111827; color: white; cursor: pointer; }}
</style>
</head>
<body>
<div class="card">
<h1>Cache Admin</h1>
<p>登录后可管理缓存目标、查看热榜并手动重排。</p>
{error_html}
<form action="/admin/session/login" method="post">
<label>Username<input type="text" name="username" autocomplete="username" /></label>
<label>Password<input type="password" name="password" autocomplete="current-password" /></label>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
"""
def _dashboard_page() -> str:
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Music Server Cache Admin</title>
<style>
body { font-family: 'Segoe UI', sans-serif; margin: 24px; background: #f5f7fb; color: #182230; }
header, section { background: white; border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 10px 30px rgba(16,24,40,0.06); }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eaecf0; font-size: 14px; }
.actions { display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
.panel-grid { display: grid; grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); gap: 16px; align-items: start; }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field-full { grid-column: 1 / -1; }
label { display: block; font-size: 13px; font-weight: 600; color: #344054; }
input, select, textarea { width: 100%; box-sizing: border-box; margin-top: 6px; padding: 10px 12px; border: 1px solid #d0d5dd; border-radius: 10px; background: #fff; color: #101828; }
textarea { min-height: 120px; resize: vertical; font-family: Consolas, 'Courier New', monospace; font-size: 12px; }
button, .small-button { padding: 10px 14px; border: none; border-radius: 10px; background: #0f172a; color: white; cursor: pointer; }
button.secondary, .small-button.secondary { background: #475467; }
button.danger, .small-button.danger { background: #b42318; }
form.inline { display: inline; }
pre { white-space: pre-wrap; word-break: break-word; }
.table-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.status { margin-top: 12px; min-height: 20px; font-size: 13px; color: #475467; }
.status.error { color: #b42318; }
.status.success { color: #027a48; }
.hint { margin-top: 6px; font-size: 12px; color: #667085; white-space: pre-wrap; word-break: break-word; }
.break-all { word-break: break-all; }
.kind-fields { display: none; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; border: 1px solid #eaecf0; border-radius: 14px; background: #f8fafc; }
.kind-fields.active { display: grid; }
.kind-title { grid-column: 1 / -1; margin: 0; font-size: 14px; color: #101828; }
</style>
</head>
<body>
<header>
<h1>Cache Targets</h1>
<div class="actions">
<button id="reconcile-button" type="button">Run Reconcile</button>
<form class="inline" action="/admin/session/logout" method="post">
<button type="submit">Logout</button>
</form>
</div>
<pre id="overview">Loading...</pre>
</header>
<section>
<div class="panel-grid">
<div>
<h2 id="target-form-title">Create Target</h2>
<form id="target-form">
<input id="target-id" type="hidden" />
<input id="target-original-kind" type="hidden" />
<input id="target-has-secrets" type="hidden" value="0" />
<div class="field-grid">
<label>
Name
<input id="target-name" type="text" placeholder="vps-a" />
</label>
<label>
Type
<select id="target-kind">
<option value="sftp">sftp</option>
<option value="s3">s3</option>
</select>
</label>
<label>
Queue Order
<input id="target-order-index" type="number" min="1" step="1" value="1" />
</label>
<label>
Song Capacity
<input id="target-capacity-songs" type="number" min="1" step="1" value="500" />
</label>
<label class="field-full">
Public Base URL
<input id="target-public-base-url" type="text" placeholder="https://cache.example.com" />
</label>
<label class="field-full">
Path Prefix
<input id="target-path-prefix" type="text" placeholder="music-cache" />
</label>
<label class="field-full">
<input id="target-enabled" type="checkbox" checked style="width:auto;margin-right:8px;" />
Enabled
</label>
<div id="credentials-hint" class="hint field-full">
New target: fill the full credential set. Existing target: leave credential fields empty to keep saved credentials, or fill the full credential set to replace them.
</div>
<div id="sftp-fields" class="kind-fields field-full">
<h3 class="kind-title">SFTP Connection</h3>
<label>
Host
<input id="sftp-host" type="text" placeholder="1.2.3.4" />
</label>
<label>
Port
<input id="sftp-port" type="number" min="1" step="1" placeholder="22" />
</label>
<label class="field-full">
Remote Root Directory
<input id="sftp-remote-root" type="text" placeholder="/srv/music_server_cache" />
</label>
<label>
Username
<input id="sftp-username" type="text" placeholder="root" />
</label>
<label>
Password
<input id="sftp-password" type="password" placeholder="Password" />
</label>
<label>
Timeout Seconds
<input id="sftp-timeout-seconds" type="number" min="1" step="1" placeholder="10" />
</label>
<label class="field-full">
Private Key
<textarea id="sftp-private-key" placeholder="Optional private key content"></textarea>
</label>
</div>
<div id="s3-fields" class="kind-fields field-full">
<h3 class="kind-title">S3 Connection</h3>
<label>
Bucket
<input id="s3-bucket" type="text" placeholder="music-cache" />
</label>
<label>
Region
<input id="s3-region" type="text" placeholder="ap-shanghai" />
</label>
<label class="field-full">
Endpoint URL
<input id="s3-endpoint-url" type="text" placeholder="https://s3.example.com" />
</label>
<label>
Access Key ID
<input id="s3-access-key-id" type="text" placeholder="AKIA..." />
</label>
<label>
Secret Access Key
<input id="s3-secret-access-key" type="password" placeholder="Secret Access Key" />
</label>
<label class="field-full">
Session Token
<input id="s3-session-token" type="password" placeholder="Optional session token" />
</label>
</div>
</div>
<div class="actions">
<button id="target-submit-button" type="submit">Save Target</button>
<button id="target-test-button" class="secondary" type="button">Test Connection</button>
<button id="target-reset-button" class="secondary" type="button">Reset</button>
</div>
<div id="target-form-status" class="status"></div>
</form>
</div>
<div>
<h2>Targets</h2>
<table>
<thead><tr><th>Name</th><th>Kind</th><th>Queue</th><th>Capacity</th><th>Enabled</th><th>Occupied</th><th>Secrets</th><th>Actions</th></tr></thead>
<tbody id="targets-body"></tbody>
</table>
</div>
</div>
</section>
<section>
<h2>Hot Songs</h2>
<table>
<thead><tr><th>Song ID</th><th>Song Name</th><th>External URL</th><th>30d</th><th>Total</th><th>Last Played</th></tr></thead>
<tbody id="hot-songs-body"></tbody>
</table>
</section>
<script>
let targetItems = [];
function setStatus(message, type) {
const node = document.getElementById('target-form-status');
node.textContent = message || '';
node.className = `status ${type || ''}`.trim();
}
async function readErrorDetail(response, fallbackMessage) {
try {
const payload = await response.json();
return payload.detail || JSON.stringify(payload);
} catch (_) {
return fallbackMessage;
}
}
function readTextValue(id) {
return document.getElementById(id).value.trim();
}
function clearCredentialInputs() {
[
'sftp-host',
'sftp-port',
'sftp-remote-root',
'sftp-username',
'sftp-password',
'sftp-timeout-seconds',
'sftp-private-key',
's3-bucket',
's3-region',
's3-endpoint-url',
's3-access-key-id',
's3-secret-access-key',
's3-session-token'
].forEach((id) => {
document.getElementById(id).value = '';
});
}
function currentTargetState() {
return {
targetId: readTextValue('target-id'),
originalKind: readTextValue('target-original-kind'),
hasSavedSecrets: readTextValue('target-has-secrets') === '1'
};
}
function shouldRequireReplacementSecrets(state, currentKind) {
if (!state.targetId) {
return true;
}
if (!state.hasSavedSecrets) {
return true;
}
if (state.originalKind && state.originalKind !== currentKind) {
return true;
}
return false;
}
function parsePositiveInteger(rawValue, label) {
if (!rawValue) {
throw new Error(`${label} is required.`);
}
const parsed = Number(rawValue);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${label} must be a positive integer.`);
}
return parsed;
}
function buildSftpSecrets(requireSecrets) {
const host = readTextValue('sftp-host');
const portText = readTextValue('sftp-port');
const remoteRoot = readTextValue('sftp-remote-root');
const username = readTextValue('sftp-username');
const password = readTextValue('sftp-password');
const timeoutText = readTextValue('sftp-timeout-seconds');
const privateKey = document.getElementById('sftp-private-key').value.trim();
const anyProvided = Boolean(host || portText || remoteRoot || username || password || timeoutText || privateKey);
if (!requireSecrets && !anyProvided) {
return { provided: false, secrets: null };
}
if (!host) {
throw new Error('SFTP Host is required.');
}
if (!username) {
throw new Error('SFTP Username is required.');
}
if (!password && !privateKey) {
throw new Error('SFTP Password or Private Key is required.');
}
const secrets = {
host,
port: portText ? parsePositiveInteger(portText, 'SFTP Port') : 22,
username
};
if (remoteRoot) {
secrets.remote_root = remoteRoot;
}
if (password) {
secrets.password = password;
}
if (privateKey) {
secrets.private_key = privateKey;
}
if (timeoutText) {
secrets.timeout_seconds = parsePositiveInteger(timeoutText, 'SFTP Timeout Seconds');
}
return { provided: true, secrets };
}
function buildS3Secrets(requireSecrets) {
const bucket = readTextValue('s3-bucket');
const region = readTextValue('s3-region');
const endpointUrl = readTextValue('s3-endpoint-url');
const accessKeyId = readTextValue('s3-access-key-id');
const secretAccessKey = readTextValue('s3-secret-access-key');
const sessionToken = readTextValue('s3-session-token');
const anyProvided = Boolean(bucket || region || endpointUrl || accessKeyId || secretAccessKey || sessionToken);
if (!requireSecrets && !anyProvided) {
return { provided: false, secrets: null };
}
if (!bucket) {
throw new Error('S3 Bucket is required.');
}
if (!accessKeyId) {
throw new Error('S3 Access Key ID is required.');
}
if (!secretAccessKey) {
throw new Error('S3 Secret Access Key is required.');
}
const secrets = {
bucket,
access_key_id: accessKeyId,
secret_access_key: secretAccessKey
};
if (region) {
secrets.region = region;
}
if (endpointUrl) {
secrets.endpoint_url = endpointUrl;
}
if (sessionToken) {
secrets.session_token = sessionToken;
}
return { provided: true, secrets };
}
function buildConnectionSecrets(requireSecrets) {
const kind = document.getElementById('target-kind').value;
if (kind === 'sftp') {
return buildSftpSecrets(requireSecrets);
}
return buildS3Secrets(requireSecrets);
}
function updateCredentialSections() {
const kind = document.getElementById('target-kind').value;
document.getElementById('sftp-fields').classList.toggle('active', kind === 'sftp');
document.getElementById('s3-fields').classList.toggle('active', kind === 's3');
}
function resetTargetForm() {
document.getElementById('target-form-title').textContent = 'Create Target';
document.getElementById('target-id').value = '';
document.getElementById('target-original-kind').value = '';
document.getElementById('target-has-secrets').value = '0';
document.getElementById('target-name').value = '';
document.getElementById('target-kind').value = 'sftp';
document.getElementById('target-order-index').value = '1';
document.getElementById('target-capacity-songs').value = '500';
document.getElementById('target-public-base-url').value = '';
document.getElementById('target-path-prefix').value = '';
document.getElementById('target-enabled').checked = true;
document.getElementById('target-submit-button').textContent = 'Save Target';
clearCredentialInputs();
updateCredentialSections();
setStatus('', '');
}
function populateTargetForm(item) {
document.getElementById('target-form-title').textContent = `Edit Target #${item.id}`;
document.getElementById('target-id').value = String(item.id);
document.getElementById('target-original-kind').value = item.kind || '';
document.getElementById('target-has-secrets').value = item.has_secrets ? '1' : '0';
document.getElementById('target-name').value = item.name || '';
document.getElementById('target-kind').value = item.kind || 'sftp';
document.getElementById('target-order-index').value = String(item.order_index || 1);
document.getElementById('target-capacity-songs').value = String(item.capacity_songs || 500);
document.getElementById('target-public-base-url').value = item.public_base_url || '';
document.getElementById('target-path-prefix').value = item.path_prefix || '';
document.getElementById('target-enabled').checked = Boolean(item.enabled);
document.getElementById('target-submit-button').textContent = 'Update Target';
clearCredentialInputs();
updateCredentialSections();
if (item.has_secrets) {
setStatus('Leave credential fields empty to keep saved credentials, or fill the full credential set to replace them.', '');
return;
}
setStatus('This target has no saved credentials yet. Fill the full credential set before testing or saving.', '');
}
async function loadOverview() {
const [overviewRes, targetsRes, hotSongsRes] = await Promise.all([
fetch('/admin/api/cache/overview'),
fetch('/admin/api/cache/targets'),
fetch('/admin/api/cache/hot-songs')
]);
const overview = await overviewRes.json();
const targets = await targetsRes.json();
const hotSongs = await hotSongsRes.json();
targetItems = targets.items || [];
document.getElementById('overview').textContent = JSON.stringify(overview, null, 2);
document.getElementById('targets-body').innerHTML = targetItems.map(item => `
<tr>
<td>${item.name}</td>
<td>${item.kind}</td>
<td>${item.order_index}</td>
<td>${item.capacity_songs}</td>
<td>${item.enabled}</td>
<td>${item.occupied_song_count ?? 0}</td>
<td>${item.secret_fields.join(', ')}</td>
<td>
<div class="table-actions">
<button class="small-button secondary" type="button" data-action="edit" data-target-id="${item.id}">Edit</button>
<button class="small-button secondary" type="button" data-action="test" data-target-id="${item.id}">Test</button>
<button class="small-button danger" type="button" data-action="delete" data-target-id="${item.id}">Delete</button>
</div>
</td>
</tr>
`).join('');
document.getElementById('hot-songs-body').innerHTML = hotSongs.items.map(item => `
<tr>
<td>${item.song_id}</td>
<td>${item.name || ''}</td>
<td class="break-all">${item.external_url ? `<a href="${item.external_url}" target="_blank" rel="noreferrer">${item.external_url}</a>` : ''}</td>
<td>${item.play_count_30d}</td>
<td>${item.play_count_total}</td>
<td>${item.last_played_at ?? ''}</td>
</tr>
`).join('');
}
async function handleTargetSubmit(event) {
event.preventDefault();
const state = currentTargetState();
const targetId = document.getElementById('target-id').value.trim();
const payload = {
name: document.getElementById('target-name').value.trim(),
kind: document.getElementById('target-kind').value,
order_index: Number(document.getElementById('target-order-index').value),
capacity_songs: Number(document.getElementById('target-capacity-songs').value),
public_base_url: document.getElementById('target-public-base-url').value.trim(),
path_prefix: document.getElementById('target-path-prefix').value.trim(),
enabled: document.getElementById('target-enabled').checked
};
try {
if (!payload.name) {
throw new Error('Name is required.');
}
if (!payload.public_base_url) {
throw new Error('Public Base URL is required.');
}
if (!Number.isFinite(payload.order_index) || payload.order_index < 1) {
throw new Error('Queue Order must be a positive integer.');
}
if (!Number.isFinite(payload.capacity_songs) || payload.capacity_songs < 1) {
throw new Error('Song Capacity must be a positive integer.');
}
const credentialState = buildConnectionSecrets(
shouldRequireReplacementSecrets(state, payload.kind)
);
if (credentialState.provided) {
payload.secrets = credentialState.secrets;
}
} catch (error) {
setStatus(error.message || 'Invalid target form.', 'error');
return;
}
const response = await fetch(
targetId ? `/admin/api/cache/targets/${targetId}` : '/admin/api/cache/targets',
{
method: targetId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}
);
if (!response.ok) {
setStatus(await readErrorDetail(response, 'Save failed.'), 'error');
return;
}
setStatus(targetId ? 'Target updated.' : 'Target created.', 'success');
resetTargetForm();
await loadOverview();
}
async function handleTargetConnectionTest() {
const state = currentTargetState();
const kind = document.getElementById('target-kind').value;
const requireReplacementSecrets = shouldRequireReplacementSecrets(state, kind);
let response;
try {
const credentialState = buildConnectionSecrets(requireReplacementSecrets);
if (state.targetId && !credentialState.provided && !requireReplacementSecrets) {
response = await fetch(`/admin/api/cache/targets/${state.targetId}/test`, { method: 'POST' });
} else {
response = await fetch('/admin/api/cache/targets/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
secrets: credentialState.secrets
})
});
}
} catch (error) {
setStatus(error.message || 'Invalid target form.', 'error');
return;
}
if (!response.ok) {
setStatus(await readErrorDetail(response, 'Connection test failed.'), 'error');
return;
}
const payload = await response.json();
if (payload.target_id) {
setStatus(`Connection test ok for saved target #${payload.target_id}.`, 'success');
return;
}
setStatus(`Connection test ok for current ${payload.kind} settings.`, 'success');
}
async function handleTargetAction(event) {
const button = event.target.closest('button[data-action]');
if (!button) {
return;
}
const action = button.dataset.action;
const targetId = button.dataset.targetId;
const item = targetItems.find(entry => String(entry.id) === String(targetId));
if (!item) {
setStatus('Target not found.', 'error');
return;
}
if (action === 'edit') {
populateTargetForm(item);
return;
}
if (action === 'test') {
const response = await fetch(`/admin/api/cache/targets/${targetId}/test`, { method: 'POST' });
if (!response.ok) {
setStatus(await readErrorDetail(response, `Connection test failed for ${item.name}.`), 'error');
return;
}
setStatus(`Connection test ok for ${item.name}.`, 'success');
return;
}
if (action === 'delete') {
if (!window.confirm(`Delete target ${item.name}?`)) {
return;
}
const response = await fetch(`/admin/api/cache/targets/${targetId}`, { method: 'DELETE' });
if (!response.ok) {
setStatus(await readErrorDetail(response, `Delete failed for ${item.name}.`), 'error');
return;
}
setStatus(`Target deleted: ${item.name}.`, 'success');
resetTargetForm();
await loadOverview();
}
}
document.getElementById('reconcile-button').addEventListener('click', async () => {
await fetch('/admin/api/cache/reconcile', { method: 'POST' });
setStatus('Reconcile requested.', 'success');
await loadOverview();
});
document.getElementById('target-kind').addEventListener('change', updateCredentialSections);
document.getElementById('target-form').addEventListener('submit', handleTargetSubmit);
document.getElementById('target-test-button').addEventListener('click', handleTargetConnectionTest);
document.getElementById('target-reset-button').addEventListener('click', resetTargetForm);
document.getElementById('targets-body').addEventListener('click', handleTargetAction);
resetTargetForm();
loadOverview();
</script>
</body>
</html>
"""
@router.get("/cache", response_class=HTMLResponse)
def cache_dashboard(request: Request) -> HTMLResponse:
if not request.session.get("admin_authenticated"):
return HTMLResponse(_login_page())
return HTMLResponse(_dashboard_page())
@api_router.get("/overview", dependencies=[Depends(require_admin_session)])
def overview() -> dict:
service = _cache_service()
payload = service.get_overview()
payload["recent_runs"] = service.list_reconcile_runs(limit=5)
return payload
@api_router.get("/targets", dependencies=[Depends(require_admin_session)])
def list_targets() -> dict:
items = [_mask_target(item) for item in _cache_service().list_cache_targets(include_secrets=True)]
return {"items": items}
@api_router.post("/targets/test", dependencies=[Depends(require_admin_session)])
def test_target_payload(payload: dict) -> dict:
kind = str(payload.get("kind") or "").strip()
secrets = payload.get("secrets")
if not kind:
raise HTTPException(status_code=400, detail="kind is required")
if not isinstance(secrets, dict) or not secrets:
raise HTTPException(status_code=400, detail="secrets are required")
return _cache_service().test_target_connection_payload(kind=kind, secrets=secrets)
@api_router.post("/targets", dependencies=[Depends(require_admin_session)])
def create_target(payload: dict) -> dict:
required = ["name", "kind", "order_index", "capacity_songs", "public_base_url", "path_prefix"]
for key in required:
if key not in payload:
raise HTTPException(status_code=400, detail=f"{key} is required")
created = _cache_service().create_cache_target(
name=str(payload["name"]),
kind=str(payload["kind"]),
order_index=int(payload["order_index"]),
capacity_songs=int(payload["capacity_songs"]),
public_base_url=str(payload["public_base_url"]),
path_prefix=str(payload.get("path_prefix", "")),
enabled=bool(payload.get("enabled", True)),
secrets=payload.get("secrets") or {},
)
target = _cache_service().get_cache_target(target_id=int(created["id"]), include_secrets=True)
return _mask_target(target)
@api_router.patch("/targets/{target_id}", dependencies=[Depends(require_admin_session)])
def update_target(target_id: int, payload: dict) -> dict:
updated = _cache_service().update_cache_target(
target_id=target_id,
name=payload.get("name"),
kind=payload.get("kind"),
order_index=int(payload["order_index"]) if "order_index" in payload else None,
capacity_songs=int(payload["capacity_songs"]) if "capacity_songs" in payload else None,
public_base_url=payload.get("public_base_url"),
path_prefix=payload.get("path_prefix"),
enabled=bool(payload["enabled"]) if "enabled" in payload else None,
secrets=payload.get("secrets"),
)
target = _cache_service().get_cache_target(target_id=int(updated["id"]), include_secrets=True)
return _mask_target(target)
@api_router.delete("/targets/{target_id}", dependencies=[Depends(require_admin_session)])
def delete_target(target_id: int) -> Response:
_cache_service().delete_cache_target(target_id=target_id)
return Response(status_code=204)
@api_router.get("/hot-songs", dependencies=[Depends(require_admin_session)])
def hot_songs() -> dict:
return {"items": _cache_service().list_hot_songs(limit=100)}
@api_router.get("/objects", dependencies=[Depends(require_admin_session)])
def cache_objects() -> dict:
return {"items": _cache_service().list_cache_objects()}
@api_router.get("/tasks", dependencies=[Depends(require_admin_session)])
def cache_tasks() -> dict:
return {"items": _cache_service().list_transfer_tasks()}
@api_router.post("/reconcile", dependencies=[Depends(require_admin_session)])
def reconcile() -> dict:
return _cache_service().reconcile_cache_assignments()
@api_router.post("/targets/{target_id}/test", dependencies=[Depends(require_admin_session)])
def test_target(target_id: int) -> dict:
return _cache_service().test_target_connection(target_id=target_id)
@@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from ..services.admin_security import verify_password_hash
from ..settings import get_settings
router = APIRouter(prefix="/admin/session")
@router.post("/login")
async def login(request: Request):
form = await request.form()
username = str(form.get("username") or "").strip()
password = str(form.get("password") or "")
settings = get_settings()
if username != settings.admin_username or not verify_password_hash(
plaintext_password=password,
stored_hash=settings.admin_password_hash,
):
raise HTTPException(status_code=401, detail="admin_login_failed")
request.session["admin_authenticated"] = True
request.session["admin_username"] = username
return RedirectResponse(url="/admin/cache", status_code=303)
@router.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/admin/cache", status_code=303)
@@ -0,0 +1,62 @@
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from ..settings import get_settings
router = APIRouter(prefix="/app")
_APK_MEDIA_TYPE = "application/vnd.android.package-archive"
def _missing_file_error(path: Path, label: str) -> HTTPException:
return HTTPException(
status_code=404,
detail=f"{label} file not found: {path}",
)
@router.get("/version.json", name="musicfree_app_version_json")
def musicfree_app_version_json(request: Request) -> JSONResponse:
settings = get_settings()
version_json_path = Path(settings.musicfree_version_json_path)
apk_path = Path(settings.musicfree_apk_path)
if not version_json_path.is_file():
raise _missing_file_error(version_json_path, "MusicFree version")
if not apk_path.is_file():
raise _missing_file_error(apk_path, "MusicFree APK")
try:
payload = json.loads(version_json_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=500,
detail=f"Invalid MusicFree version JSON: {exc}",
) from exc
payload["download"] = [str(request.url_for("musicfree_app_apk"))]
return JSONResponse(
content=payload,
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
},
)
@router.get(
"/MusicFree_latest_release_universal.apk",
name="musicfree_app_apk",
)
def musicfree_app_apk() -> FileResponse:
apk_path = Path(get_settings().musicfree_apk_path)
if not apk_path.is_file():
raise _missing_file_error(apk_path, "MusicFree APK")
return FileResponse(
apk_path,
media_type=_APK_MEDIA_TYPE,
filename=apk_path.name,
)
@@ -0,0 +1,39 @@
import sqlite3
from fastapi import APIRouter, Header
from ..auth import parse_bearer_token
from ..services.catalog_reader import CatalogReader
from ..services.token_service import TokenService
from ..settings import get_settings
router = APIRouter(prefix="/auth/v1")
@router.get("/token-status")
def token_status(
authorization: str | None = Header(default=None),
x_music_client_id: str | None = Header(default=None, alias="X-Music-Client-Id"),
x_music_client_label: str | None = Header(default=None, alias="X-Music-Client-Label"),
) -> dict:
settings = get_settings()
if settings.disable_auth:
payload = {
"valid": True,
"status": "active",
"source": "auth_disabled",
"expires_at": None,
}
else:
token = parse_bearer_token(authorization)
payload = TokenService(settings.player_db_path).status(
plaintext_token=token,
client_id=x_music_client_id,
client_label=x_music_client_label,
)
if payload["status"] == "active":
try:
payload["playableSongCount"] = CatalogReader(settings.catalog_db_path).count_playable_tracks()
except sqlite3.Error:
payload["playableSongCount"] = None
return payload
@@ -0,0 +1,21 @@
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
from ..services.cover_service import CoverService
from ..settings import get_settings
router = APIRouter(prefix="/mf/v1/covers")
def _service() -> CoverService:
return CoverService(db_path=get_settings().catalog_db_path)
@router.get("/playlists/{playlist_id}")
def playlist_cover(playlist_id: int) -> RedirectResponse:
return RedirectResponse(url=_service().playlist_cover_url(playlist_id), status_code=307)
@router.get("/songs/{song_id}")
def song_cover(song_id: int) -> RedirectResponse:
return RedirectResponse(url=_service().song_cover_url(song_id), status_code=307)
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/healthz")
def healthz() -> dict:
return {"status": "ok"}
@@ -0,0 +1,291 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import require_bearer_token
from ..services.catalog_reader import CatalogReader
from ..settings import get_settings
router = APIRouter(prefix="/mf/v1", dependencies=[Depends(require_bearer_token)])
def _reader() -> CatalogReader:
return CatalogReader(db_path=get_settings().catalog_db_path)
def _read_raw_lrc(local_locator: str | None) -> str | None:
library_root = get_settings().local_library_root
if not library_root or not local_locator:
return None
root_path = Path(library_root).resolve()
audio_path = (root_path / local_locator).resolve()
try:
audio_path.relative_to(root_path)
except ValueError:
return None
lyrics_path = audio_path.with_suffix(".lrc")
try:
lyrics_path.relative_to(root_path)
except ValueError:
return None
if not lyrics_path.is_file():
return None
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
return lyrics_path.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
except OSError:
return None
try:
return lyrics_path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return None
def _to_sheet_item(row: dict) -> dict:
if "playlist_id" in row:
item_type = "playlist"
item_id = row["playlist_id"]
elif row.get("item_type") == "playlist":
item_type = "playlist"
item_id = row["item_id"]
else:
item_type = "toplist"
item_id = row.get("toplist_id") or row["item_id"]
return {
"id": f"catalogsync:{item_type}:{item_id}",
"platform": "catalogsync",
"title": row["name"],
"coverImg": row["cover_url"] or "",
"description": row["description"] or "",
"worksNum": row["song_count"],
"playableSongCount": row.get("playable_song_count", 0),
"play_count": row["play_count"],
}
def _to_music_item(row: dict) -> dict:
duration_ms = int(row.get("duration_ms") or 0)
item = {
"id": f"catalogsync:song:{row['song_id']}",
"platform": "catalogsync",
"title": row["name"],
"artist": row.get("singers") or "",
"album": row.get("album") or "",
"artwork": row.get("cover_url") or "",
"duration": duration_ms // 1000,
}
raw_lrc = _read_raw_lrc(row.get("local_locator"))
if raw_lrc:
item["rawLrc"] = raw_lrc
return item
def _to_artist_item(row: dict) -> dict:
return {
"id": f"catalogsync:artist:{row['artist_id']}",
"platform": row["platform"],
"name": row["name"],
"avatar": row.get("avatar_url") or "",
"description": row.get("description") or "",
"worksNum": row.get("playable_song_count", 0),
"supportedArtistTabs": ["music"],
}
def _playlist_platform_filter_from_tag(tag: str) -> str | None:
normalized_tag = str(tag or "").strip().lower()
if normalized_tag in {"", "all", "playlist_square"}:
return None
return normalized_tag
@router.get("/recommend/tags")
def recommend_tags() -> dict:
return {
"pinned": [
{"id": "all", "title": "all"},
{"id": "netease", "title": "netease"},
{"id": "qq", "title": "qq"},
{"id": "kuwo", "title": "kuwo"},
],
"data": [
{
"title": "source",
"data": [
{"id": "playlist_square", "title": "playlist square"},
{"id": "toplist", "title": "toplist"},
],
}
],
}
@router.get("/recommend/sheets")
def recommend_sheets(
tag: str = Query(default="all"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=60, ge=1, le=200),
) -> dict:
normalized_tag = str(tag or "").strip().lower()
reader = _reader()
if normalized_tag == "toplist":
toplist_groups = reader.list_toplists()
all_toplist_rows = [row for group in toplist_groups for row in group["data"]]
offset = (page - 1) * page_size
rows = all_toplist_rows[offset : offset + page_size]
return {
"isEnd": offset + len(rows) >= len(all_toplist_rows),
"data": [_to_sheet_item(row) for row in rows],
}
rows = reader.list_playlists(
page=page,
page_size=page_size,
platform=_playlist_platform_filter_from_tag(tag),
)
is_end = len(rows) < page_size
return {
"isEnd": is_end,
"data": [_to_sheet_item(row) for row in rows],
}
@router.get("/search/songs")
def search_songs(
q: str = Query(default=""),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
) -> dict:
rows = _reader().search_tracks(query=q, page=page, page_size=page_size)
return {"isEnd": len(rows) < page_size, "data": [_to_music_item(row) for row in rows]}
@router.get("/songs/{song_id}/lyric")
def get_song_lyric(song_id: int) -> dict:
row = _reader().get_song(song_id=song_id)
if row is None:
raise HTTPException(status_code=404, detail="song not found")
raw_lrc = _read_raw_lrc(row.get("local_locator"))
if not raw_lrc:
raise HTTPException(status_code=404, detail="lyric not found")
return {"rawLrc": raw_lrc, "lyric": raw_lrc}
@router.get("/search/artists")
def search_artists(
q: str = Query(default=""),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
) -> dict:
rows = _reader().search_artists(query=q, page=page, page_size=page_size)
return {"isEnd": len(rows) < page_size, "data": [_to_artist_item(row) for row in rows]}
@router.get("/artists/{artist_id}")
def get_artist(artist_id: int) -> dict:
row = _reader().get_artist(artist_id=artist_id)
if row is None:
raise HTTPException(status_code=404, detail="artist not found")
return _to_artist_item(row)
@router.get("/artists/{artist_id}/tracks")
def list_artist_tracks(
artist_id: int,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=60, ge=1, le=200),
) -> dict:
rows = _reader().list_artist_tracks(artist_id=artist_id, page=page, page_size=page_size)
return {"isEnd": len(rows) < page_size, "musicList": [_to_music_item(row) for row in rows]}
@router.get("/search/sheets")
def search_sheets(
q: str = Query(default=""),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
) -> dict:
rows = _reader().search_sheets(query=q, page=page, page_size=page_size)
return {"isEnd": len(rows) < page_size, "data": [_to_sheet_item(row) for row in rows]}
@router.get("/playlists/{playlist_id}")
def get_playlist(playlist_id: int) -> dict:
row = _reader().get_playlist(playlist_id=playlist_id)
if row is None:
raise HTTPException(status_code=404, detail="playlist not found")
return _to_sheet_item(row)
@router.get("/playlists/{playlist_id}/tracks")
def list_playlist_tracks(
playlist_id: int,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=60, ge=1, le=200),
) -> dict:
rows = _reader().list_playlist_tracks(
playlist_id=playlist_id,
page=page,
page_size=page_size,
)
return {
"isEnd": len(rows) < page_size,
"musicList": [_to_music_item(row) for row in rows],
}
@router.get("/toplists")
def list_toplists() -> list[dict]:
groups = _reader().list_toplists()
return [
{"title": group["title"], "data": [_to_sheet_item(row) for row in group["data"]]}
for group in groups
]
@router.get("/toplists/{toplist_id}")
def get_toplist(toplist_id: str) -> dict:
row = _reader().get_toplist(toplist_id=toplist_id)
if row is None:
raise HTTPException(status_code=404, detail="toplist not found")
return _to_sheet_item(row)
@router.get("/toplists/{toplist_id}/tracks")
def list_toplist_tracks(
toplist_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=60, ge=1, le=200),
) -> dict:
reader = _reader()
toplist = reader.get_toplist(toplist_id=toplist_id)
if toplist is None:
raise HTTPException(status_code=404, detail="toplist not found")
rows = reader.list_toplist_tracks(
toplist_id=toplist_id,
page=page,
page_size=page_size,
)
if len(rows) < page_size:
is_end = True
else:
next_rows = reader.list_toplist_tracks(
toplist_id=toplist_id,
page=page + 1,
page_size=page_size,
)
is_end = len(next_rows) == 0
return {
"isEnd": is_end,
"musicList": [_to_music_item(row) for row in rows],
}
@@ -0,0 +1,243 @@
import os
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, Response, StreamingResponse
from ..auth import require_bearer_token
from ..services.cache_service import CacheService
from ..services.media_resolver import MediaResolver
from ..services.local_streaming import (
RangeNotSatisfiable,
guess_audio_media_type,
iter_file_range,
parse_single_range,
)
from ..services.stream_tokens import create_stream_token, parse_stream_token
from ..settings import get_settings
router = APIRouter(prefix="/mf/v1", dependencies=[Depends(require_bearer_token)])
stream_router = APIRouter(prefix="/mf/v1")
_CACHE_URL_PROBE_TIMEOUT_SECONDS = 2.0
def _song_id_from_public_id(public_song_id: str) -> int:
parts = str(public_song_id).split(":")
if len(parts) < 3 or parts[0] != "catalogsync" or parts[1] != "song":
raise HTTPException(status_code=400, detail="invalid song id")
try:
return int(parts[2])
except ValueError as exc:
raise HTTPException(status_code=400, detail="invalid song id") from exc
def _resolve_local_stream_path(locator: str) -> Path:
library_root = os.getenv("LOCAL_LIBRARY_ROOT")
if not library_root:
raise HTTPException(status_code=404, detail="local stream root not configured")
root_path = Path(library_root).resolve()
file_path = (root_path / locator).resolve()
try:
file_path.relative_to(root_path)
except ValueError as exc:
raise HTTPException(status_code=404, detail="local stream file not found") from exc
if not file_path.is_file():
raise HTTPException(status_code=404, detail="local stream file not found")
return file_path
def _cache_service(settings) -> CacheService:
return CacheService(
player_db_path=settings.player_db_path,
catalog_db_path=settings.catalog_db_path,
secret_encryption_key=settings.secret_encryption_key,
local_library_root=settings.local_library_root,
cache_relay_enabled=settings.cache_relay_enabled,
)
def _is_cache_url_reachable(public_url: str) -> bool:
try:
response = httpx.head(
public_url,
follow_redirects=True,
timeout=_CACHE_URL_PROBE_TIMEOUT_SECONDS,
)
except httpx.HTTPError:
return False
if response.status_code in {200, 206}:
return True
if response.status_code not in {405, 501}:
return False
try:
with httpx.stream(
"GET",
public_url,
headers={"Range": "bytes=0-0"},
follow_redirects=True,
timeout=_CACHE_URL_PROBE_TIMEOUT_SECONDS,
) as fallback_response:
return fallback_response.status_code in {200, 206}
except httpx.HTTPError:
return False
def _selected_source_payload(*, resolved: dict, quality: str, size_bytes: int | None = None) -> dict:
ext = resolved.get("ext")
if not ext:
locator = str(resolved.get("source_locator") or resolved.get("remote_key") or resolved.get("locator") or "")
ext = Path(locator).suffix.lstrip(".") or None
return {
"kind": resolved.get("backend_type") or resolved.get("kind"),
"backend": resolved.get("backend_name") or resolved.get("target_name"),
"quality": resolved.get("quality_label") or quality,
"ext": ext,
"size_bytes": size_bytes,
}
def _build_stream_url(*, token: str, resolved: dict) -> str:
ext = resolved.get("ext")
if not ext:
locator = str(resolved.get("source_locator") or resolved.get("remote_key") or resolved.get("locator") or "")
ext = Path(locator).suffix.lstrip(".") or None
if ext:
return f"/mf/v1/media/stream/{token}.{ext}"
return f"/mf/v1/media/stream/{token}"
@router.post("/media/resolve")
def resolve_media(payload: dict) -> dict:
settings = get_settings()
public_song_id = payload.get("song_id")
if not public_song_id:
raise HTTPException(status_code=400, detail="song_id is required")
quality = str(payload.get("quality", "standard"))
song_id = _song_id_from_public_id(str(public_song_id))
resolver = MediaResolver(db_path=settings.catalog_db_path)
fallback_source = None
try:
fallback_source = resolver.resolve(
song_id=song_id,
quality=quality,
)
except LookupError as exc:
fallback_error = exc
else:
fallback_error = None
cached_source = _cache_service(settings).resolve_cached_source(song_id=song_id)
if fallback_source is None and cached_source is None:
raise HTTPException(status_code=404, detail=str(fallback_error or "no playable source found"))
token_locator = ""
if fallback_source is not None:
token_locator = str(fallback_source["locator"])
elif cached_source is not None:
token_locator = str(cached_source.get("source_locator") or "")
token = create_stream_token(
secret=settings.access_token,
song_id=song_id,
locator=token_locator,
)
selected_source = cached_source or fallback_source or {}
selected_size = None
if fallback_source is not None:
selected_size = fallback_source.get("file_size_bytes")
return {
"song_id": public_song_id,
"selected_source": _selected_source_payload(
resolved=selected_source,
quality=quality,
size_bytes=selected_size,
),
"stream": {
"url": _build_stream_url(token=token, resolved=selected_source),
"headers": {},
"range_supported": True,
},
}
@stream_router.get("/media/stream/{token}")
@stream_router.get("/media/stream/{token}.{ext}")
def stream_media(token: str, request: Request, ext: str | None = None):
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
song_id = int(parsed["song_id"])
cache_service = _cache_service(settings)
cached_source = cache_service.resolve_cached_source(song_id=song_id)
if cached_source is not None and _is_cache_url_reachable(str(cached_source["public_url"])):
cache_service.record_stream_play(song_id=song_id, stream_token=token)
return RedirectResponse(url=str(cached_source["public_url"]), status_code=307)
try:
resolved = MediaResolver(db_path=settings.catalog_db_path).resolve_by_locator(
song_id=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)
headers = {"Accept-Ranges": "bytes"}
range_header = request.headers.get("range")
try:
byte_range = parse_single_range(range_header, file_size)
except RangeNotSatisfiable:
return Response(
status_code=416,
headers={
**headers,
"Content-Range": f"bytes */{file_size}",
},
)
if byte_range is None:
if file_size == 0:
body_iter = iter(())
else:
body_iter = iter_file_range(file_path=file_path, start=0, end=file_size - 1)
cache_service.record_stream_play(song_id=song_id, stream_token=token)
return StreamingResponse(
body_iter,
media_type=media_type,
headers={
**headers,
"Content-Length": str(file_size),
},
)
start, end = byte_range
cache_service.record_stream_play(song_id=song_id, stream_token=token)
return StreamingResponse(
iter_file_range(file_path=file_path, start=start, end=end),
media_type=media_type,
status_code=206,
headers={
**headers,
"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")
cache_service.record_stream_play(song_id=song_id, stream_token=token)
return RedirectResponse(url=str(public_url), status_code=307)
@@ -0,0 +1,73 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
from ..auth import require_bearer_token
from ..services.catalog_reader import CatalogReader
from ..services.player_service import PlayerService
from ..settings import get_settings
router = APIRouter(prefix="/player/v1", dependencies=[Depends(require_bearer_token)])
def _player_service() -> PlayerService:
return PlayerService(db_path=get_settings().player_db_path)
def _catalog_reader() -> CatalogReader:
return CatalogReader(db_path=get_settings().catalog_db_path)
@router.get("/home")
def home() -> dict:
return {
"recommend_playlists": _catalog_reader().list_playlists(page=1, page_size=12),
"favorite_playlists": _player_service().list_favorite_playlists(),
"recent_history": _player_service().list_history(),
}
@router.put("/me/favorites/tracks/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
def add_favorite_track(track_id: int) -> Response:
_player_service().add_favorite_track(track_id=track_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me/favorites/tracks")
def list_favorite_tracks() -> dict:
return {"items": _player_service().list_favorite_tracks()}
@router.put("/me/favorites/playlists/{playlist_id}", status_code=status.HTTP_204_NO_CONTENT)
def add_favorite_playlist(playlist_id: int) -> Response:
_player_service().add_favorite_playlist(playlist_id=playlist_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me/favorites/playlists")
def list_favorite_playlists() -> dict:
return {"items": _player_service().list_favorite_playlists()}
@router.post("/me/history", status_code=status.HTTP_201_CREATED)
def record_history(payload: dict) -> dict:
if "track_id" not in payload:
raise HTTPException(status_code=400, detail="track_id is required")
try:
track_id = int(payload["track_id"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="track_id must be a number") from exc
try:
progress_seconds = int(payload.get("progress_seconds", 0))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="progress_seconds must be a number") from exc
_player_service().record_history(
track_id=track_id,
progress_seconds=progress_seconds,
)
return {"status": "created"}
@router.get("/me/history")
def list_history() -> dict:
return {"items": _player_service().list_history()}
@@ -0,0 +1,75 @@
from pathlib import Path
from fastapi import APIRouter, Request, Response
router = APIRouter(prefix="/plugins")
_SRC_URL_PLACEHOLDER = "__MUSIC_SERVER_PLUGIN_SRC_URL__"
_ASSET_ROOT = Path(__file__).resolve().parent.parent / "plugin_assets"
_PLUGIN_ASSETS = {
"music_server": {
"name": "Music_Server",
"asset_path": _ASSET_ROOT / "music_server.js",
"route_name": "music_server_plugin_js",
},
"music_server_lan": {
"name": "Music_Server LAN",
"asset_path": _ASSET_ROOT / "music_server_lan.js",
"route_name": "music_server_lan_plugin_js",
},
}
def _plugin_src_url(request: Request, asset_key: str) -> str:
plugin_asset = _PLUGIN_ASSETS[asset_key]
return str(request.url_for(plugin_asset["route_name"]))
def _plugin_js_text(request: Request, asset_key: str) -> str:
plugin_asset = _PLUGIN_ASSETS[asset_key]
raw = plugin_asset["asset_path"].read_text(encoding="utf-8")
return raw.replace(_SRC_URL_PLACEHOLDER, _plugin_src_url(request, asset_key))
@router.get("/music_server.js", name="music_server_plugin_js")
def music_server_plugin_js(request: Request) -> Response:
body = _plugin_js_text(request, "music_server")
return Response(
content=body,
media_type="application/javascript",
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
},
)
@router.get("/music_server_lan.js", name="music_server_lan_plugin_js")
def music_server_lan_plugin_js(request: Request) -> Response:
body = _plugin_js_text(request, "music_server_lan")
return Response(
content=body,
media_type="application/javascript",
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
},
)
@router.get("/music_server.json", name="music_server_plugin_manifest")
def music_server_plugin_manifest(request: Request) -> dict:
return {
"plugins": [
{
"name": _PLUGIN_ASSETS["music_server"]["name"],
"url": _plugin_src_url(request, "music_server"),
},
{
"name": _PLUGIN_ASSETS["music_server_lan"]["name"],
"url": _plugin_src_url(request, "music_server_lan"),
},
]
}
@@ -0,0 +1,15 @@
from __future__ import annotations
import hashlib
import hmac
def verify_password_hash(*, plaintext_password: str, stored_hash: str) -> bool:
normalized = (stored_hash or "").strip()
if not normalized:
return False
if normalized.startswith("sha256$"):
expected = normalized.split("$", 1)[1]
actual = hashlib.sha256(plaintext_password.encode("utf-8")).hexdigest()
return hmac.compare_digest(actual, expected)
return hmac.compare_digest(plaintext_password, normalized)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
from __future__ import annotations
from contextlib import closing
import mimetypes
from pathlib import Path, PurePosixPath
from typing import Any
import paramiko
def _build_boto3_client(**kwargs):
import boto3
return boto3.client("s3", **kwargs)
def _guess_content_type(path: Path) -> str | None:
guessed, _ = mimetypes.guess_type(str(path))
return guessed
class SFTPCacheTargetUploader:
def __init__(
self,
*,
host: str,
port: int = 22,
username: str,
password: str | None = None,
private_key: str | None = None,
timeout_seconds: int = 10,
remote_root: str | None = None,
) -> None:
self._host = host
self._port = port
self._username = username
self._password = password
self._private_key = private_key
self._timeout_seconds = timeout_seconds
self._remote_root = (remote_root or "").strip().rstrip("/")
def _connect(self):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs: dict[str, Any] = {
"hostname": self._host,
"port": self._port,
"username": self._username,
"timeout": self._timeout_seconds,
}
if self._private_key:
kwargs["key_filename"] = self._private_key
elif self._password:
kwargs["password"] = self._password
client.connect(**kwargs)
sftp = client.open_sftp()
return client, sftp
def _full_remote_path(self, remote_key: str) -> str:
normalized_key = remote_key.lstrip("/")
if self._remote_root:
return (PurePosixPath(self._remote_root) / normalized_key).as_posix()
return normalized_key
def _ensure_remote_dir(self, sftp, remote_path: str) -> None:
parent = PurePosixPath(remote_path).parent
if str(parent) in {"", "."}:
return
current = PurePosixPath("/") if parent.is_absolute() else PurePosixPath(".")
for part in parent.parts:
if part in {"", ".", "/"}:
continue
current = current / part
remote_path = current.as_posix()
try:
sftp.stat(remote_path)
except IOError:
sftp.mkdir(remote_path)
def upload_file(self, *, local_path: Path, remote_key: str) -> None:
client, sftp = self._connect()
try:
remote_path = self._full_remote_path(remote_key)
self._ensure_remote_dir(sftp, remote_path)
sftp.put(str(local_path), remote_path)
finally:
sftp.close()
client.close()
def delete_file(self, *, remote_key: str) -> None:
client, sftp = self._connect()
try:
sftp.remove(self._full_remote_path(remote_key))
finally:
sftp.close()
client.close()
def test_connection(self) -> None:
client, sftp = self._connect()
try:
sftp.listdir(self._remote_root or ".")
finally:
sftp.close()
client.close()
class S3CacheTargetUploader:
def __init__(
self,
*,
bucket: str,
region: str | None = None,
endpoint_url: str | None = None,
access_key_id: str | None = None,
secret_access_key: str | None = None,
session_token: str | None = None,
) -> None:
self._bucket = bucket
self._region = region
self._endpoint_url = endpoint_url
self._access_key_id = access_key_id
self._secret_access_key = secret_access_key
self._session_token = session_token
def _client(self):
return _build_boto3_client(
region_name=self._region,
endpoint_url=self._endpoint_url,
aws_access_key_id=self._access_key_id,
aws_secret_access_key=self._secret_access_key,
aws_session_token=self._session_token,
)
def upload_file(self, *, local_path: Path, remote_key: str) -> None:
client = self._client()
extra_args = {}
content_type = _guess_content_type(local_path)
if content_type:
extra_args["ContentType"] = content_type
client.upload_file(
str(local_path),
self._bucket,
remote_key,
ExtraArgs=extra_args or None,
)
def delete_file(self, *, remote_key: str) -> None:
client = self._client()
client.delete_object(Bucket=self._bucket, Key=remote_key)
def test_connection(self) -> None:
client = self._client()
client.head_bucket(Bucket=self._bucket)
@@ -0,0 +1,595 @@
from contextlib import closing
from typing import TypedDict, cast
from ..db import connect_sqlite
class PlaylistRow(TypedDict):
playlist_id: int
platform: str
remote_playlist_id: str
name: str
description: str | None
cover_url: str | None
play_count: int
song_count: int
playable_song_count: int
class PlaylistTrackRow(TypedDict):
song_id: int
name: str
singers: str | None
album: str | None
cover_url: str | None
duration_ms: int
local_locator: str | None
class SearchTrackRow(TypedDict):
song_id: int
name: str
singers: str | None
album: str | None
cover_url: str | None
duration_ms: int
local_locator: str | None
class SongRow(TypedDict):
song_id: int
name: str
singers: str | None
album: str | None
cover_url: str | None
duration_ms: int
local_locator: str | None
class SheetSearchRow(TypedDict):
item_type: str
item_id: str
platform: str
name: str
description: str | None
cover_url: str | None
play_count: int
song_count: int
playable_song_count: int
class ArtistRow(TypedDict):
artist_id: int
artist_key: str
platform: str
remote_artist_id: str | None
name: str
normalized_name: str
avatar_url: str | None
description: str | None
playable_song_count: int
class ToplistRow(TypedDict):
toplist_id: str
platform: str
name: str
description: str | None
cover_url: str | None
play_count: int
song_count: int
playable_song_count: int
group_name: str
class CatalogReader:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
def _normalize_pagination(self, page: int, page_size: int) -> tuple[int, int]:
normalized_page = page if page > 0 else 1
normalized_page_size = page_size if page_size > 0 else 1
return normalized_page, normalized_page_size
def _escape_like_term(self, term: str) -> str:
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def count_playable_tracks(self) -> int:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select count(distinct song_id) as playable_song_count
from catalog_track_files
where status = 'active'
"""
).fetchone()
if row is None:
return 0
value = row["playable_song_count"] if "playable_song_count" in row.keys() else row[0]
return int(value or 0)
def list_playlists(
self,
page: int,
page_size: int,
platform: str | None = None,
) -> list[PlaylistRow]:
page, page_size = self._normalize_pagination(page, page_size)
offset = (page - 1) * page_size
normalized_platform = str(platform or "").strip().lower()
supported_platforms = {"netease", "qq", "kuwo"}
with closing(connect_sqlite(self._db_path)) as conn:
if normalized_platform in supported_platforms:
rows = conn.execute(
"""
select
playlist_id,
platform,
remote_playlist_id,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count
from catalog_playlists
where song_count > 0
and lower(platform) = ?
order by play_count desc, playlist_id asc
limit ? offset ?
""",
(normalized_platform, page_size, offset),
).fetchall()
elif normalized_platform:
rows = []
else:
rows = conn.execute(
"""
select
playlist_id,
platform,
remote_playlist_id,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count
from catalog_playlists
where song_count > 0
order by play_count desc, playlist_id asc
limit ? offset ?
""",
(page_size, offset),
).fetchall()
return [cast(PlaylistRow, dict(row)) for row in rows]
def get_playlist(self, playlist_id: int) -> PlaylistRow | None:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select
playlist_id,
platform,
remote_playlist_id,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count
from catalog_playlists
where playlist_id = ?
""",
(playlist_id,),
).fetchone()
return cast(PlaylistRow, dict(row)) if row else None
def list_playlist_tracks(
self, playlist_id: int, page: int, page_size: int
) -> list[PlaylistTrackRow]:
page, page_size = self._normalize_pagination(page, page_size)
offset = (page - 1) * page_size
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,
(
select f.locator
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
and f.backend_type = 'local_fs'
order by f.is_primary desc, f.locator asc
limit 1
) as local_locator
from catalog_playlist_tracks pt
join catalog_tracks t on t.song_id = pt.song_id
where pt.playlist_id = ?
and exists (
select 1
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
)
order by pt.position asc, pt.song_id asc
limit ? offset ?
""",
(playlist_id, page_size, offset),
).fetchall()
return [cast(PlaylistTrackRow, dict(row)) for row in rows]
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()
escaped_query = self._escape_like_term(exact_query)
prefix_query = f"{escaped_query}%"
like_query = f"%{escaped_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,
(
select f.locator
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
and f.backend_type = 'local_fs'
order by f.is_primary desc, f.locator asc
limit 1
) as local_locator
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 ? escape '\\'
or lower(coalesce(t.singers, '')) like ? escape '\\'
)
order by
case
when lower(t.name) = ? then 0
when lower(t.name) like ? escape '\\' then 1
when lower(t.name) like ? escape '\\' then 2
when lower(coalesce(t.singers, '')) like ? escape '\\' 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]
def get_song(self, song_id: int) -> SongRow | None:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select
t.song_id,
t.name,
t.singers,
t.album,
t.cover_url,
t.duration_ms,
(
select f.locator
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
and f.backend_type = 'local_fs'
order by f.is_primary desc, f.locator asc
limit 1
) as local_locator
from catalog_tracks t
where t.song_id = ?
and exists (
select 1
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
)
""",
(song_id,),
).fetchone()
return cast(SongRow, dict(row)) if row else None
def search_sheets(self, query: str, page: int, page_size: int) -> list[SheetSearchRow]:
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()
escaped_query = self._escape_like_term(exact_query)
prefix_query = f"{escaped_query}%"
like_query = f"%{escaped_query}%"
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(
"""
select *
from (
select
'playlist' as item_type,
cast(playlist_id as text) as item_id,
platform,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count
from catalog_playlists
where playable_song_count > 0
and lower(name) like ? escape '\\'
union all
select
'toplist' as item_type,
toplist_id as item_id,
platform,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count
from catalog_toplists
where playable_song_count > 0
and lower(name) like ? escape '\\'
) sheets
order by
case
when lower(name) = ? then 0
when lower(name) like ? escape '\\' then 1
when lower(name) like ? escape '\\' then 2
else 9
end,
play_count desc,
case when item_type = 'playlist' then 0 else 1 end,
item_id asc
limit ? offset ?
""",
(
like_query,
like_query,
exact_query,
prefix_query,
like_query,
page_size,
offset,
),
).fetchall()
return [cast(SheetSearchRow, dict(row)) for row in rows]
def search_artists(self, query: str, page: int, page_size: int) -> list[ArtistRow]:
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()
escaped_query = self._escape_like_term(exact_query)
prefix_query = f"{escaped_query}%"
like_query = f"%{escaped_query}%"
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(
"""
select
artist_id,
artist_key,
platform,
remote_artist_id,
name,
normalized_name,
avatar_url,
description,
playable_song_count
from catalog_artists
where playable_song_count > 0
and lower(name) like ? escape '\\'
order by
case
when lower(name) = ? then 0
when lower(name) like ? escape '\\' then 1
when lower(name) like ? escape '\\' then 2
else 9
end,
playable_song_count desc,
lower(name) asc,
artist_id asc
limit ? offset ?
""",
(
like_query,
exact_query,
prefix_query,
like_query,
page_size,
offset,
),
).fetchall()
return [cast(ArtistRow, dict(row)) for row in rows]
def get_artist(self, artist_id: int) -> ArtistRow | None:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select
artist_id,
artist_key,
platform,
remote_artist_id,
name,
normalized_name,
avatar_url,
description,
playable_song_count
from catalog_artists
where artist_id = ?
""",
(artist_id,),
).fetchone()
return cast(ArtistRow, dict(row)) if row else None
def list_artist_tracks(
self, artist_id: int, page: int, page_size: int
) -> list[PlaylistTrackRow]:
page, page_size = self._normalize_pagination(page, page_size)
offset = (page - 1) * page_size
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,
(
select f.locator
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
and f.backend_type = 'local_fs'
order by f.is_primary desc, f.locator asc
limit 1
) as local_locator
from catalog_artist_tracks at
join catalog_tracks t on t.song_id = at.song_id
where at.artist_id = ?
and exists (
select 1
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
)
order by at.position asc, t.song_id asc
limit ? offset ?
""",
(artist_id, page_size, offset),
).fetchall()
return [cast(PlaylistTrackRow, dict(row)) for row in rows]
def list_toplists(self) -> list[dict]:
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(
"""
select
toplist_id,
platform,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count,
group_name
from catalog_toplists
order by group_name asc, play_count desc, name asc
"""
).fetchall()
grouped: dict[str, list[ToplistRow]] = {}
for row in rows:
data = cast(ToplistRow, dict(row))
grouped.setdefault(data["group_name"], []).append(data)
return [{"title": group_name, "data": data} for group_name, data in grouped.items()]
def get_toplist(self, toplist_id: str) -> ToplistRow | None:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select
toplist_id,
platform,
name,
description,
cover_url,
play_count,
song_count,
playable_song_count,
group_name
from catalog_toplists
where toplist_id = ?
""",
(toplist_id,),
).fetchone()
return cast(ToplistRow, dict(row)) if row else None
def list_toplist_tracks(
self, toplist_id: str, page: int, page_size: int
) -> list[PlaylistTrackRow]:
page, page_size = self._normalize_pagination(page, page_size)
offset = (page - 1) * page_size
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,
(
select f.locator
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
and f.backend_type = 'local_fs'
order by f.is_primary desc, f.locator asc
limit 1
) as local_locator
from catalog_toplist_tracks tt
join catalog_tracks t on t.song_id = tt.song_id
where tt.toplist_id = ?
and exists (
select 1
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
)
order by tt.position asc, tt.song_id asc
limit ? offset ?
""",
(toplist_id, page_size, offset),
).fetchall()
return [cast(PlaylistTrackRow, dict(row)) for row in rows]
@@ -0,0 +1,30 @@
from contextlib import closing
from fastapi import HTTPException
from ..db import connect_sqlite
class CoverService:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
def playlist_cover_url(self, playlist_id: int) -> str:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"select cover_url from catalog_playlists where playlist_id = ?",
(playlist_id,),
).fetchone()
if row is None or not row["cover_url"]:
raise HTTPException(status_code=404, detail="playlist cover not found")
return str(row["cover_url"])
def song_cover_url(self, song_id: int) -> str:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"select cover_url from catalog_tracks where song_id = ?",
(song_id,),
).fetchone()
if row is None or not row["cover_url"]:
raise HTTPException(status_code=404, detail="song cover not found")
return str(row["cover_url"])
@@ -0,0 +1,94 @@
from os import PathLike
from pathlib import Path
from typing import Iterator
class RangeNotSatisfiable(ValueError):
pass
_AUDIO_MEDIA_TYPES = {
"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 | PathLike[str]) -> str:
suffix = Path(path_like).suffix.lower().lstrip(".")
return _AUDIO_MEDIA_TYPES.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
unit, sep, raw_range = range_header.strip().partition("=")
if sep != "=" or unit.strip().lower() != "bytes":
raise RangeNotSatisfiable("only bytes ranges are supported")
range_spec = raw_range.strip()
if "," in range_spec:
raise RangeNotSatisfiable("multiple ranges are not supported")
start_text, dash, end_text = range_spec.partition("-")
if dash != "-":
raise RangeNotSatisfiable("invalid range")
start_text = start_text.strip()
end_text = end_text.strip()
if not start_text:
if not end_text.isdigit():
raise RangeNotSatisfiable("invalid suffix range")
suffix_length = int(end_text)
if suffix_length <= 0 or file_size <= 0:
raise RangeNotSatisfiable("invalid suffix range")
start = max(file_size - suffix_length, 0)
return (start, file_size - 1)
if not start_text.isdigit():
raise RangeNotSatisfiable("invalid range start")
start = int(start_text)
if not end_text:
if start >= file_size:
raise RangeNotSatisfiable("range out of bounds")
return (start, file_size - 1)
if not end_text.isdigit():
raise RangeNotSatisfiable("invalid range end")
end = int(end_text)
if end < start:
raise RangeNotSatisfiable("range start exceeds end")
if start >= file_size:
raise RangeNotSatisfiable("range out of bounds")
if end >= file_size:
end = file_size - 1
return (start, end)
def iter_file_range(
file_path: str | PathLike[str],
start: int,
end: int,
chunk_size: int = 64 * 1024,
) -> Iterator[bytes]:
if chunk_size <= 0:
raise ValueError("chunk_size must be positive")
if start < 0 or end < start:
raise ValueError("invalid byte range")
remaining = end - start + 1
with Path(file_path).open("rb") as file_obj:
file_obj.seek(start)
while remaining > 0:
chunk = file_obj.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
@@ -0,0 +1,40 @@
from contextlib import closing
from ..db import connect_sqlite
class MediaResolver:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
def resolve(self, song_id: int, quality: str) -> dict:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url
from catalog_track_files
where song_id = ? and status = 'active'
order by case when quality_label = ? then 0 else 1 end, is_primary desc
limit 1
""",
(song_id, quality),
).fetchone()
if row is None:
raise LookupError("no playable source found")
return dict(row)
def resolve_by_locator(self, song_id: int, locator: str) -> dict:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url
from catalog_track_files
where song_id = ? and locator = ? and status = 'active'
order by is_primary desc
limit 1
""",
(song_id, locator),
).fetchone()
if row is None:
raise LookupError("no playable source found")
return dict(row)
@@ -0,0 +1,127 @@
from contextlib import closing
from datetime import datetime, timezone
from typing import TypedDict, cast
from ..db import connect_sqlite
class FavoriteTrackItem(TypedDict):
track_id: int
class FavoritePlaylistItem(TypedDict):
playlist_id: int
class PlayHistoryItem(TypedDict):
track_id: int
played_at: str
progress_seconds: int
class PlayerService:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
self._ensure_schema()
def _ensure_schema(self) -> None:
with closing(connect_sqlite(self._db_path)) as conn:
conn.execute(
"""
create table if not exists favorite_tracks (
track_id integer primary key,
added_at text not null
)
"""
)
conn.execute(
"""
create table if not exists favorite_playlists (
playlist_id integer primary key,
added_at text not null
)
"""
)
conn.execute(
"""
create table if not exists play_history (
id integer primary key autoincrement,
track_id integer not null,
played_at text not null,
progress_seconds integer not null default 0
)
"""
)
conn.commit()
def add_favorite_track(self, track_id: int) -> None:
added_at = datetime.now(timezone.utc).isoformat()
with closing(connect_sqlite(self._db_path)) as conn:
conn.execute(
"""
insert into favorite_tracks (track_id, added_at)
values (?, ?)
on conflict(track_id) do update set added_at = excluded.added_at
""",
(track_id, added_at),
)
conn.commit()
def list_favorite_tracks(self) -> list[FavoriteTrackItem]:
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(
"""
select track_id
from favorite_tracks
order by added_at desc
"""
).fetchall()
return [cast(FavoriteTrackItem, dict(row)) for row in rows]
def add_favorite_playlist(self, playlist_id: int) -> None:
added_at = datetime.now(timezone.utc).isoformat()
with closing(connect_sqlite(self._db_path)) as conn:
conn.execute(
"""
insert into favorite_playlists (playlist_id, added_at)
values (?, ?)
on conflict(playlist_id) do update set added_at = excluded.added_at
""",
(playlist_id, added_at),
)
conn.commit()
def list_favorite_playlists(self) -> list[FavoritePlaylistItem]:
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(
"""
select playlist_id
from favorite_playlists
order by added_at desc
"""
).fetchall()
return [cast(FavoritePlaylistItem, dict(row)) for row in rows]
def record_history(self, track_id: int, progress_seconds: int) -> None:
played_at = datetime.now(timezone.utc).isoformat()
with closing(connect_sqlite(self._db_path)) as conn:
conn.execute(
"""
insert into play_history (track_id, played_at, progress_seconds)
values (?, ?, ?)
""",
(track_id, played_at, progress_seconds),
)
conn.commit()
def list_history(self) -> list[PlayHistoryItem]:
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(
"""
select track_id, played_at, progress_seconds
from play_history
order by played_at desc, rowid desc
limit 100
"""
).fetchall()
return [cast(PlayHistoryItem, dict(row)) for row in rows]
@@ -0,0 +1,57 @@
import base64
import hashlib
import hmac
import json
import time
def _sign_payload(secret: str, payload_json: str) -> str:
return hmac.new(
secret.encode("utf-8"),
payload_json.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def create_stream_token(secret: str, song_id: int, locator: str, ttl_seconds: int = 300) -> str:
payload = {
"song_id": int(song_id),
"locator": str(locator),
"expires_at": int(time.time()) + int(ttl_seconds),
}
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signature = _sign_payload(secret=secret, payload_json=payload_json)
envelope = {"payload": payload, "sig": signature}
token = base64.urlsafe_b64encode(
json.dumps(envelope, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
)
return token.decode("ascii")
def parse_stream_token(secret: str, token: str) -> dict:
try:
padding = "=" * (-len(token) % 4)
raw = base64.urlsafe_b64decode((token + padding).encode("ascii"))
envelope = json.loads(raw.decode("utf-8"))
payload = envelope["payload"]
sig = str(envelope["sig"])
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
if not hmac.compare_digest(sig, _sign_payload(secret=secret, payload_json=payload_json)):
raise ValueError("invalid stream token")
song_id = int(payload["song_id"])
locator = str(payload["locator"])
expires_at = int(payload["expires_at"])
if not locator:
raise ValueError("invalid stream token")
if expires_at < int(time.time()):
raise ValueError("stream token expired")
return {
"song_id": song_id,
"locator": locator,
"expires_at": expires_at,
}
except ValueError:
raise
except Exception as exc:
raise ValueError("invalid stream token") from exc
@@ -0,0 +1,404 @@
from __future__ import annotations
from contextlib import closing
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import secrets
from typing import TypedDict
from ..db import connect_sqlite
@dataclass(frozen=True)
class IssuedToken:
token_id: str
plaintext_token: str
issued_at: str
expires_at: str
@dataclass(frozen=True)
class AuthResult:
valid: bool
error_code: str | None
token_id: str | None
bound_client_id: str | None
expires_at: str | None
class TokenStatus(TypedDict):
valid: bool
status: str
tokenId: str | None
label: str | None
issuedAt: str | None
expiresAt: str | None
remainingSeconds: int | None
remainingDays: int | None
playableSongCount: int | None
bound: bool
isCurrentClientBound: bool
boundClientLabel: str | None
class TokenService:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
self._ensure_schema()
def _ensure_schema(self) -> None:
with closing(connect_sqlite(self._db_path)) as conn:
conn.execute(
"""
create table if not exists 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
)
"""
)
conn.execute(
"create index if not exists idx_access_tokens_expires_at on access_tokens (expires_at)"
)
conn.execute(
"create index if not exists idx_access_tokens_bound_client_id on access_tokens (bound_client_id)"
)
conn.commit()
def issue_token(self, days: int = 90, label: str | None = None) -> IssuedToken:
issued_at = datetime.now(timezone.utc)
expires_at = issued_at + timedelta(days=days)
plaintext_token = f"msv1_{secrets.token_urlsafe(24)}"
token_id = f"tok_{secrets.token_hex(6)}"
token_hash = hashlib.sha256(plaintext_token.encode("utf-8")).hexdigest()
with closing(connect_sqlite(self._db_path)) as conn:
conn.execute(
"""
insert into access_tokens (
token_id, token_hash, label, issued_at, expires_at
) values (?, ?, ?, ?, ?)
""",
(
token_id,
token_hash,
label,
issued_at.isoformat(),
expires_at.isoformat(),
),
)
conn.commit()
return IssuedToken(
token_id=token_id,
plaintext_token=plaintext_token,
issued_at=issued_at.isoformat(),
expires_at=expires_at.isoformat(),
)
def _parse_datetime(self, value: str) -> datetime:
normalized = value.strip()
if normalized.endswith("Z"):
normalized = f"{normalized[:-1]}+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _is_expired(self, expires_at: str, now: datetime) -> bool:
return self._parse_datetime(expires_at) <= now
def _remaining(self, expires_at: str, now: datetime) -> tuple[int, int]:
remaining_seconds = max(int((self._parse_datetime(expires_at) - now).total_seconds()), 0)
remaining_days = remaining_seconds // 86400
return remaining_seconds, remaining_days
def _load_token_by_hash(self, conn, token_hash: str):
return conn.execute(
"""
select *
from access_tokens
where token_hash = ?
""",
(token_hash,),
).fetchone()
def _load_token_by_id(self, conn, token_id: str):
return conn.execute(
"""
select *
from access_tokens
where token_id = ?
""",
(token_id,),
).fetchone()
def _authenticate_row(
self,
conn,
row,
client_id: str,
client_label: str | None,
now: datetime,
) -> tuple[AuthResult, object | None]:
if row is None:
return AuthResult(False, "token_not_found", None, None, None), None
token_id = row["token_id"]
expires_at = row["expires_at"]
now_iso = now.isoformat()
if row["revoked_at"]:
return (
AuthResult(False, "token_revoked", token_id, row["bound_client_id"], expires_at),
row,
)
if self._is_expired(expires_at, now):
return (
AuthResult(False, "token_expired", token_id, row["bound_client_id"], expires_at),
row,
)
bound_client_id = row["bound_client_id"]
if bound_client_id and bound_client_id != client_id:
return (
AuthResult(False, "token_bound_to_other_client", token_id, bound_client_id, expires_at),
row,
)
def failure_from_final_state(fresh_row):
if fresh_row is None:
return AuthResult(False, "token_not_found", None, None, None)
if fresh_row["revoked_at"]:
return AuthResult(
False,
"token_revoked",
fresh_row["token_id"],
fresh_row["bound_client_id"],
fresh_row["expires_at"],
)
if self._is_expired(fresh_row["expires_at"], now):
return AuthResult(
False,
"token_expired",
fresh_row["token_id"],
fresh_row["bound_client_id"],
fresh_row["expires_at"],
)
final_bound_client_id = fresh_row["bound_client_id"]
if final_bound_client_id and final_bound_client_id != client_id:
return AuthResult(
False,
"token_bound_to_other_client",
fresh_row["token_id"],
final_bound_client_id,
fresh_row["expires_at"],
)
return None
if bound_client_id == client_id:
conn.execute(
"update access_tokens set last_seen_at = ?, bound_client_label = ? where token_id = ?",
(now_iso, client_label or row["bound_client_label"], token_id),
)
fresh = self._load_token_by_id(conn, token_id)
failure = failure_from_final_state(fresh)
if failure is not None:
return failure, fresh
return (
AuthResult(True, None, fresh["token_id"], fresh["bound_client_id"], fresh["expires_at"]),
fresh,
)
bind_result = conn.execute(
"""
update access_tokens
set bound_client_id = ?, bound_client_label = ?, bound_at = ?, last_seen_at = ?
where token_id = ? and bound_client_id is null and revoked_at is null
""",
(client_id, client_label, now_iso, now_iso, token_id),
)
fresh = self._load_token_by_id(conn, token_id)
failure = failure_from_final_state(fresh)
if failure is not None:
return failure, fresh
if bind_result.rowcount == 0 and fresh["bound_client_id"] is None:
return (
AuthResult(False, "token_not_found", fresh["token_id"], None, fresh["expires_at"]),
fresh,
)
if fresh["bound_client_id"] != client_id:
return (
AuthResult(
False,
"token_bound_to_other_client",
fresh["token_id"],
fresh["bound_client_id"],
fresh["expires_at"],
),
fresh,
)
return (
AuthResult(True, None, fresh["token_id"], fresh["bound_client_id"], fresh["expires_at"]),
fresh,
)
def authenticate(
self,
plaintext_token: str,
client_id: str | None,
client_label: str | None,
) -> AuthResult:
if not client_id:
return AuthResult(False, "client_id_missing", None, None, None)
token_hash = hashlib.sha256(plaintext_token.encode("utf-8")).hexdigest()
now = datetime.now(timezone.utc)
with closing(connect_sqlite(self._db_path)) as conn:
row = self._load_token_by_hash(conn, token_hash)
auth_result, _ = self._authenticate_row(
conn=conn,
row=row,
client_id=client_id,
client_label=client_label,
now=now,
)
conn.commit()
return auth_result
def status(
self,
plaintext_token: str,
client_id: str | None,
client_label: str | None,
) -> TokenStatus:
token_hash = hashlib.sha256(plaintext_token.encode("utf-8")).hexdigest()
now = datetime.now(timezone.utc)
with closing(connect_sqlite(self._db_path)) as conn:
row = self._load_token_by_hash(conn, token_hash)
if row is None:
return {
"valid": False,
"status": "token_not_found",
"tokenId": None,
"label": None,
"issuedAt": None,
"expiresAt": None,
"remainingSeconds": None,
"remainingDays": None,
"playableSongCount": None,
"bound": False,
"isCurrentClientBound": False,
"boundClientLabel": None,
}
if not client_id:
return {
"valid": False,
"status": "client_id_missing",
"tokenId": row["token_id"],
"label": row["label"],
"issuedAt": row["issued_at"],
"expiresAt": row["expires_at"],
"remainingSeconds": None,
"remainingDays": None,
"playableSongCount": None,
"bound": bool(row["bound_client_id"]),
"isCurrentClientBound": False,
"boundClientLabel": row["bound_client_label"],
}
auth_result, fresh = self._authenticate_row(
conn=conn,
row=row,
client_id=client_id,
client_label=client_label,
now=now,
)
conn.commit()
if fresh is None:
return {
"valid": False,
"status": auth_result.error_code or "token_not_found",
"tokenId": None,
"label": None,
"issuedAt": None,
"expiresAt": None,
"remainingSeconds": None,
"remainingDays": None,
"playableSongCount": None,
"bound": False,
"isCurrentClientBound": False,
"boundClientLabel": None,
}
remaining_seconds, remaining_days = self._remaining(fresh["expires_at"], now)
status = "active" if auth_result.valid else (auth_result.error_code or "token_not_found")
return {
"valid": auth_result.valid,
"status": status,
"tokenId": fresh["token_id"],
"label": fresh["label"],
"issuedAt": fresh["issued_at"],
"expiresAt": fresh["expires_at"],
"remainingSeconds": remaining_seconds,
"remainingDays": remaining_days,
"playableSongCount": None,
"bound": bool(fresh["bound_client_id"]),
"isCurrentClientBound": fresh["bound_client_id"] == client_id,
"boundClientLabel": fresh["bound_client_label"],
}
def list_tokens(self, include_revoked: bool = False) -> list[dict]:
sql = """
select token_id, label, issued_at, expires_at, bound_client_id, bound_client_label, bound_at, last_seen_at, revoked_at, revoked_reason
from access_tokens
"""
if not include_revoked:
sql += " where revoked_at is null"
sql += " order by issued_at desc"
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(sql).fetchall()
return [dict(row) for row in rows]
def unbind_token(self, token_id: str) -> None:
with closing(connect_sqlite(self._db_path)) as conn:
conn.execute(
"""
update access_tokens
set bound_client_id = null, bound_client_label = null, bound_at = null
where token_id = ?
""",
(token_id,),
)
conn.commit()
def revoke_token(self, token_id: str, reason: str | None = None) -> None:
revoked_at = datetime.now(timezone.utc).isoformat()
with closing(connect_sqlite(self._db_path)) as conn:
conn.execute(
"""
update access_tokens
set revoked_at = ?, revoked_reason = ?
where token_id = ?
""",
(revoked_at, reason, token_id),
)
conn.commit()
+89
View File
@@ -0,0 +1,89 @@
import os
from dataclasses import dataclass
import hashlib
from pathlib import Path
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
normalized = raw.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return default
@dataclass(frozen=True)
class Settings:
access_token: str
catalog_db_path: str
player_db_path: str
local_library_root: str | None
disable_auth: bool
cache_relay_enabled: bool
admin_username: str
admin_password_hash: str
secret_encryption_key: str
cache_reconcile_interval_seconds: int
musicfree_version_json_path: str
musicfree_apk_path: str
def _default_admin_password_hash() -> str:
return f"sha256${hashlib.sha256('admin'.encode('utf-8')).hexdigest()}"
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw.strip())
except ValueError:
return default
def _default_musicfree_release_dir() -> Path:
project_root = Path(__file__).resolve().parents[2]
sibling_release_dir = project_root.parent / "MusicFree" / "release"
if sibling_release_dir.is_dir():
return sibling_release_dir
return project_root / "release"
def get_settings() -> Settings:
musicfree_release_dir = Path(
os.getenv("MUSICFREE_RELEASE_DIR", str(_default_musicfree_release_dir()))
)
return Settings(
access_token=os.getenv("PUBLIC_MUSIC_ACCESS_TOKEN", "dev-token"),
catalog_db_path=os.getenv("CATALOG_DB_PATH", "./data/catalog_read.db"),
player_db_path=os.getenv("PLAYER_DB_PATH", "./data/player.db"),
local_library_root=os.getenv("LOCAL_LIBRARY_ROOT"),
disable_auth=_env_bool("MUSIC_SERVER_DISABLE_AUTH", default=False),
cache_relay_enabled=_env_bool("MUSIC_SERVER_CACHE_RELAY_ENABLED", default=True),
admin_username=os.getenv("MUSIC_SERVER_ADMIN_USERNAME", "admin"),
admin_password_hash=os.getenv(
"MUSIC_SERVER_ADMIN_PASSWORD_HASH",
_default_admin_password_hash(),
),
secret_encryption_key=os.getenv(
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY",
"dev-secret-encryption-key",
),
cache_reconcile_interval_seconds=_env_int(
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS",
600,
),
musicfree_version_json_path=os.getenv(
"MUSICFREE_VERSION_JSON",
str(musicfree_release_dir / "version.json"),
),
musicfree_apk_path=os.getenv(
"MUSICFREE_APK_PATH",
str(musicfree_release_dir / "MusicFree_latest_release_universal.apk"),
),
)
@@ -0,0 +1 @@
"""Command-line tools for Music_Server."""
@@ -0,0 +1,12 @@
import argparse
from ..services.token_service import TokenService
from ..settings import get_settings
def token_service_from_settings() -> TokenService:
return TokenService(db_path=get_settings().player_db_path)
def build_parser(prog: str, description: str) -> argparse.ArgumentParser:
return argparse.ArgumentParser(prog=prog, description=description)
@@ -0,0 +1,17 @@
from ._common import build_parser, token_service_from_settings
def main() -> None:
parser = build_parser("issue_token", "Issue a new Music_Server access token")
parser.add_argument("--days", type=int, default=90)
parser.add_argument("--label", default=None)
args = parser.parse_args()
issued = token_service_from_settings().issue_token(days=args.days, label=args.label)
print(f"token_id={issued.token_id}")
print(f"token={issued.plaintext_token}")
print(f"expires_at={issued.expires_at}")
if __name__ == "__main__":
main()
@@ -0,0 +1,26 @@
from ._common import build_parser, token_service_from_settings
def main() -> None:
parser = build_parser("list_tokens", "List Music_Server access tokens")
parser.add_argument("--include-revoked", action="store_true")
args = parser.parse_args()
for row in token_service_from_settings().list_tokens(include_revoked=args.include_revoked):
print(
"|".join(
[
row["token_id"],
row.get("label") or "",
row["expires_at"],
row.get("bound_client_id") or "",
row.get("bound_client_label") or "",
row.get("last_seen_at") or "",
row.get("revoked_at") or "",
]
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,15 @@
from ._common import build_parser, token_service_from_settings
def main() -> None:
parser = build_parser("revoke_token", "Revoke a Music_Server token")
parser.add_argument("--token-id", required=True)
parser.add_argument("--reason", default=None)
args = parser.parse_args()
token_service_from_settings().revoke_token(args.token_id, reason=args.reason)
print(f"revoked={args.token_id}")
if __name__ == "__main__":
main()
@@ -0,0 +1,14 @@
from ._common import build_parser, token_service_from_settings
def main() -> None:
parser = build_parser("unbind_token", "Unbind a Music_Server token")
parser.add_argument("--token-id", required=True)
args = parser.parse_args()
token_service_from_settings().unbind_token(args.token_id)
print(f"unbound={args.token_id}")
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
import sys
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parents[1] / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
from music_server.services.token_service import TokenService
_TOKEN_CACHE: dict[tuple[str, str], str] = {}
def issue_access_token(player_db_path: str | Path, *, label: str = "test-token") -> str:
cache_key = (str(player_db_path), label)
cached = _TOKEN_CACHE.get(cache_key)
if cached is not None:
return cached
service = TokenService(str(player_db_path))
issued = service.issue_token(label=label).plaintext_token
_TOKEN_CACHE[cache_key] = issued
return issued
def auth_headers(
player_db_path: str | Path,
*,
client_id: str = "test-client",
client_label: str = "Test Client",
token: str | None = None,
include_client_id: bool = True,
include_client_label: bool = True,
) -> dict[str, str]:
issued_token = token or issue_access_token(player_db_path)
headers = {"Authorization": f"Bearer {issued_token}"}
if include_client_id:
headers["X-Music-Client-Id"] = client_id
if include_client_label:
headers["X-Music-Client-Label"] = client_label
return headers
@@ -0,0 +1,249 @@
import hashlib
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from music_server.services.cache_service import CacheService
class AdminCacheRouteTests(unittest.TestCase):
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
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
)
"""
)
conn.execute(
"""
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
2001,
"kuwo",
"remote-2001",
"Song 2001",
"Singer 2001",
"Album 2001",
None,
None,
None,
),
)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
2001,
"super",
"flac",
1024,
"object_storage",
"origin",
"songs/2001.flac",
"https://origin.example/2001.flac",
"active",
1,
),
)
conn.commit()
conn.close()
def _admin_env(self, player_db_path: Path, catalog_db_path: Path) -> dict[str, str]:
return {
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
"MUSIC_SERVER_ADMIN_USERNAME": "admin",
"MUSIC_SERVER_ADMIN_PASSWORD_HASH": f"sha256${hashlib.sha256('secret123'.encode('utf-8')).hexdigest()}",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
}
def test_admin_cache_api_requires_login(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict("os.environ", self._admin_env(player_db_path, catalog_db_path), clear=False):
client = TestClient(create_app())
response = client.get("/admin/api/cache/overview")
self.assertEqual(401, response.status_code)
def test_admin_login_then_manage_targets_and_reconcile(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict("os.environ", self._admin_env(player_db_path, catalog_db_path), clear=False):
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
service.upsert_heat_summary(
song_id=2001,
play_count_total=12,
play_count_30d=12,
last_played_at="2026-04-23T12:00:00+00:00",
)
client = TestClient(create_app())
login = client.post(
"/admin/session/login",
data={"username": "admin", "password": "secret123"},
follow_redirects=False,
)
self.assertIn(login.status_code, {200, 204, 303})
create_target = client.post(
"/admin/api/cache/targets",
json={
"name": "tier-a",
"kind": "s3",
"order_index": 1,
"capacity_songs": 10,
"public_base_url": "https://cache.example",
"path_prefix": "music",
"enabled": True,
"secrets": {
"bucket": "music-cache",
"region": "ap-shanghai",
"access_key_id": "AKIA",
"secret_access_key": "SECRET",
},
},
)
overview = client.get("/admin/api/cache/overview")
targets = client.get("/admin/api/cache/targets")
hot_songs = client.get("/admin/api/cache/hot-songs")
reconcile = client.post("/admin/api/cache/reconcile")
html_page = client.get("/admin/cache")
self.assertEqual(200, create_target.status_code)
self.assertEqual(200, overview.status_code)
self.assertEqual(200, targets.status_code)
self.assertEqual(200, hot_songs.status_code)
self.assertEqual(200, reconcile.status_code)
self.assertEqual(200, html_page.status_code)
target_payload = targets.json()["items"][0]
self.assertNotIn("secrets", target_payload)
self.assertTrue(target_payload["has_secrets"])
self.assertEqual(
["access_key_id", "bucket", "region", "secret_access_key"],
target_payload["secret_fields"],
)
self.assertEqual(2001, hot_songs.json()["items"][0]["song_id"])
self.assertEqual("Song 2001", hot_songs.json()["items"][0]["name"])
self.assertEqual("https://origin.example/2001.flac", hot_songs.json()["items"][0]["external_url"])
self.assertEqual(1, reconcile.json()["created_upload_tasks"])
self.assertIn("Cache Targets", html_page.text)
self.assertIn('id="target-form"', html_page.text)
self.assertIn('id="target-kind"', html_page.text)
self.assertIn('id="target-order-index"', html_page.text)
self.assertIn('id="target-capacity-songs"', html_page.text)
self.assertIn("Save Target", html_page.text)
self.assertIn('id="target-test-button"', html_page.text)
self.assertIn("Test Connection", html_page.text)
self.assertIn("Song Name", html_page.text)
self.assertIn("External URL", html_page.text)
self.assertNotIn("Secrets JSON", html_page.text)
self.assertIn('id="sftp-host"', html_page.text)
self.assertIn('id="sftp-remote-root"', html_page.text)
self.assertIn('id="sftp-username"', html_page.text)
self.assertIn('id="sftp-password"', html_page.text)
self.assertIn('id="s3-bucket"', html_page.text)
self.assertIn('id="s3-access-key-id"', html_page.text)
self.assertIn('id="s3-secret-access-key"', html_page.text)
def test_admin_can_test_unsaved_target_connection(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict("os.environ", self._admin_env(player_db_path, catalog_db_path), clear=False):
client = TestClient(create_app())
login = client.post(
"/admin/session/login",
data={"username": "admin", "password": "secret123"},
follow_redirects=False,
)
self.assertIn(login.status_code, {200, 204, 303})
with patch.object(
CacheService,
"test_target_connection_payload",
create=True,
return_value={
"kind": "sftp",
"ok": True,
"secret_keys": ["host", "password", "username"],
},
) as mocked_test:
response = client.post(
"/admin/api/cache/targets/test",
json={
"kind": "sftp",
"secrets": {
"host": "1.2.3.4",
"port": 22,
"username": "root",
"password": "secret",
},
},
)
self.assertEqual(200, response.status_code)
self.assertEqual("sftp", response.json()["kind"])
self.assertTrue(response.json()["ok"])
mocked_test.assert_called_once_with(
kind="sftp",
secrets={
"host": "1.2.3.4",
"port": 22,
"username": "root",
"password": "secret",
},
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,87 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
class AppUpdateRouteTests(unittest.TestCase):
def test_app_version_json_returns_server_apk_download_url(self):
with tempfile.TemporaryDirectory() as tmp:
release_dir = Path(tmp)
version_json = release_dir / "version.json"
apk_path = release_dir / "MusicFree_latest_release_universal.apk"
version_json.write_text(
json.dumps(
{
"version": "9.9.9",
"changeLog": ["server app update"],
"download": ["https://official.example/download"],
}
),
encoding="utf-8",
)
apk_path.write_bytes(b"fake-apk")
with patch.dict(
"os.environ",
{
"MUSICFREE_VERSION_JSON": str(version_json),
"MUSICFREE_APK_PATH": str(apk_path),
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS": "0",
},
clear=False,
):
client = TestClient(create_app())
response = client.get("/app/version.json")
self.assertEqual(200, response.status_code)
self.assertIn("application/json", response.headers.get("content-type", ""))
payload = response.json()
self.assertEqual("9.9.9", payload["version"])
self.assertEqual(["server app update"], payload["changeLog"])
self.assertEqual(
["http://testserver/app/MusicFree_latest_release_universal.apk"],
payload["download"],
)
def test_app_apk_route_serves_configured_apk_file(self):
with tempfile.TemporaryDirectory() as tmp:
release_dir = Path(tmp)
version_json = release_dir / "version.json"
apk_path = release_dir / "MusicFree_latest_release_universal.apk"
version_json.write_text(
json.dumps({"version": "9.9.9", "changeLog": [], "download": []}),
encoding="utf-8",
)
apk_path.write_bytes(b"fake-apk")
with patch.dict(
"os.environ",
{
"MUSICFREE_VERSION_JSON": str(version_json),
"MUSICFREE_APK_PATH": str(apk_path),
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS": "0",
},
clear=False,
):
client = TestClient(create_app())
response = client.get("/app/MusicFree_latest_release_universal.apk")
self.assertEqual(200, response.status_code)
self.assertEqual(b"fake-apk", response.content)
self.assertIn(
"application/vnd.android.package-archive",
response.headers.get("content-type", ""),
)
if __name__ == "__main__":
unittest.main()
+175
View File
@@ -0,0 +1,175 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from music_server.services.token_service import TokenService
from tests.support import auth_headers, issue_access_token
class AuthRouteTests(unittest.TestCase):
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
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_track_files (
song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(101, "super", "flac", 100, "object_storage", "cdn", "101.flac", None, "active", 1),
(101, "standard", "mp3", 80, "object_storage", "cdn", "101.mp3", None, "active", 0),
(102, "standard", "mp3", 90, "object_storage", "cdn", "102.mp3", None, "inactive", 1),
(103, "standard", "mp3", 90, "object_storage", "cdn", "103.mp3", None, "active", 1),
],
)
conn.commit()
conn.close()
def test_token_status_active_returns_playable_song_count(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/auth/v1/token-status",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["valid"])
self.assertEqual("active", payload["status"])
self.assertEqual(2, payload["playableSongCount"])
def test_token_status_client_id_missing_uses_body_status_not_401(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
token = issue_access_token(player_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/auth/v1/token-status",
headers=auth_headers(
player_db_path,
token=token,
include_client_id=False,
),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertFalse(payload["valid"])
self.assertEqual("client_id_missing", payload["status"])
self.assertIsNone(payload["playableSongCount"])
def test_token_status_rejects_missing_bearer(self):
client = TestClient(create_app())
response = client.get("/auth/v1/token-status")
self.assertEqual(401, response.status_code)
def test_token_status_allows_missing_bearer_when_auth_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
"MUSIC_SERVER_DISABLE_AUTH": "1",
},
clear=False,
):
client = TestClient(create_app())
response = client.get("/auth/v1/token-status")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["valid"])
self.assertEqual("active", payload["status"])
self.assertEqual("auth_disabled", payload["source"])
self.assertEqual(2, payload["playableSongCount"])
def test_token_status_active_degrades_when_catalog_track_files_table_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
sqlite3.connect(catalog_db_path).close()
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app(), raise_server_exceptions=False)
response = client.get(
"/auth/v1/token-status",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["valid"])
self.assertEqual("active", payload["status"])
self.assertIsNone(payload["playableSongCount"])
def test_auth_headers_reuses_same_token_by_default_for_same_db(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
first = auth_headers(player_db_path)
second = auth_headers(player_db_path)
self.assertEqual(first["Authorization"], second["Authorization"])
service = TokenService(str(player_db_path))
self.assertEqual(1, len(service.list_tokens(include_revoked=True)))
if __name__ == "__main__":
unittest.main()
+521
View File
@@ -0,0 +1,521 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from music_server.services.cache_service import CacheService
class CacheServiceTests(unittest.TestCase):
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
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
)
"""
)
conn.execute(
"""
create table catalog_track_files (
song_id integer not null,
quality_label text not null,
ext text not null,
file_size_bytes integer not null,
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
)
"""
)
track_rows = [
(1001, "kuwo", "remote-1001", "Song 1001", "Singer 1001", "Album 1001", None, None, None),
(1002, "kuwo", "remote-1002", "Song 1002", "Singer 1002", "Album 1002", None, None, None),
(1003, "kuwo", "remote-1003", "Song 1003", "Singer 1003", "Album 1003", None, None, None),
(1004, "kuwo", "remote-1004", "Song 1004", "Singer 1004", "Album 1004", None, None, None),
]
conn.executemany(
"""
insert into catalog_tracks (
song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
track_rows,
)
rows = [
(1001, "standard", "mp3", 1000, "local_fs", "library", "std/1001.mp3", None, "active", 0),
(1001, "super", "flac", 2000, "local_fs", "library", "super/1001.flac", None, "active", 1),
(1002, "standard", "mp3", 1000, "local_fs", "library", "std/1002.mp3", None, "active", 1),
(1003, "standard", "mp3", 1000, "object_storage", "main", "obj/1003.mp3", "https://origin.example/1003.mp3", "active", 1),
(1004, "standard", "mp3", 1000, "object_storage", "main", "obj/1004.mp3", "https://origin.example/1004.mp3", "active", 1),
]
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
conn.commit()
conn.close()
def test_record_stream_play_counts_each_stream_token_once(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
first = service.record_stream_play(
song_id=1001,
stream_token="stream-a",
played_at="2026-04-23T10:00:00+00:00",
)
second = service.record_stream_play(
song_id=1001,
stream_token="stream-a",
played_at="2026-04-23T10:01:00+00:00",
)
summary = service.get_heat_summary(song_id=1001)
self.assertTrue(first)
self.assertFalse(second)
self.assertEqual(1, summary["play_count_total"])
self.assertEqual(1, summary["play_count_30d"])
self.assertEqual("2026-04-23T10:00:00+00:00", summary["last_played_at"])
def test_record_stream_play_is_noop_when_cache_relay_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
cache_relay_enabled=False,
)
recorded = service.record_stream_play(
song_id=1001,
stream_token="stream-disabled",
played_at="2026-04-23T10:00:00+00:00",
)
summary = service.get_heat_summary(song_id=1001)
self.assertFalse(recorded)
self.assertEqual(0, summary["play_count_total"])
self.assertEqual(0, summary["play_count_30d"])
self.assertIsNone(summary["last_played_at"])
def test_reconcile_assigns_strict_top_n_targets_and_best_quality_source(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
target_a = service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=2,
public_base_url="https://cache-a.example",
path_prefix="music/a",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
target_b = service.create_cache_target(
name="tier-b",
kind="s3",
order_index=2,
capacity_songs=1,
public_base_url="https://cache-b.example",
path_prefix="music/b",
enabled=True,
secrets={"bucket": "music", "region": "test"},
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=50,
play_count_30d=50,
last_played_at="2026-04-23T12:00:00+00:00",
)
service.upsert_heat_summary(
song_id=1002,
play_count_total=40,
play_count_30d=40,
last_played_at="2026-04-23T11:00:00+00:00",
)
service.upsert_heat_summary(
song_id=1003,
play_count_total=30,
play_count_30d=30,
last_played_at="2026-04-23T10:00:00+00:00",
)
service.upsert_heat_summary(
song_id=1004,
play_count_total=20,
play_count_30d=20,
last_played_at="2026-04-23T09:00:00+00:00",
)
result = service.reconcile_cache_assignments(now="2026-04-23T13:00:00+00:00")
objects = service.list_cache_objects()
tasks = service.list_transfer_tasks()
self.assertEqual(3, result["desired_song_count"])
self.assertEqual(3, len(objects))
self.assertEqual(3, len(tasks))
self.assertEqual(
[
(1001, target_a["id"], "super", "super/1001.flac", "pending_upload"),
(1002, target_a["id"], "standard", "std/1002.mp3", "pending_upload"),
(1003, target_b["id"], "standard", "obj/1003.mp3", "pending_upload"),
],
[
(
item["song_id"],
item["target_id"],
item["quality_label"],
item["source_locator"],
item["status"],
)
for item in objects
],
)
def test_reconcile_marks_out_of_range_items_evictable_and_only_deletes_when_over_capacity(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
target = service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=1,
public_base_url="https://cache-a.example",
path_prefix="music",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
service.upsert_cache_object(
song_id=1002,
target_id=target["id"],
quality_label="standard",
source_locator="std/1002.mp3",
remote_key="music/1002.mp3",
public_url="https://cache-a.example/music/1002.mp3",
status="active",
last_rank=1,
uploaded_at="2026-04-20T00:00:00+00:00",
last_verified_at="2026-04-20T00:00:00+00:00",
evictable=False,
)
service.upsert_cache_object(
song_id=1004,
target_id=target["id"],
quality_label="standard",
source_locator="obj/1004.mp3",
remote_key="music/1004.mp3",
public_url="https://cache-a.example/music/1004.mp3",
status="active",
last_rank=2,
uploaded_at="2026-04-20T00:00:00+00:00",
last_verified_at="2026-04-20T00:00:00+00:00",
evictable=False,
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=10,
play_count_30d=10,
last_played_at="2026-04-23T12:00:00+00:00",
)
service.reconcile_cache_assignments(now="2026-04-23T13:00:00+00:00")
objects = service.list_cache_objects(target_id=target["id"])
tasks = service.list_transfer_tasks(task_kind="delete")
self.assertEqual(
[(1001, "pending_upload", False), (1002, "evictable", True)],
[(item["song_id"], item["status"], bool(item["evictable"])) for item in objects],
)
self.assertEqual([1004], [item["song_id"] for item in tasks])
def test_process_transfer_tasks_marks_uploaded_objects_active(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
local_file = library_root / "super" / "1001.flac"
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(b"audio-data")
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
local_library_root=str(library_root),
)
service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=1,
public_base_url="https://cache-a.example",
path_prefix="music",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=99,
play_count_30d=99,
last_played_at="2026-04-23T12:00:00+00:00",
)
service.reconcile_cache_assignments(now="2026-04-23T13:00:00+00:00")
with patch("music_server.services.cache_service.SFTPCacheTargetUploader.upload_file") as upload_file:
result = service.process_transfer_tasks(now="2026-04-23T13:05:00+00:00")
objects = service.list_cache_objects()
tasks = service.list_transfer_tasks()
self.assertEqual(1, result["uploaded"])
self.assertEqual("active", objects[0]["status"])
self.assertEqual("success", tasks[0]["status"])
upload_file.assert_called_once()
def test_reconcile_is_noop_when_cache_relay_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
cache_relay_enabled=False,
)
service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=2,
public_base_url="https://cache-a.example",
path_prefix="music/a",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=50,
play_count_30d=50,
last_played_at="2026-04-23T12:00:00+00:00",
)
result = service.reconcile_cache_assignments(now="2026-04-23T13:00:00+00:00")
self.assertEqual(0, result["desired_song_count"])
self.assertEqual(0, result["created_upload_tasks"])
self.assertEqual(0, result["created_delete_tasks"])
self.assertEqual([], service.list_cache_objects())
self.assertEqual([], service.list_transfer_tasks())
def test_process_transfer_tasks_is_noop_when_cache_relay_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
cache_relay_enabled=False,
)
target = service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=1,
public_base_url="https://cache-a.example",
path_prefix="music",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
service.upsert_cache_object(
song_id=1001,
target_id=target["id"],
quality_label="super",
source_locator="super/1001.flac",
remote_key="music/1001_super.flac",
public_url="https://cache-a.example/music/1001_super.flac",
status="pending_upload",
last_rank=1,
uploaded_at=None,
last_verified_at=None,
evictable=False,
)
conn = sqlite3.connect(player_db_path)
conn.execute(
"""
insert into cache_transfer_tasks (
song_id, target_id, task_kind, quality_label, source_locator, remote_key,
public_url, status, run_id, created_at, updated_at
) values (?, ?, 'upload', ?, ?, ?, ?, 'pending', null, ?, ?)
""",
(
1001,
target["id"],
"super",
"super/1001.flac",
"music/1001_super.flac",
"https://cache-a.example/music/1001_super.flac",
"2026-04-23T13:00:00+00:00",
"2026-04-23T13:00:00+00:00",
),
)
conn.commit()
conn.close()
result = service.process_transfer_tasks(now="2026-04-23T13:05:00+00:00")
tasks = service.list_transfer_tasks()
objects = service.list_cache_objects()
self.assertEqual({"uploaded": 0, "deleted": 0, "failed": 0}, result)
self.assertEqual("pending", tasks[0]["status"])
self.assertEqual("pending_upload", objects[0]["status"])
def test_list_hot_songs_includes_name_and_prefers_cache_public_url(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
target = service.create_cache_target(
name="tier-a",
kind="s3",
order_index=1,
capacity_songs=10,
public_base_url="https://cache-a.example",
path_prefix="music",
enabled=True,
secrets={"bucket": "music-cache", "access_key_id": "AKIA", "secret_access_key": "SECRET"},
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=20,
play_count_30d=20,
last_played_at="2026-04-23T12:00:00+00:00",
)
service.upsert_heat_summary(
song_id=1003,
play_count_total=10,
play_count_30d=10,
last_played_at="2026-04-23T11:00:00+00:00",
)
service.upsert_cache_object(
song_id=1001,
target_id=target["id"],
quality_label="super",
source_locator="super/1001.flac",
remote_key="music/1001_super.flac",
public_url="https://cache-a.example/music/1001_super.flac",
status="active",
last_rank=1,
uploaded_at="2026-04-23T12:05:00+00:00",
last_verified_at="2026-04-23T12:05:00+00:00",
evictable=False,
)
hot_songs = service.list_hot_songs(limit=10)
self.assertEqual(
[
(1001, "Song 1001", "https://cache-a.example/music/1001_super.flac"),
(1003, "Song 1003", "https://origin.example/1003.mp3"),
],
[(item["song_id"], item["name"], item["external_url"]) for item in hot_songs],
)
@patch("music_server.services.cache_service.SFTPCacheTargetUploader")
def test_test_target_connection_payload_passes_sftp_remote_root(self, uploader_class):
uploader = uploader_class.return_value
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
result = service.test_target_connection_payload(
kind="sftp",
secrets={
"host": "64.83.43.123",
"port": 22,
"username": "root",
"password": "secret",
"remote_root": "/srv/music_server_cache",
},
)
uploader_class.assert_called_once_with(
host="64.83.43.123",
port=22,
username="root",
password="secret",
private_key=None,
timeout_seconds=10,
remote_root="/srv/music_server_cache",
)
uploader.test_connection.assert_called_once_with()
self.assertEqual("sftp", result["kind"])
self.assertTrue(result["ok"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,63 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from music_server.services.cache_uploaders import S3CacheTargetUploader, SFTPCacheTargetUploader
class CacheUploaderTests(unittest.TestCase):
@patch("music_server.services.cache_uploaders.paramiko.SSHClient")
def test_sftp_uploader_upload_and_delete(self, ssh_client_class):
ssh_client = MagicMock()
sftp_client = MagicMock()
ssh_client.open_sftp.return_value = sftp_client
ssh_client_class.return_value = ssh_client
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "song.flac"
file_path.write_bytes(b"flac-data")
uploader = SFTPCacheTargetUploader(
host="127.0.0.1",
port=22,
username="tester",
password="secret",
remote_root="/srv/music_server_cache",
)
uploader.upload_file(local_path=file_path, remote_key="music/song.flac")
uploader.delete_file(remote_key="music/song.flac")
uploader.test_connection()
self.assertEqual(3, ssh_client.connect.call_count)
sftp_client.put.assert_called_once_with(str(file_path), "/srv/music_server_cache/music/song.flac")
sftp_client.remove.assert_called_once_with("/srv/music_server_cache/music/song.flac")
sftp_client.listdir.assert_called_once_with("/srv/music_server_cache")
@patch("music_server.services.cache_uploaders._build_boto3_client")
def test_s3_uploader_upload_and_delete(self, build_client):
s3_client = MagicMock()
build_client.return_value = s3_client
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "song.flac"
file_path.write_bytes(b"flac-data")
uploader = S3CacheTargetUploader(
bucket="music-cache",
region="ap-shanghai",
endpoint_url="https://s3.example",
access_key_id="AKIA",
secret_access_key="SECRET",
)
uploader.upload_file(local_path=file_path, remote_key="music/song.flac")
uploader.delete_file(remote_key="music/song.flac")
uploader.test_connection()
s3_client.upload_file.assert_called_once()
s3_client.delete_object.assert_called_once_with(Bucket="music-cache", Key="music/song.flac")
s3_client.head_bucket.assert_called_once_with(Bucket="music-cache")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,639 @@
import ast
import importlib.util
import sqlite3
import tempfile
import unittest
from pathlib import Path
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "export_catalog_read.py"
SPEC = importlib.util.spec_from_file_location("export_catalog_read", SCRIPT_PATH)
assert SPEC is not None
assert SPEC.loader is not None
export_catalog_read = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(export_catalog_read)
class ExportCatalogReadTests(unittest.TestCase):
def test_export_script_defers_annotation_evaluation_for_host_python38(self):
module = ast.parse(SCRIPT_PATH.read_text(encoding="utf-8"))
future_imports = [
node
for node in module.body
if isinstance(node, ast.ImportFrom) and node.module == "__future__"
]
imported_names = {
alias.name
for node in future_imports
for alias in node.names
}
self.assertIn(
"annotations",
imported_names,
"export_catalog_read.py must defer annotation evaluation so host Python 3.8 "
"can execute the script during post-download catalog export.",
)
def setUp(self) -> None:
self._tmpdir = tempfile.TemporaryDirectory()
self._source_db = Path(self._tmpdir.name) / "catalogsync.db"
self._target_db = Path(self._tmpdir.name) / "catalog_read.db"
conn = sqlite3.connect(self._source_db)
conn.executescript(
"""
create table playlists (
id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
url text not null,
parse_strategy text not null default 'playlist_url',
cover_url text,
creator_name text,
play_count integer,
metadata_json text,
created_at text,
updated_at text,
collected_song_count integer
);
create table songs (
id integer primary key,
platform text not null,
remote_song_id text not null,
name text not null,
singers text,
album text,
duration_seconds integer,
metadata_json text
);
create table artists (
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,
metadata_json text
);
create table artist_songs (
artist_id integer not null,
song_id integer not null,
discovered_at text
);
create table playlist_songs (
playlist_id integer not null,
song_id integer not null,
position integer
);
create table file_assets (
id integer primary key,
song_id integer not null,
quality_label text,
ext text,
file_size_bytes integer
);
create table storage_backends (
id integer primary key,
name text not null,
backend_type text not null
);
create table file_locations (
id integer primary key,
file_asset_id integer not null,
backend_id integer not null,
locator text,
public_url text,
download_url text,
status text,
is_primary integer
);
"""
)
conn.executemany(
"""
insert into playlists (
id, platform, remote_playlist_id, name, url, parse_strategy,
cover_url, creator_name, play_count, collected_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
1,
"netease",
"18165",
"Playlist One",
"https://example.com/p1",
"playlist_url",
"https://img/p1.jpg",
"creator-1",
123,
None,
),
(
2,
"qq",
"75",
"Toplist One",
"https://example.com/p2",
"qq_toplist",
"https://img/p2.jpg",
"creator-2",
456,
None,
),
],
)
conn.executemany(
"""
insert into songs (
id, platform, remote_song_id, name, singers, album, duration_seconds, metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(101, "qq", "s101", "Song 101", "Singer A", "Album A", 210, None),
(102, "qq", "s102", "Song 102", "Singer B", "Album B", 220, None),
(103, "qq", "s103", "Song 103", "Singer C", "Album C", 230, None),
],
)
conn.executemany(
"""
insert into playlist_songs (playlist_id, song_id, position) values (?, ?, ?)
""",
[
(1, 101, 1),
(1, 102, 2),
(2, 101, 1),
(2, 102, 2),
(2, 103, 3),
],
)
conn.execute(
"""
insert into storage_backends (id, name, backend_type)
values (?, ?, ?)
""",
(1, "nas", "alist"),
)
conn.executemany(
"""
insert into file_assets (id, song_id, quality_label, ext, file_size_bytes)
values (?, ?, ?, ?, ?)
""",
[
(1001, 101, "128k", "mp3", 1234),
(1002, 102, "128k", "mp3", 2345),
(1003, 103, "128k", "mp3", 3456),
(1004, 101, "flac", "flac", 4567),
],
)
conn.executemany(
"""
insert into file_locations (
id, file_asset_id, backend_id, locator,
public_url, download_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
2001,
1001,
1,
"music/101-128k.mp3",
"https://cdn.example/101-128k.mp3",
None,
"active",
1,
),
(
2002,
1002,
1,
"music/102-128k.mp3",
"https://cdn.example/102-128k.mp3",
None,
"inactive",
1,
),
(
2003,
1003,
1,
"music/103-128k.mp3",
None,
"https://download.example/103-128k.mp3",
"active",
1,
),
(
2004,
1004,
1,
"music/101-flac.flac",
"https://cdn.example/101-flac.flac",
None,
"active",
0,
),
],
)
conn.commit()
conn.close()
def tearDown(self) -> None:
self._tmpdir.cleanup()
def test_build_catalog_read_exports_song_and_playable_song_counts(self):
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
playlist_rows = conn.execute(
"""
select playlist_id, song_count, playable_song_count
from catalog_playlists
order by playlist_id
"""
).fetchall()
toplist_rows = conn.execute(
"""
select toplist_id, song_count, playable_song_count
from catalog_toplists
order by toplist_id
"""
).fetchall()
conn.close()
self.assertEqual([(1, 2, 1)], playlist_rows)
self.assertEqual([("qq_top_75", 3, 2)], toplist_rows)
def test_build_catalog_read_playable_song_count_deduplicates_duplicate_song_rows(self):
conn = sqlite3.connect(self._source_db)
conn.executemany(
"""
insert into playlist_songs (playlist_id, song_id, position) values (?, ?, ?)
""",
[
(1, 101, 3),
(2, 103, 4),
],
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
playlist_rows = conn.execute(
"""
select playlist_id, song_count, playable_song_count
from catalog_playlists
order by playlist_id
"""
).fetchall()
toplist_rows = conn.execute(
"""
select toplist_id, song_count, playable_song_count
from catalog_toplists
order by toplist_id
"""
).fetchall()
conn.close()
self.assertEqual([(1, 3, 1)], playlist_rows)
self.assertEqual([("qq_top_75", 4, 2)], toplist_rows)
def test_build_catalog_read_song_count_prefers_playlist_rows_then_collected_fallback(self):
conn = sqlite3.connect(self._source_db)
conn.executemany(
"""
update playlists
set collected_song_count = ?
where id = ?
""",
[
(99, 1),
(88, 2),
],
)
conn.executemany(
"""
insert into playlists (
id, platform, remote_playlist_id, name, url, parse_strategy,
cover_url, creator_name, play_count, collected_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
3,
"netease",
"18166",
"Playlist Fallback",
"https://example.com/p3",
"playlist_url",
"https://img/p3.jpg",
"creator-3",
321,
7,
),
(
4,
"qq",
"86",
"Toplist Fallback",
"https://example.com/p4",
"qq_toplist",
"https://img/p4.jpg",
"creator-4",
654,
6,
),
(
5,
"netease",
"19723756",
"Netease Official Toplist",
"https://example.com/p5",
"netease_toplist",
"https://img/p5.jpg",
"creator-5",
777,
10,
),
(
6,
"kuwo",
"489927",
"Kuwo Hot Toplist",
"https://example.com/p6",
"kuwo_toplist",
"https://img/p6.jpg",
"creator-6",
888,
11,
),
],
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
playlist_rows = conn.execute(
"""
select playlist_id, song_count
from catalog_playlists
order by playlist_id
"""
).fetchall()
toplist_rows = conn.execute(
"""
select toplist_id, song_count
from catalog_toplists
order by toplist_id
"""
).fetchall()
conn.close()
self.assertEqual([(1, 2), (3, 7)], playlist_rows)
self.assertEqual(
[
("kuwo_top_489927", 11),
("netease_top_19723756", 10),
("qq_top_75", 3),
("qq_top_86", 6),
],
toplist_rows,
)
def test_build_catalog_read_exports_playable_artists_and_tracks(self):
conn = sqlite3.connect(self._source_db)
conn.executemany(
"""
insert into artists (
id, artist_key, platform, remote_artist_id, name, normalized_name, metadata_json
) values (?, ?, ?, ?, ?, ?, ?)
""",
[
(
1,
"netease:artist-a",
"netease",
"artist-a",
"Singer A",
"singer a",
'{"avatar":"https://img/artist-a.jpg","description":"desc-a"}',
),
(
2,
"qq:artist-b",
"qq",
"artist-b",
"Singer B",
"singer b",
'{"avatar":"https://img/artist-b.jpg","description":"desc-b"}',
),
],
)
conn.executemany(
"""
insert into artist_songs (artist_id, song_id, discovered_at)
values (?, ?, ?)
""",
[
(1, 101, "2026-04-23T00:00:00+00:00"),
(1, 102, "2026-04-23T00:00:00+00:00"),
(2, 103, "2026-04-23T00:00:00+00:00"),
],
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
artist_rows = conn.execute(
"""
select artist_id, platform, remote_artist_id, name, avatar_url, description, playable_song_count
from catalog_artists
order by artist_id
"""
).fetchall()
artist_track_rows = conn.execute(
"""
select artist_id, song_id, position
from catalog_artist_tracks
order by artist_id, position
"""
).fetchall()
conn.close()
self.assertEqual(
[
(1, "netease", "artist-a", "Singer A", "https://img/artist-a.jpg", "desc-a", 1),
(2, "qq", "artist-b", "Singer B", "https://img/artist-b.jpg", "desc-b", 1),
],
artist_rows,
)
self.assertEqual([(1, 101, 1), (2, 103, 1)], artist_track_rows)
def test_build_catalog_read_artist_tracks_deduplicate_duplicate_artist_song_links(self):
conn = sqlite3.connect(self._source_db)
conn.execute(
"""
insert into artists (
id, artist_key, platform, remote_artist_id, name, normalized_name, metadata_json
) values (?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"netease:artist-a",
"netease",
"artist-a",
"Singer A",
"singer a",
'{"avatar":"https://img/artist-a.jpg","description":"desc-a"}',
),
)
conn.executemany(
"""
insert into artist_songs (artist_id, song_id, discovered_at)
values (?, ?, ?)
""",
[
(1, 101, "2026-04-23T00:00:00+00:00"),
(1, 101, "2026-04-23T01:00:00+00:00"),
(1, 103, "2026-04-23T02:00:00+00:00"),
],
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
artist_rows = conn.execute(
"""
select artist_id, playable_song_count
from catalog_artists
order by artist_id
"""
).fetchall()
artist_track_rows = conn.execute(
"""
select artist_id, song_id, position
from catalog_artist_tracks
order by artist_id, position
"""
).fetchall()
conn.close()
self.assertEqual([(1, 2)], artist_rows)
self.assertEqual([(1, 101, 1), (1, 103, 2)], artist_track_rows)
def test_build_catalog_read_artist_export_ignores_dangling_song_relations(self):
conn = sqlite3.connect(self._source_db)
conn.execute(
"""
insert into artists (
id, artist_key, platform, remote_artist_id, name, normalized_name, metadata_json
) values (?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"netease:artist-a",
"netease",
"artist-a",
"Singer A",
"singer a",
'{"avatar":"https://img/artist-a.jpg","description":"desc-a"}',
),
)
conn.executemany(
"""
insert into artist_songs (artist_id, song_id, discovered_at)
values (?, ?, ?)
""",
[
(1, 101, "2026-04-23T00:00:00+00:00"),
(1, 999, "2026-04-23T01:00:00+00:00"),
],
)
conn.execute(
"""
insert into file_assets (id, song_id, quality_label, ext, file_size_bytes)
values (?, ?, ?, ?, ?)
""",
(1005, 999, "128k", "mp3", 2222),
)
conn.execute(
"""
insert into file_locations (
id, file_asset_id, backend_id, locator,
public_url, download_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
2005,
1005,
1,
"music/999-128k.mp3",
"https://cdn.example/999-128k.mp3",
None,
"active",
1,
),
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
artist_rows = conn.execute(
"""
select artist_id, playable_song_count
from catalog_artists
order by artist_id
"""
).fetchall()
artist_track_rows = conn.execute(
"""
select artist_id, song_id, position
from catalog_artist_tracks
order by artist_id, position
"""
).fetchall()
conn.close()
self.assertEqual([(1, 1)], artist_rows)
self.assertEqual([(1, 101, 1)], artist_track_rows)
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
import unittest
import tempfile
from pathlib import Path
from unittest.mock import patch
from fastapi import HTTPException
from fastapi.testclient import TestClient
from music_server.app import create_app
from music_server.auth import require_bearer_token
from music_server.services.token_service import TokenService
from music_server.settings import get_settings
class HealthRouteTests(unittest.TestCase):
def test_healthz_returns_ok(self):
client = TestClient(create_app())
response = client.get("/healthz")
self.assertEqual(200, response.status_code)
self.assertEqual({"status": "ok"}, response.json())
class SettingsAndAuthTests(unittest.TestCase):
def test_get_settings_reflects_runtime_env_changes(self):
with patch.dict(
"os.environ",
{
"PUBLIC_MUSIC_ACCESS_TOKEN": "token-one",
"CATALOG_DB_PATH": "./tmp/catalog-one.db",
"PLAYER_DB_PATH": "./tmp/player-one.db",
"MUSIC_SERVER_DISABLE_AUTH": "0",
},
clear=False,
):
first = get_settings()
with patch.dict(
"os.environ",
{
"PUBLIC_MUSIC_ACCESS_TOKEN": "token-two",
"CATALOG_DB_PATH": "./tmp/catalog-two.db",
"PLAYER_DB_PATH": "./tmp/player-two.db",
"MUSIC_SERVER_DISABLE_AUTH": "1",
},
clear=False,
):
second = get_settings()
self.assertEqual("token-one", first.access_token)
self.assertEqual("./tmp/catalog-one.db", first.catalog_db_path)
self.assertEqual("./tmp/player-one.db", first.player_db_path)
self.assertEqual("token-two", second.access_token)
self.assertEqual("./tmp/catalog-two.db", second.catalog_db_path)
self.assertEqual("./tmp/player-two.db", second.player_db_path)
self.assertFalse(first.disable_auth)
self.assertTrue(second.disable_auth)
def test_get_settings_reads_cache_relay_switch(self):
with patch.dict(
"os.environ",
{"MUSIC_SERVER_CACHE_RELAY_ENABLED": "0"},
clear=False,
):
disabled = get_settings()
with patch.dict(
"os.environ",
{"MUSIC_SERVER_CACHE_RELAY_ENABLED": "1"},
clear=False,
):
enabled = get_settings()
self.assertFalse(disabled.cache_relay_enabled)
self.assertTrue(enabled.cache_relay_enabled)
def test_require_bearer_token_accepts_case_insensitive_scheme(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
service = TokenService(str(player_db_path))
issued = service.issue_token(label="health-auth")
with patch.dict(
"os.environ",
{"PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
require_bearer_token(
authorization=f" bearer {issued.plaintext_token} ",
x_music_client_id="client-case",
x_music_client_label="Case Client",
)
def test_require_bearer_token_is_bypassed_when_auth_disabled(self):
with patch.dict(
"os.environ",
{"MUSIC_SERVER_DISABLE_AUTH": "1"},
clear=False,
):
require_bearer_token(
authorization=None,
x_music_client_id=None,
)
def test_require_bearer_token_raises_specific_error_codes(self):
with self.assertRaises(HTTPException) as missing:
require_bearer_token(
authorization=None,
x_music_client_id="client-a",
)
self.assertEqual(401, missing.exception.status_code)
self.assertEqual("authorization_missing", missing.exception.detail)
with self.assertRaises(HTTPException) as invalid:
require_bearer_token(
authorization="Basic abc",
x_music_client_id="client-a",
)
self.assertEqual(401, invalid.exception.status_code)
self.assertEqual("authorization_invalid", invalid.exception.detail)
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
service = TokenService(str(player_db_path))
issued = service.issue_token(label="health-auth")
with patch.dict(
"os.environ",
{"PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
with self.assertRaises(HTTPException) as missing_client_id:
require_bearer_token(
authorization=f"Bearer {issued.plaintext_token}",
x_music_client_id=None,
)
self.assertEqual(401, missing_client_id.exception.status_code)
self.assertEqual("client_id_missing", missing_client_id.exception.detail)
with self.assertRaises(HTTPException) as wrong:
require_bearer_token(
authorization="Bearer not-exists",
x_music_client_id="client-a",
)
self.assertEqual(401, wrong.exception.status_code)
self.assertEqual("token_not_found", wrong.exception.detail)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
import unittest
from pathlib import Path
from music_server.services.local_streaming import (
RangeNotSatisfiable,
guess_audio_media_type,
parse_single_range,
)
class GuessAudioMediaTypeTests(unittest.TestCase):
def test_guess_audio_media_type_supported_extensions(self):
self.assertEqual("audio/flac", guess_audio_media_type("a.flac"))
self.assertEqual("audio/mpeg", guess_audio_media_type("a.mp3"))
self.assertEqual("audio/mp4", guess_audio_media_type(Path("a.m4a")))
self.assertEqual("audio/wav", guess_audio_media_type("a.wav"))
self.assertEqual("audio/ogg", guess_audio_media_type("a.ogg"))
self.assertEqual("audio/ape", guess_audio_media_type("a.ape"))
def test_guess_audio_media_type_falls_back_to_octet_stream(self):
self.assertEqual("application/octet-stream", guess_audio_media_type("a.bin"))
class ParseSingleRangeTests(unittest.TestCase):
def test_parse_single_range_returns_none_for_missing_header(self):
self.assertIsNone(parse_single_range(None, 10))
def test_parse_single_range_explicit_start_end(self):
self.assertEqual((2, 5), parse_single_range("bytes=2-5", 10))
def test_parse_single_range_clamps_out_of_bounds_end(self):
self.assertEqual((0, 9), parse_single_range("bytes=0-999", 10))
def test_parse_single_range_suffix(self):
self.assertEqual((6, 9), parse_single_range("bytes=-4", 10))
def test_parse_single_range_open_ended(self):
self.assertEqual((2, 9), parse_single_range("bytes=2-", 10))
def test_parse_single_range_rejects_out_of_bounds(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=10-12", 10)
def test_parse_single_range_rejects_reverse_range(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=7-2", 10)
def test_parse_single_range_rejects_multi_range(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=0-1,3-4", 10)
def test_parse_single_range_rejects_non_bytes_unit(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("items=0-1", 10)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,776 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from tests.support import auth_headers
class MfCatalogRouteTests(unittest.TestCase):
def _prepare_playlist_toplist_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
create table catalog_toplists (
toplist_id text primary key,
platform text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null,
group_name text not null
);
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
);
create table catalog_playlist_tracks (
playlist_id integer not null,
song_id integer not null,
position integer not null
);
create table catalog_toplist_tracks (
toplist_id text not null,
song_id integer not null,
position integer not null
);
create table 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
);
create table catalog_artist_tracks (
artist_id integer not null,
song_id integer not null,
position integer not null
);
"""
)
conn.execute(
"""
insert into catalog_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(1, "netease", "rid-1", "playlist-1", "desc", "https://img/1.jpg", 100, 2, 1),
)
conn.execute(
"""
insert into catalog_toplists (
toplist_id, platform, name, description, cover_url, play_count, song_count, playable_song_count, group_name
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("tl-1", "netease", "toplist-1", "desc", "https://img/top.jpg", 88, 2, 1, "official"),
)
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",
"Playable Song",
"Singer A",
"Album A",
"https://img/song1.jpg",
200000,
"{}",
),
(
2,
"netease",
"n2",
"Blocked Song",
"Singer B",
"Album B",
"https://img/song2.jpg",
180000,
"{}",
),
],
)
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,
"standard",
"mp3",
90,
"object_storage",
"cdn",
"song-2.mp3",
"https://cdn/2.mp3",
"inactive",
1,
),
],
)
conn.executemany(
"""
insert into catalog_playlist_tracks (playlist_id, song_id, position) values (?, ?, ?)
""",
[(1, 1, 1), (1, 2, 2)],
)
conn.executemany(
"""
insert into catalog_toplist_tracks (toplist_id, song_id, position) values (?, ?, ?)
""",
[("tl-1", 1, 1), ("tl-1", 2, 2)],
)
conn.execute(
"""
insert into catalog_artists (
artist_id, artist_key, platform, remote_artist_id, name, normalized_name, avatar_url, description, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"netease:artist-a",
"netease",
"artist-a",
"Singer A",
"singer a",
"https://img/artist-a.jpg",
"artist-desc",
1,
),
)
conn.execute(
"""
insert into catalog_artist_tracks (artist_id, song_id, position) values (?, ?, ?)
""",
(1, 1, 1),
)
conn.commit()
conn.close()
def test_recommend_tags_returns_musicfree_shape(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(player_db_path)}, clear=False):
client = TestClient(create_app())
response = client.get(
"/mf/v1/recommend/tags",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertIn("pinned", payload)
self.assertIn("data", payload)
self.assertEqual(["all", "netease", "qq", "kuwo"], [item["id"] for item in payload["pinned"]])
self.assertEqual(
["playlist_square", "toplist"],
[item["id"] for item in payload["data"][0]["data"]],
)
def test_recommend_routes_requires_token_when_missing(self):
client = TestClient(create_app())
response = client.get("/mf/v1/recommend/tags")
self.assertEqual(401, response.status_code)
def test_recommend_routes_requires_token_when_wrong(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(player_db_path)}, clear=False):
client = TestClient(create_app())
response = client.get(
"/mf/v1/recommend/tags",
headers=auth_headers(player_db_path, token="wrong-token"),
)
self.assertEqual(401, response.status_code)
def test_recommend_routes_allow_anonymous_when_auth_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"MUSIC_SERVER_DISABLE_AUTH": "1",
},
clear=False,
):
client = TestClient(create_app())
response = client.get("/mf/v1/recommend/tags")
self.assertEqual(200, response.status_code)
def test_recommend_sheets_returns_musicfree_shape(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
"""
)
rows = [
(
1,
"netease",
"rid-1",
"测试歌单",
"desc",
"https://img/1.jpg",
999,
9,
5,
)
]
for idx in range(2, 21):
rows.append(
(
idx,
"netease",
f"rid-{idx}",
f"playlist-{idx}",
"desc",
f"https://img/{idx}.jpg",
200 - idx,
5,
5,
)
)
conn.executemany(
"""
insert into catalog_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/recommend/sheets?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
response_large_page = client.get(
"/mf/v1/recommend/sheets?page=1&page_size=21",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertFalse(payload["isEnd"])
self.assertEqual("catalogsync:playlist:1", payload["data"][0]["id"])
self.assertEqual("测试歌单", payload["data"][0]["title"])
self.assertEqual(9, payload["data"][0]["worksNum"])
self.assertEqual(5, payload["data"][0]["playableSongCount"])
self.assertEqual(999, payload["data"][0]["play_count"])
self.assertNotIn("playCount", payload["data"][0])
self.assertEqual(200, response_large_page.status_code)
self.assertTrue(response_large_page.json()["isEnd"])
def test_recommend_sheets_filters_by_platform_tag(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
"""
)
conn.executemany(
"""
insert into catalog_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(1, "netease", "n-1", "Netease List", "desc", "https://img/1.jpg", 300, 10, 10),
(2, "qq", "q-1", "QQ List", "desc", "https://img/2.jpg", 200, 10, 10),
(3, "kuwo", "k-1", "Kuwo List", "desc", "https://img/3.jpg", 100, 10, 10),
],
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/recommend/sheets?tag=qq&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["isEnd"])
self.assertEqual(["catalogsync:playlist:2"], [item["id"] for item in payload["data"]])
self.assertEqual(["QQ List"], [item["title"] for item in payload["data"]])
def test_recommend_sheets_returns_toplists_when_tag_toplist(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
conn = sqlite3.connect(db_path)
conn.execute(
"""
insert into catalog_toplists (
toplist_id, platform, name, description, cover_url, play_count, song_count, playable_song_count, group_name
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("tl-2", "netease", "toplist-2", "desc", "https://img/top2.jpg", 77, 1, 1, "official"),
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response_page_1 = client.get(
"/mf/v1/recommend/sheets?tag=toplist&page=1&page_size=1",
headers=auth_headers(player_db_path),
)
response_page_2 = client.get(
"/mf/v1/recommend/sheets?tag=toplist&page=2&page_size=1",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response_page_1.status_code)
self.assertEqual(200, response_page_2.status_code)
payload_page_1 = response_page_1.json()
payload_page_2 = response_page_2.json()
self.assertEqual(["catalogsync:toplist:tl-1"], [item["id"] for item in payload_page_1["data"]])
self.assertTrue(payload_page_1["data"][0]["id"].startswith("catalogsync:toplist:"))
self.assertFalse(payload_page_1["isEnd"])
self.assertEqual(["catalogsync:toplist:tl-2"], [item["id"] for item in payload_page_2["data"]])
self.assertTrue(payload_page_2["data"][0]["id"].startswith("catalogsync:toplist:"))
self.assertTrue(payload_page_2["isEnd"])
def test_search_songs_returns_musicfree_shape(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/search/songs?q=Playable&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["isEnd"])
self.assertEqual(["catalogsync:song:1"], [item["id"] for item in payload["data"]])
def test_search_songs_includes_raw_lrc_when_local_lyrics_exist(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
library_root.mkdir()
self._prepare_playlist_toplist_catalog_db(db_path)
conn = sqlite3.connect(db_path)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"standard",
"mp3",
80,
"local_fs",
"library",
"Singer A/Playable Song.mp3",
None,
"active",
0,
),
)
conn.commit()
conn.close()
song_dir = library_root / "Singer A"
song_dir.mkdir()
(song_dir / "Playable Song.mp3").write_bytes(b"audio")
(song_dir / "Playable Song.lrc").write_text("[00:00.00]hello lyric\n", encoding="utf-8")
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"LOCAL_LIBRARY_ROOT": str(library_root),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/search/songs?q=Playable&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("[00:00.00]hello lyric\n", payload["data"][0]["rawLrc"])
def test_song_lyric_route_returns_raw_lrc_when_local_lyrics_exist(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
library_root.mkdir()
self._prepare_playlist_toplist_catalog_db(db_path)
conn = sqlite3.connect(db_path)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"standard",
"mp3",
80,
"local_fs",
"library",
"Singer A/Playable Song.mp3",
None,
"active",
0,
),
)
conn.commit()
conn.close()
song_dir = library_root / "Singer A"
song_dir.mkdir()
(song_dir / "Playable Song.mp3").write_bytes(b"audio")
(song_dir / "Playable Song.lrc").write_text("[00:00.00]hello lyric\n", encoding="utf-8")
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"LOCAL_LIBRARY_ROOT": str(library_root),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/songs/1/lyric",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("[00:00.00]hello lyric\n", payload["rawLrc"])
self.assertEqual("[00:00.00]hello lyric\n", payload["lyric"])
def test_playlist_tracks_omit_raw_lrc_when_local_library_root_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
conn = sqlite3.connect(db_path)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"standard",
"mp3",
80,
"local_fs",
"library",
"Singer A/Playable Song.mp3",
None,
"active",
0,
),
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/playlists/1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertNotIn("rawLrc", payload["musicList"][0])
def test_search_artists_and_artist_detail_routes_return_musicfree_shape(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
client = TestClient(create_app())
search_response = client.get(
"/mf/v1/search/artists?q=Singer&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
detail_response = client.get(
"/mf/v1/artists/1",
headers=auth_headers(player_db_path),
)
tracks_response = client.get(
"/mf/v1/artists/1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, search_response.status_code)
self.assertEqual(200, detail_response.status_code)
self.assertEqual(200, tracks_response.status_code)
self.assertEqual("catalogsync:artist:1", search_response.json()["data"][0]["id"])
self.assertEqual(["music"], search_response.json()["data"][0]["supportedArtistTabs"])
self.assertEqual("Singer A", detail_response.json()["name"])
self.assertEqual("catalogsync:song:1", tracks_response.json()["musicList"][0]["id"])
def test_search_sheets_returns_playlists_and_toplists(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/search/sheets?q=1&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(
["catalogsync:playlist:1", "catalogsync:toplist:tl-1"],
[item["id"] for item in payload["data"]],
)
def test_playlist_tracks_only_returns_playable_rows(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/playlists/1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(1, len(payload["musicList"]))
self.assertEqual("catalogsync:song:1", payload["musicList"][0]["id"])
self.assertEqual("Playable Song", payload["musicList"][0]["title"])
def test_toplists_returns_playable_song_count(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/toplists",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(1, len(payload))
self.assertEqual(1, len(payload[0]["data"]))
toplist = payload[0]["data"][0]
self.assertEqual("catalogsync:toplist:tl-1", toplist["id"])
self.assertEqual(2, toplist["worksNum"])
self.assertEqual(1, toplist["playableSongCount"])
def test_toplist_tracks_only_returns_playable_rows(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/toplists/tl-1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(1, len(payload["musicList"]))
self.assertEqual("catalogsync:song:1", payload["musicList"][0]["id"])
self.assertEqual("Playable Song", payload["musicList"][0]["title"])
if __name__ == "__main__":
unittest.main()
+289
View File
@@ -0,0 +1,289 @@
import sqlite3
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from tests.support import auth_headers
class MfDetailRouteTests(unittest.TestCase):
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
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 not null,
source_meta text not null
);
create table catalog_playlist_tracks (
playlist_id integer not null,
song_id integer not null,
position integer not null,
primary key (playlist_id, song_id)
);
create table catalog_toplists (
toplist_id text primary key,
platform text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null,
group_name text not null
);
create table catalog_toplist_tracks (
toplist_id text not null,
song_id integer not null,
position integer not null,
primary key (toplist_id, song_id)
);
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_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(1, "netease", "18165", "playlist-1", "desc", "https://img/p.jpg", 2000, 2, 2),
)
conn.executemany(
"""
insert into catalog_tracks (
song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, source_meta
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
3476,
"netease",
"65800",
"song-3476",
"artist-a",
"album-a",
"https://img/s-3476.jpg",
220000,
"{}",
),
(
4001,
"qq",
"4001",
"song-4001",
"artist-b",
"album-b",
"https://img/s-4001.jpg",
180000,
"{}",
),
],
)
conn.executemany(
"""
insert into catalog_playlist_tracks (playlist_id, song_id, position)
values (?, ?, ?)
""",
[(1, 3476, 1), (1, 4001, 2)],
)
conn.execute(
"""
insert into catalog_toplists (
toplist_id, platform, name, description, cover_url, play_count, song_count, playable_song_count, group_name
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"kuwo_top_16",
"kuwo",
"\u9177\u6211\u98d9\u5347\u699c",
"desc",
"https://img/t.jpg",
1000,
2,
2,
"\u9177\u6211",
),
)
conn.executemany(
"""
insert into catalog_toplist_tracks (toplist_id, song_id, position)
values (?, ?, ?)
""",
[("kuwo_top_16", 3476, 1), ("kuwo_top_16", 4001, 2)],
)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
3476,
"super",
"flac",
100,
"object_storage",
"cdn",
"song-3476.flac",
"https://cdn/song-3476.flac",
"active",
1,
),
(
4001,
"standard",
"mp3",
90,
"object_storage",
"cdn",
"song-4001.mp3",
"https://cdn/song-4001.mp3",
"active",
1,
),
],
)
conn.commit()
conn.close()
@contextmanager
def _catalog_client(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
yield TestClient(create_app()), player_db_path
def test_get_toplist_detail_returns_musicfree_shape(self):
with self._catalog_client() as (client, player_db_path):
response = client.get(
"/mf/v1/toplists/kuwo_top_16",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("catalogsync:toplist:kuwo_top_16", payload["id"])
self.assertEqual("catalogsync", payload["platform"])
self.assertEqual("\u9177\u6211\u98d9\u5347\u699c", payload["title"])
def test_get_toplist_tracks_pagination(self):
with self._catalog_client() as (client, player_db_path):
first_page = client.get(
"/mf/v1/toplists/kuwo_top_16/tracks?page=1&page_size=1",
headers=auth_headers(player_db_path),
)
second_page = client.get(
"/mf/v1/toplists/kuwo_top_16/tracks?page=2&page_size=1",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, first_page.status_code)
self.assertFalse(first_page.json()["isEnd"])
self.assertEqual("catalogsync:song:3476", first_page.json()["musicList"][0]["id"])
self.assertEqual(200, second_page.status_code)
self.assertTrue(second_page.json()["isEnd"])
self.assertEqual("catalogsync:song:4001", second_page.json()["musicList"][0]["id"])
def test_get_toplist_detail_returns_404_when_missing(self):
with self._catalog_client() as (client, player_db_path):
response = client.get(
"/mf/v1/toplists/missing_toplist",
headers=auth_headers(player_db_path),
)
self.assertEqual(404, response.status_code)
def test_get_toplist_tracks_returns_404_when_missing(self):
with self._catalog_client() as (client, player_db_path):
response = client.get(
"/mf/v1/toplists/missing_toplist/tracks?page=1&page_size=1",
headers=auth_headers(player_db_path),
)
self.assertEqual(404, response.status_code)
def test_get_toplist_routes_require_token(self):
with self._catalog_client() as (client, _player_db_path):
detail_response = client.get("/mf/v1/toplists/kuwo_top_16")
tracks_response = client.get(
"/mf/v1/toplists/kuwo_top_16/tracks?page=1&page_size=1"
)
self.assertEqual(401, detail_response.status_code)
self.assertEqual(401, tracks_response.status_code)
def test_legacy_playlist_tracks_and_toplists_routes_still_work(self):
with self._catalog_client() as (client, player_db_path):
playlist_tracks_response = client.get(
"/mf/v1/playlists/1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
toplists_response = client.get(
"/mf/v1/toplists",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, playlist_tracks_response.status_code)
playlist_payload = playlist_tracks_response.json()
self.assertTrue(playlist_payload["musicList"])
self.assertEqual("catalogsync:song:3476", playlist_payload["musicList"][0]["id"])
self.assertEqual(200, toplists_response.status_code)
toplists_payload = toplists_response.json()
self.assertEqual("\u9177\u6211", toplists_payload[0]["title"])
if __name__ == "__main__":
unittest.main()
+578
View File
@@ -0,0 +1,578 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from music_server.services.cache_service import CacheService
from tests.support import auth_headers
class MfMediaRouteTests(unittest.TestCase):
def _prepare_catalog_db(
self,
db_path: Path,
*,
backend_type: str = "object_storage",
backend_name: str = "main-s3",
locator: str = "music/netease/test.flac",
public_url: str | None = "https://cdn.example/test.flac",
file_size_bytes: int = 42345678,
) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table catalog_track_files (
song_id integer not null,
quality_label text not null,
ext text not null,
file_size_bytes integer not null,
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_track_files (
song_id,
quality_label,
ext,
file_size_bytes,
backend_type,
backend_name,
locator,
public_url,
status,
is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
3476,
"super",
"flac",
file_size_bytes,
backend_type,
backend_name,
locator,
public_url,
"active",
1,
),
)
conn.commit()
conn.close()
def _prepare_active_cache(
self,
*,
player_db_path: Path,
catalog_db_path: Path,
song_id: int = 3476,
cache_url: str = "https://cache.example/test.flac",
status: str = "active",
) -> None:
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
target = service.create_cache_target(
name="edge-cache",
kind="s3",
order_index=1,
capacity_songs=10,
public_base_url="https://cache.example",
path_prefix="songs",
enabled=True,
secrets={"bucket": "music", "region": "test"},
)
service.upsert_cache_object(
song_id=song_id,
target_id=target["id"],
quality_label="super",
source_locator="cache/test.flac",
remote_key="songs/test.flac",
public_url=cache_url,
status=status,
last_rank=1,
uploaded_at="2026-04-23T00:00:00+00:00",
last_verified_at="2026-04-23T00:00:00+00:00",
evictable=False,
)
def test_media_stream_extension_route_registers_before_generic_route(self):
app = create_app()
stream_paths = [
route.path
for route in app.routes
if getattr(route, "path", "").startswith("/mf/v1/media/stream/")
]
self.assertEqual(
[
"/mf/v1/media/stream/{token}.{ext}",
"/mf/v1/media/stream/{token}",
],
stream_paths,
)
def test_media_resolve_returns_selected_source_and_signed_stream_url(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
},
clear=False,
):
client = TestClient(create_app())
response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("object_storage", payload["selected_source"]["kind"])
self.assertEqual("main-s3", payload["selected_source"]["backend"])
self.assertEqual("super", payload["selected_source"]["quality"])
self.assertEqual("flac", payload["selected_source"]["ext"])
self.assertEqual(42345678, payload["selected_source"]["size_bytes"])
self.assertIn("/mf/v1/media/stream/", payload["stream"]["url"])
self.assertTrue(payload["stream"]["url"].endswith(".flac"))
def test_media_stream_redirects_to_public_url(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
stream_url = resolve_response.json()["stream"]["url"]
stream_response = client.get(
stream_url,
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_resolve_prefers_active_cache_object(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
self._prepare_active_cache(player_db_path=player_db_path, catalog_db_path=db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
self.assertEqual("cache", resolve_response.json()["selected_source"]["kind"])
with patch("music_server.routes.mf_media._is_cache_url_reachable", return_value=True):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cache.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_resolve_falls_back_when_cache_object_is_not_active(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
self._prepare_active_cache(
player_db_path=player_db_path,
catalog_db_path=db_path,
status="failed",
)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
self.assertEqual("object_storage", resolve_response.json()["selected_source"]["kind"])
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_resolve_and_stream_ignore_cache_when_cache_relay_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
self._prepare_active_cache(player_db_path=player_db_path, catalog_db_path=db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
"MUSIC_SERVER_CACHE_RELAY_ENABLED": "0",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
self.assertEqual("object_storage", resolve_response.json()["selected_source"]["kind"])
with patch("music_server.routes.mf_media._is_cache_url_reachable", return_value=True):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
heat_service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(db_path),
secret_encryption_key="test-secret",
)
summary = heat_service.get_heat_summary(song_id=3476)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
self.assertEqual(0, summary["play_count_total"])
self.assertEqual(0, summary["play_count_30d"])
def test_media_stream_falls_back_when_cached_public_url_is_unreachable(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
self._prepare_active_cache(player_db_path=player_db_path, catalog_db_path=db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
with patch("music_server.routes.mf_media._is_cache_url_reachable", return_value=False):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_stream_falls_back_when_cached_public_url_is_unreachable(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
self._prepare_active_cache(player_db_path=player_db_path, catalog_db_path=db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
with patch("music_server.routes.mf_media._is_cache_url_reachable", return_value=False):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_stream_serves_local_file_when_public_url_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.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),
"PLAYER_DB_PATH": str(player_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=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
stream_url = resolve_response.json()["stream"]["url"]
stream_response = client.get(stream_url)
self.assertEqual(200, stream_response.status_code)
self.assertEqual(b"flac-bytes", stream_response.content)
self.assertEqual("bytes", stream_response.headers.get("accept-ranges"))
self.assertEqual("10", stream_response.headers.get("content-length"))
self.assertEqual("audio/flac", stream_response.headers.get("content-type"))
def test_media_stream_local_single_range_returns_206_and_partial_content(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.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"0123456789")
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),
"PLAYER_DB_PATH": str(player_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=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
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(b"2345", stream_response.content)
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"))
def test_media_stream_local_invalid_range_returns_416(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.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"0123456789")
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),
"PLAYER_DB_PATH": str(player_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=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
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", stream_response.headers.get("accept-ranges"))
self.assertEqual("bytes */10", stream_response.headers.get("content-range"))
def test_media_stream_counts_heat_once_for_repeated_stream_requests(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.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"0123456789")
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),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"LOCAL_LIBRARY_ROOT": str(library_root),
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
stream_url = resolve_response.json()["stream"]["url"]
full_response = client.get(stream_url)
range_response = client.get(stream_url, headers={"Range": "bytes=2-5"})
heat_service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(db_path),
secret_encryption_key="test-secret",
)
summary = heat_service.get_heat_summary(song_id=3476)
self.assertEqual(200, full_response.status_code)
self.assertEqual(206, range_response.status_code)
self.assertEqual(1, summary["play_count_total"])
self.assertEqual(1, summary["play_count_30d"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,82 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEPLOY_DOC = REPO_ROOT / "docs" / "nas-docker-deployment.md"
COMPOSE_FILE = REPO_ROOT / "docker-compose.nas.yml"
DEPLOY_ENTRY = REPO_ROOT / "deploy-music-server.ps1"
DEPLOY_PS = REPO_ROOT / "scripts" / "deploy_to_nas.ps1"
DEPLOY_PY = REPO_ROOT / "scripts" / "deploy_to_nas.py"
DEPLOY_TEMPLATE = REPO_ROOT / "scripts" / "templates" / "deploy_and_restart.sh"
ENV_EXAMPLE = REPO_ROOT / "config" / "music_server.env.example"
def load_deploy_module():
spec = importlib.util.spec_from_file_location("music_server_deploy_to_nas", DEPLOY_PY)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class NasDeployLayoutTests(unittest.TestCase):
def test_compose_uses_runtime_dirs_outside_repo_checkout(self):
compose_text = COMPOSE_FILE.read_text(encoding="utf-8")
self.assertIn("- ../config/music_server.env", compose_text)
self.assertIn("- ../data:/app/data", compose_text)
self.assertNotIn("- ./config/music_server.env", compose_text)
self.assertNotIn("- ./data:/app/data", compose_text)
def test_docs_and_scripts_reference_standard_music_cloud_layout(self):
deploy_text = DEPLOY_DOC.read_text(encoding="utf-8")
self.assertTrue(DEPLOY_ENTRY.exists(), f"missing deploy entry: {DEPLOY_ENTRY}")
self.assertTrue(DEPLOY_PS.exists(), f"missing deploy powershell: {DEPLOY_PS}")
self.assertTrue(DEPLOY_PY.exists(), f"missing deploy python: {DEPLOY_PY}")
self.assertTrue(DEPLOY_TEMPLATE.exists(), f"missing deploy template: {DEPLOY_TEMPLATE}")
self.assertIn("/volume4/Music_Cloud/Music_Server/app", deploy_text)
self.assertIn("/volume4/Music_Cloud/Music_Server/config/music_server.env", deploy_text)
self.assertIn("/volume4/Music_Cloud/Music_Server/data/catalog_read.db", deploy_text)
self.assertIn("/volume4/Music_Cloud/Music_Server/bin/deploy_and_restart.sh", deploy_text)
self.assertIn("deploy-music-server.ps1", deploy_text)
def test_deploy_helper_skips_generated_archives_and_cache_dirs(self):
module = load_deploy_module()
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "src").mkdir()
(root / "__pycache__").mkdir()
(root / ".git").mkdir()
(root / "src" / "app.py").write_text("print('ok')\n", encoding="utf-8")
(root / "__pycache__" / "ignored.pyc").write_bytes(b"123")
(root / ".git" / "config").write_text("[core]\n", encoding="utf-8")
(root / "music_server_deploy.tar").write_bytes(b"tar")
(root / "music_server_deploy.zip").write_bytes(b"zip")
actual = sorted(path.relative_to(root).as_posix() for path in module.iter_local_files(root))
self.assertEqual(actual, ["src/app.py"])
def test_deploy_template_removes_conflicting_fixed_name_music_server_container(self):
template_text = DEPLOY_TEMPLATE.read_text(encoding="utf-8")
self.assertIn(
"docker rm -f music-server >/dev/null 2>&1 || true",
template_text,
)
def test_env_example_and_docs_do_not_reference_wireguard_settings(self):
deploy_text = DEPLOY_DOC.read_text(encoding="utf-8")
env_example_text = ENV_EXAMPLE.read_text(encoding="utf-8")
self.assertNotIn("WIREGUARD_", deploy_text)
self.assertNotIn("WIREGUARD_", env_example_text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,45 @@
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEPLOY_DOC = REPO_ROOT / "docs" / "nas-docker-deployment.md"
SPEC_DOC = (
REPO_ROOT
/ "docs"
/ "superpowers"
/ "specs"
/ "2026-04-19-music-cloud-public-music-service-design.md"
)
LEGACY_APP = "/volume4/Music_Server/app"
TARGET_ROOT = "/volume4/Music_Cloud/Music_Server"
TARGET_APP = "/volume4/Music_Cloud/Music_Server/app"
REQUIRED_SPEC_ITEMS = (
"/volume4/Music_Cloud",
"/volume4/Music_Cloud/catalogsync",
"/volume4/Music_Cloud/catalogsync/data/catalogsync.db",
"/volume4/Music_Cloud/library",
"/volume4/Music_Cloud/playlists",
"catalogsync serve",
)
class NasDeploymentPathTests(unittest.TestCase):
def test_docs_use_music_cloud_host_root(self):
deploy_text = DEPLOY_DOC.read_text(encoding="utf-8")
spec_text = SPEC_DOC.read_text(encoding="utf-8")
self.assertIn(TARGET_ROOT, deploy_text)
self.assertIn(TARGET_APP, deploy_text)
self.assertNotIn(LEGACY_APP, deploy_text)
self.assertIn(TARGET_ROOT, spec_text)
self.assertIn(TARGET_APP, spec_text)
self.assertNotIn(LEGACY_APP, spec_text)
for item in REQUIRED_SPEC_ITEMS:
self.assertIn(item, spec_text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,145 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from tests.support import auth_headers
class PlayerHistoryRouteTests(unittest.TestCase):
def _prepare_player_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table play_history (
id integer primary key autoincrement,
track_id integer not null,
played_at text not null,
progress_seconds integer not null
);
create table favorite_playlists (
playlist_id integer primary key,
added_at text not null
);
"""
)
conn.commit()
conn.close()
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
)
"""
)
conn.execute(
"""
insert into catalog_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"netease",
"18165",
"测试歌单",
"desc",
"https://img/p.jpg",
1000,
1,
1,
),
)
conn.commit()
conn.close()
def test_history_home_and_list_routes(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_player_db(player_db_path)
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app())
post_response = client.post(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
json={"track_id": 3476, "progress_seconds": 12},
)
home_response = client.get(
"/player/v1/home",
headers=auth_headers(player_db_path),
)
history_response = client.get(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
)
self.assertEqual(201, post_response.status_code)
self.assertEqual(200, home_response.status_code)
self.assertEqual(200, history_response.status_code)
self.assertEqual(3476, history_response.json()["items"][0]["track_id"])
def test_record_history_rejects_invalid_payload(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_player_db(player_db_path)
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app(), raise_server_exceptions=False)
missing_track_id = client.post(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
json={"progress_seconds": 12},
)
invalid_track_id = client.post(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
json={"track_id": "abc", "progress_seconds": 12},
)
invalid_progress = client.post(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
json={"track_id": 3476, "progress_seconds": "xx"},
)
self.assertEqual(400, missing_track_id.status_code)
self.assertEqual(400, invalid_track_id.status_code)
self.assertEqual(400, invalid_progress.status_code)
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from tests.support import auth_headers
class PlayerRouteTests(unittest.TestCase):
def _prepare_player_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table favorite_tracks (
track_id integer primary key,
added_at text not null
)
"""
)
conn.commit()
conn.close()
def test_favorite_track_put_then_list(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
self._prepare_player_db(db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(db_path),
},
clear=False,
):
client = TestClient(create_app())
put_response = client.put(
"/player/v1/me/favorites/tracks/3476",
headers=auth_headers(db_path),
)
get_response = client.get(
"/player/v1/me/favorites/tracks",
headers=auth_headers(db_path),
)
self.assertEqual(204, put_response.status_code)
self.assertEqual(200, get_response.status_code)
self.assertEqual({"items": [{"track_id": 3476}]}, get_response.json())
def test_favorite_track_routes_work_with_fresh_empty_db(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(db_path),
},
clear=False,
):
client = TestClient(create_app())
put_response = client.put(
"/player/v1/me/favorites/tracks/8888",
headers=auth_headers(db_path),
)
get_response = client.get(
"/player/v1/me/favorites/tracks",
headers=auth_headers(db_path),
)
self.assertEqual(204, put_response.status_code)
self.assertEqual(200, get_response.status_code)
self.assertEqual({"items": [{"track_id": 8888}]}, get_response.json())
if __name__ == "__main__":
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
import unittest
from fastapi.testclient import TestClient
from music_server.app import create_app
class PluginRouteTests(unittest.TestCase):
def test_plugin_js_route_returns_importable_plugin_asset(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server.js")
self.assertEqual(200, response.status_code)
self.assertIn("javascript", response.headers.get("content-type", ""))
body = response.text
self.assertIn('platform: "Music_Server"', body)
self.assertIn('srcUrl: "http://testserver/plugins/music_server.js"', body)
self.assertNotIn("__MUSIC_SERVER_PLUGIN_SRC_URL__", body)
self.assertEqual(
"no-store, no-cache, must-revalidate, max-age=0",
response.headers.get("cache-control"),
)
def test_lan_plugin_js_route_returns_importable_plugin_asset(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, response.status_code)
self.assertIn("javascript", response.headers.get("content-type", ""))
body = response.text
self.assertIn('platform: "Music_Server_LAN"', body)
self.assertIn('srcUrl: "http://testserver/plugins/music_server_lan.js"', body)
self.assertNotIn("__MUSIC_SERVER_PLUGIN_SRC_URL__", body)
self.assertEqual(
"no-store, no-cache, must-revalidate, max-age=0",
response.headers.get("cache-control"),
)
def test_private_plugin_asset_exposes_artist_and_sheet_support(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server.js")
self.assertEqual(200, response.status_code)
body = response.text
self.assertIn('supportedSearchType: ["music", "artist", "sheet"]', body)
self.assertIn("/mf/v1/search/artists", body)
self.assertIn("/mf/v1/search/sheets", body)
self.assertIn("function getArtistWorks(", body)
def test_lan_plugin_asset_exposes_artist_and_sheet_support(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, response.status_code)
body = response.text
self.assertIn('supportedSearchType: ["music", "artist", "sheet"]', body)
self.assertIn("/mf/v1/search/artists", body)
self.assertIn("/mf/v1/search/sheets", body)
self.assertIn("function getArtistWorks(", body)
def test_plugin_assets_preserve_raw_lrc_field(self):
client = TestClient(create_app())
private_response = client.get("/plugins/music_server.js")
lan_response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, private_response.status_code)
self.assertEqual(200, lan_response.status_code)
self.assertIn("rawLrc", private_response.text)
self.assertIn("rawLrc", lan_response.text)
def test_plugin_assets_use_play_count_field_only(self):
client = TestClient(create_app())
private_response = client.get("/plugins/music_server.js")
lan_response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, private_response.status_code)
self.assertEqual(200, lan_response.status_code)
self.assertIn("item.play_count", private_response.text)
self.assertIn("result.play_count", private_response.text)
self.assertNotIn("item.playCount", private_response.text)
self.assertNotIn("result.playCount", private_response.text)
self.assertIn("item.play_count", lan_response.text)
self.assertIn("result.play_count", lan_response.text)
self.assertNotIn("item.playCount", lan_response.text)
self.assertNotIn("result.playCount", lan_response.text)
def test_plugin_assets_expose_get_lyric_method(self):
client = TestClient(create_app())
private_response = client.get("/plugins/music_server.js")
lan_response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, private_response.status_code)
self.assertEqual(200, lan_response.status_code)
self.assertIn("async function getLyric(", private_response.text)
self.assertIn('"/mf/v1/songs/" + songId + "/lyric"', private_response.text)
self.assertIn("getLyric: getLyric", private_response.text)
self.assertIn("async function getLyric(", lan_response.text)
self.assertIn('"/mf/v1/songs/" + songId + "/lyric"', lan_response.text)
self.assertIn("getLyric: getLyric", lan_response.text)
def test_plugin_manifest_route_returns_plugin_url(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server.json")
self.assertEqual(200, response.status_code)
self.assertIn("application/json", response.headers.get("content-type", ""))
payload = response.json()
self.assertEqual(2, len(payload["plugins"]))
self.assertEqual(
[
{
"name": "Music_Server",
"url": "http://testserver/plugins/music_server.js",
},
{
"name": "Music_Server LAN",
"url": "http://testserver/plugins/music_server_lan.js",
},
],
payload["plugins"],
)
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
import io
import runpy
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from music_server.services.token_service import TokenService
class TokenCliTests(unittest.TestCase):
def test_issue_token_prints_plaintext_token_and_expiry(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(db_path)}, clear=False):
with patch("sys.argv", ["issue_token", "--days", "90", "--label", "iphone16"]):
buffer = io.StringIO()
with redirect_stdout(buffer):
runpy.run_module("music_server.tools.issue_token", run_name="__main__")
output = buffer.getvalue()
self.assertIn("token_id=", output)
self.assertIn("token=", output)
self.assertIn("expires_at=", output)
def test_unbind_and_revoke_commands_mutate_service_state(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="android")
service.authenticate(issued.plaintext_token, "client-a", "Pixel")
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(db_path)}, clear=False):
with patch("sys.argv", ["unbind_token", "--token-id", issued.token_id]):
runpy.run_module("music_server.tools.unbind_token", run_name="__main__")
with patch(
"sys.argv",
["revoke_token", "--token-id", issued.token_id, "--reason", "replaced"],
):
runpy.run_module("music_server.tools.revoke_token", run_name="__main__")
listed = service.list_tokens(include_revoked=True)
self.assertIsNone(listed[0]["bound_client_id"])
self.assertEqual("replaced", listed[0]["revoked_reason"])
+283
View File
@@ -0,0 +1,283 @@
import sqlite3
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import music_server.services.token_service as token_service_module
from music_server.services.token_service import TokenService
class _RaceInjectingConnection:
def __init__(self, conn: sqlite3.Connection, race_state: dict[str, bool]) -> None:
self._conn = conn
self._race_state = race_state
self._injecting = False
def execute(self, sql: str, parameters=()):
normalized_sql = " ".join(sql.lower().split())
is_bind_update = (
normalized_sql.startswith("update access_tokens")
and "set bound_client_id = ?, bound_client_label = ?, bound_at = ?, last_seen_at = ?" in normalized_sql
and "where token_id = ? and bound_client_id is null" in normalized_sql
)
if is_bind_update and not self._injecting and not self._race_state["done"]:
token_id = parameters[4]
self._injecting = True
try:
self._conn.execute(
"""
update access_tokens
set bound_client_id = ?, bound_client_label = ?, bound_at = ?, last_seen_at = ?
where token_id = ? and bound_client_id is null
""",
(
"racer-client",
"Race Winner",
"2000-01-01T00:00:00+00:00",
"2000-01-01T00:00:00+00:00",
token_id,
),
)
self._race_state["done"] = True
finally:
self._injecting = False
if parameters is None:
return self._conn.execute(sql)
return self._conn.execute(sql, parameters)
def __getattr__(self, name: str):
return getattr(self._conn, name)
class _RevokeBeforeBindConnection:
def __init__(self, conn: sqlite3.Connection, race_state: dict[str, bool]) -> None:
self._conn = conn
self._race_state = race_state
self._injecting = False
def execute(self, sql: str, parameters=()):
normalized_sql = " ".join(sql.lower().split())
is_bind_update = (
normalized_sql.startswith("update access_tokens")
and "set bound_client_id = ?, bound_client_label = ?, bound_at = ?, last_seen_at = ?" in normalized_sql
and "where token_id = ? and bound_client_id is null" in normalized_sql
)
if is_bind_update and not self._injecting and not self._race_state["done"]:
token_id = parameters[4]
self._injecting = True
try:
self._conn.execute(
"""
update access_tokens
set revoked_at = ?, revoked_reason = ?
where token_id = ?
""",
(
"2000-01-01T00:00:00+00:00",
"revoked-during-bind-race",
token_id,
),
)
self._race_state["done"] = True
finally:
self._injecting = False
if parameters is None:
return self._conn.execute(sql)
return self._conn.execute(sql, parameters)
def __getattr__(self, name: str):
return getattr(self._conn, name)
class TokenServiceTests(unittest.TestCase):
def _build_racing_connect(self):
race_state = {"done": False}
real_connect = token_service_module.connect_sqlite
def racing_connect(db_path: str):
return _RaceInjectingConnection(real_connect(db_path), race_state)
return racing_connect
def _build_revoke_before_bind_connect(self):
race_state = {"done": False}
real_connect = token_service_module.connect_sqlite
def racing_connect(db_path: str):
return _RevokeBeforeBindConnection(real_connect(db_path), race_state)
return racing_connect
def test_issue_token_persists_hash_and_listable_metadata(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="iphone16")
listed = service.list_tokens(include_revoked=True)
self.assertTrue(issued.plaintext_token.startswith("msv1_"))
self.assertEqual("iphone16", listed[0]["label"])
self.assertEqual(issued.token_id, listed[0]["token_id"])
self.assertIsNone(listed[0]["bound_client_id"])
conn = sqlite3.connect(db_path)
row = conn.execute(
"select token_hash, expires_at from access_tokens where token_id = ?",
(issued.token_id,),
).fetchone()
conn.close()
self.assertIsNotNone(row)
self.assertNotEqual(issued.plaintext_token, row[0])
def test_authenticate_binds_first_client_and_reuses_same_client(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="ipad")
first = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice iPad",
)
second = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice iPad",
)
third = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-b",
client_label="Other Device",
)
self.assertTrue(first.valid)
self.assertTrue(second.valid)
self.assertEqual("token_bound_to_other_client", third.error_code)
def test_unbind_and_revoke_change_future_auth_outcome(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="android")
service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Pixel",
)
service.unbind_token(issued.token_id)
rebound = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-b",
client_label="New Pixel",
)
service.revoke_token(issued.token_id, reason="replaced")
revoked = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-b",
client_label="New Pixel",
)
self.assertTrue(rebound.valid)
self.assertEqual("token_revoked", revoked.error_code)
def test_authenticate_returns_bound_other_when_first_bind_loses_race(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="race")
with patch(
"music_server.services.token_service.connect_sqlite",
side_effect=self._build_racing_connect(),
):
result = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice Phone",
)
self.assertFalse(result.valid)
self.assertEqual("token_bound_to_other_client", result.error_code)
self.assertEqual("racer-client", result.bound_client_id)
def test_authenticate_compares_expiration_by_datetime_not_string(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="offset-expiry")
future_utc = datetime.now(timezone.utc) + timedelta(minutes=5)
future_with_negative_offset = future_utc.astimezone(
timezone(timedelta(hours=-12))
).isoformat()
conn = sqlite3.connect(db_path)
conn.execute(
"update access_tokens set expires_at = ? where token_id = ?",
(future_with_negative_offset, issued.token_id),
)
conn.commit()
conn.close()
result = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Offset Device",
)
self.assertTrue(result.valid)
self.assertIsNone(result.error_code)
def test_status_uses_final_binding_state_when_first_bind_loses_race(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="status-race")
with patch(
"music_server.services.token_service.connect_sqlite",
side_effect=self._build_racing_connect(),
):
payload = service.status(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice Phone",
)
self.assertFalse(payload["valid"])
self.assertEqual("token_bound_to_other_client", payload["status"])
self.assertTrue(payload["bound"])
self.assertFalse(payload["isCurrentClientBound"])
self.assertEqual("Race Winner", payload["boundClientLabel"])
def test_authenticate_does_not_pass_when_token_revoked_before_first_bind(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="revoke-race")
with patch(
"music_server.services.token_service.connect_sqlite",
side_effect=self._build_revoke_before_bind_connect(),
):
result = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice Phone",
)
self.assertFalse(result.valid)
self.assertEqual("token_revoked", result.error_code)
if __name__ == "__main__":
unittest.main()