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