64 KiB
Music_Server Token Binding 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: 为 Music_Server 增加“单 token 单终端首绑 + token 时效 + 状态查询 + 管理命令”,并让 MusicFree 插件列表页显示 token 剩余时间、绑定状态和当前可播歌曲数。
Architecture: 服务端把 token 元数据写入 player.db,由 TokenService 统一负责签发、绑定、过期、撤销和状态计算;FastAPI 认证依赖与 /auth/v1/token-status 路由只做请求头解析、TokenService 编排以及 CatalogReader 的全局可播歌曲数聚合。MusicFree 侧为插件持久化随机 clientId,插件资产统一带上 X-Music-Client-Id 请求头并暴露 getPluginStatus(),客户端插件列表页负责把 token 状态与“可播 N 首”组合渲染。
Tech Stack: Python 3.11, FastAPI, sqlite3, unittest/pytest, TypeScript, React Native, Jest, Node node:test
File Map
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\token_service.py- 新增 token 领域服务,负责 schema、签发、列表、绑定、解绑、撤销、状态计算。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\auth.py- 从“字符串比较”升级为“Bearer + clientId + TokenService”认证依赖。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\routes\auth.py- 新增
/auth/v1/token-status状态接口,并合并全局可播歌曲数。
- 新增
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\catalog_reader.py- 追加
count_playable_tracks(),统一统计当前至少有一个 active 文件位置的歌曲总数。
- 追加
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\app.py- 挂载新 auth router。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\__init__.py- CLI 包入口。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\_common.py- CLI 共用参数与 service 构造。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\issue_token.pypython -m music_server.tools.issue_token
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\list_tokens.pypython -m music_server.tools.list_tokens
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\revoke_token.pypython -m music_server.tools.revoke_token
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\unbind_token.pypython -m music_server.tools.unbind_token
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_service.py- 新增 token service 单测。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_auth_routes.py- 新增 token status 路由、认证依赖与
playableSongCount单测。
- 新增 token status 路由、认证依赖与
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py- 为
count_playable_tracks()补读模型统计单测。
- 为
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_cli.py- 新增 CLI 行为单测。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\support.py- 新增测试公共认证头构造,统一补
X-Music-Client-Id。
- 新增测试公共认证头构造,统一补
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_health.py- 调整认证依赖断言,覆盖
client_id_missing。
- 调整认证依赖断言,覆盖
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_catalog_routes.py- 接入统一认证头 helper。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_media_routes.py- 接入统一认证头 helper。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_detail_routes.py- 接入统一认证头 helper。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_player_routes.py- 接入统一认证头 helper。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_player_history_routes.py- 接入统一认证头 helper。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server.js- 公网发布给 MusicFree 订阅安装的插件资产。
D:\source\MusicFree\keep-alive-master\Music_Free\music_server.js- 本地可调试插件脚本副本。
D:\source\MusicFree\keep-alive-master\Music_Free\music_server.test.cjsnode:test插件脚本测试。
D:\source\MusicFree\release\music_server_latest.js- 手工导入用最新发布副本。
D:\source\MusicFree\release\music_server_final.js- 手工导入用稳定发布副本。
D:\source\MusicFree\src\core\pluginManager\meta.ts- 扩展插件私有 runtime 存储,持久化
runtimeClientId。
- 扩展插件私有 runtime 存储,持久化
D:\source\MusicFree\src\core\pluginManager\runtimeClient.ts- 新增 runtime client helper,生成并复用稳定 clientId。
D:\source\MusicFree\src\core\pluginManager\runtimeClient.test.ts- 新增 runtime client helper 单测。
D:\source\MusicFree\src\core\pluginManager\plugin.ts- 向插件运行环境注入 runtime 值,并在 wrapper 中支持
getPluginStatus()。
- 向插件运行环境注入 runtime 值,并在 wrapper 中支持
D:\source\MusicFree\src\types\plugin.d.ts- 补
IPluginStatusResult、playableSongCount与getPluginStatus()类型。
- 补
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\hooks\usePluginTokenStatus.ts- 新增列表页状态拉取 hook。
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\utils\formatPluginTokenStatus.ts- 新增状态到展示文案的纯函数,并在有效时拼接“可播 N 首”。
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\utils\formatPluginTokenStatus.test.ts- 新增状态文案纯函数测试。
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\components\pluginItem.tsx- 展示 token 剩余时间/状态。
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\components\pluginItem.test.tsx- 新增插件列表项状态展示测试。
D:\source\MusicFree\src\core\i18n\languages\zh-cn.json- 新增中文 token 状态文案。
D:\source\MusicFree\src\core\i18n\languages\en-us.json- 新增英文 token 状态文案。
D:\source\MusicFree\src\core\i18n\languages\zh-tw.json- 新增繁中 token 状态文案。
D:\source\MusicFree\src\types\core\i18n\index.d.ts- 增补新文案 key 类型。
D:\source\musicdl-catalog-sync-worktrees\Music_Server\docs\nas-docker-deployment.md- 追加 CLI 签发与 token status smoke 检查说明。
Task 1: Build Music_Server TokenService
Files:
-
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\token_service.py -
Test:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_service.py -
Step 1: Write the failing service tests
import sqlite3
import tempfile
import unittest
from pathlib import Path
from music_server.services.token_service import TokenService
class TokenServiceTests(unittest.TestCase):
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)
- Step 2: Run test to verify it fails
Run: python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_service.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'music_server.services.token_service'
- Step 3: Write the minimal implementation
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 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_iso = datetime.now(timezone.utc).isoformat()
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select *
from access_tokens
where token_hash = ?
""",
(token_hash,),
).fetchone()
if row is None:
return AuthResult(False, "token_not_found", None, None, None)
if row["revoked_at"]:
return AuthResult(False, "token_revoked", row["token_id"], row["bound_client_id"], row["expires_at"])
if row["expires_at"] <= now_iso:
return AuthResult(False, "token_expired", row["token_id"], row["bound_client_id"], row["expires_at"])
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", row["token_id"], bound_client_id, row["expires_at"])
if not bound_client_id:
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
""",
(client_id, client_label, now_iso, now_iso, row["token_id"]),
)
else:
conn.execute(
"update access_tokens set last_seen_at = ?, bound_client_label = ? where token_id = ?",
(now_iso, client_label or row["bound_client_label"], row["token_id"]),
)
conn.commit()
return AuthResult(True, None, row["token_id"], client_id, row["expires_at"])
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()
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select *
from access_tokens
where token_hash = ?
""",
(token_hash,),
).fetchone()
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 = self.authenticate(
plaintext_token=plaintext_token,
client_id=client_id,
client_label=client_label,
)
with closing(connect_sqlite(self._db_path)) as conn:
fresh = conn.execute(
"""
select *
from access_tokens
where token_id = ?
""",
(row["token_id"],),
).fetchone()
expires_at = datetime.fromisoformat(fresh["expires_at"])
remaining_seconds = max(int((expires_at - datetime.now(timezone.utc)).total_seconds()), 0)
remaining_days = remaining_seconds // 86400
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()
- Step 4: Run test to verify it passes
Run: python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_service.py -q
Expected: 3 passed
- Step 5: Commit
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server add src/music_server/services/token_service.py tests/test_token_service.py
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server commit -m "feat: add token service"
Task 2: Wire FastAPI Auth Dependency And Status Route
Files:
-
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\catalog_reader.py -
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\routes\auth.py -
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_auth_routes.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py -
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\support.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\auth.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\app.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_health.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_catalog_routes.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_media_routes.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_detail_routes.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_player_routes.py -
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_player_history_routes.py -
Step 1: Write the failing route and auth tests
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.catalog_reader import CatalogReader
from music_server.services.token_service import TokenService
class AuthRouteTests(unittest.TestCase):
def test_token_status_returns_active_payload_for_bound_client(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="iphone16")
catalog_conn = sqlite3.connect(catalog_db_path)
catalog_conn.executescript(
"""
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
);
insert into catalog_track_files values
(1, 'SQ', 'flac', 100, 'local', 'nas', '/music/1.flac', null, 'active', 1),
(1, 'HQ', 'mp3', 90, 'local', 'nas', '/music/1.mp3', null, 'inactive', 0),
(2, 'SQ', 'flac', 120, 'local', 'nas', '/music/2.flac', null, 'active', 1),
(3, 'SQ', 'flac', 140, 'local', 'nas', '/music/3.flac', null, 'failed', 1);
"""
)
catalog_conn.commit()
catalog_conn.close()
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/auth/v1/token-status",
headers={
"Authorization": f"Bearer {issued.plaintext_token}",
"X-Music-Client-Id": "client-a",
"X-Music-Client-Label": "Alice iPhone",
},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("active", payload["status"])
self.assertTrue(payload["valid"])
self.assertTrue(payload["isCurrentClientBound"])
self.assertEqual(2, payload["playableSongCount"])
def test_token_status_returns_client_id_missing_in_body(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
issued = TokenService(str(db_path)).issue_token(days=90, label="iphone16")
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(db_path)}, clear=False):
client = TestClient(create_app())
response = client.get(
"/auth/v1/token-status",
headers={"Authorization": f"Bearer {issued.plaintext_token}"},
)
self.assertEqual(200, response.status_code)
self.assertEqual("client_id_missing", response.json()["status"])
def test_count_playable_tracks_counts_distinct_active_song_ids(self):
conn = sqlite3.connect(self._db_path)
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, "SQ", "flac", 100, "local", "nas", "/music/1.flac", None, "active", 1),
(1, "HQ", "mp3", 90, "local", "nas", "/music/1.mp3", None, "active", 0),
(2, "SQ", "flac", 120, "local", "nas", "/music/2.flac", None, "inactive", 1),
(3, "SQ", "flac", 130, "local", "nas", "/music/3.flac", None, "active", 1),
],
)
conn.commit()
conn.close()
reader = CatalogReader(db_path=str(self._db_path))
self.assertEqual(2, reader.count_playable_tracks())
def auth_headers(token: str = "dev-token", client_id: str = "musicfree-test-client") -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"X-Music-Client-Id": client_id,
"X-Music-Client-Label": "MusicFree Test Device",
}
- Step 2: Run test to verify it fails
Run: 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
Expected: FAIL because /auth/v1/token-status does not exist and require_bearer_token() does not inspect X-Music-Client-Id
- Step 3: Write minimal route and dependency implementation
def count_playable_tracks(self) -> int:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
"""
select count(distinct song_id) as total
from catalog_track_files
where status = 'active'
"""
).fetchone()
return int((dict(row).get("total") if row else 0) or 0)
from fastapi import Header, HTTPException
from .services.token_service import TokenService
from .settings import get_settings
def _token_service() -> TokenService:
return TokenService(db_path=get_settings().player_db_path)
def parse_bearer_token(authorization: str | None) -> str:
if authorization is None:
raise HTTPException(status_code=401, detail="unauthorized")
parts = authorization.strip().split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(status_code=401, detail="unauthorized")
return parts[1].strip()
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:
token = parse_bearer_token(authorization)
result = _token_service().authenticate(
plaintext_token=token,
client_id=x_music_client_id,
client_label=x_music_client_label,
)
if not result.valid:
raise HTTPException(status_code=401, detail=result.error_code or "unauthorized")
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")
def _token_service() -> TokenService:
return TokenService(db_path=get_settings().player_db_path)
def _catalog_reader() -> CatalogReader:
return CatalogReader(db_path=get_settings().catalog_db_path)
@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:
token = parse_bearer_token(authorization)
payload = _token_service().status(
plaintext_token=token,
client_id=x_music_client_id,
client_label=x_music_client_label,
)
if payload["status"] == "active":
payload["playableSongCount"] = _catalog_reader().count_playable_tracks()
return payload
from .routes.auth import router as auth_router
def create_app() -> FastAPI:
app = FastAPI(title="Public Music Service")
app.include_router(health_router)
app.include_router(auth_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
from .support import auth_headers
response = client.get("/mf/v1/toplists", headers=auth_headers())
wrong = client.get("/mf/v1/toplists", headers=auth_headers(token="wrong-token"))
- Step 4: Run tests to verify wiring passes
Run: 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
Expected: PASS, and all protected-route tests use the new auth_headers() helper while /auth/v1/token-status returns playableSongCount
- Step 5: Commit
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server add src/music_server/services/catalog_reader.py src/music_server/auth.py src/music_server/routes/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
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server commit -m "feat: enforce token binding auth"
Task 3: Add Token Management CLI Commands
Files:
-
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\__init__.py -
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\_common.py -
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\issue_token.py -
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\list_tokens.py -
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\revoke_token.py -
Create:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\unbind_token.py -
Test:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_cli.py -
Step 1: Write the failing CLI tests
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"])
- Step 2: Run test to verify it fails
Run: python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_cli.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'music_server.tools.issue_token'
- Step 3: Write the CLI modules
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)
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()
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()
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()
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()
- Step 4: Run test to verify it passes
Run: python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_token_cli.py -q
Expected: 2 passed
- Step 5: Commit
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server add src/music_server/tools/__init__.py src/music_server/tools/_common.py src/music_server/tools/issue_token.py src/music_server/tools/list_tokens.py src/music_server/tools/revoke_token.py src/music_server/tools/unbind_token.py tests/test_token_cli.py
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server commit -m "feat: add token management cli"
Task 4: Persist Runtime Client Identity In MusicFree
Files:
-
Create:
D:\source\MusicFree\src\core\pluginManager\runtimeClient.ts -
Test:
D:\source\MusicFree\src\core\pluginManager\runtimeClient.test.ts -
Modify:
D:\source\MusicFree\src\core\pluginManager\meta.ts -
Modify:
D:\source\MusicFree\src\core\pluginManager\plugin.ts -
Step 1: Write the failing runtime client tests
import { ensureRuntimeClientId } from "./runtimeClient";
describe("ensureRuntimeClientId", () => {
it("returns stored id when one already exists", () => {
const getValue = jest.fn(() => "stored-client-id");
const setValue = jest.fn();
const value = ensureRuntimeClientId(getValue, setValue, () => "new-client-id");
expect(value).toBe("stored-client-id");
expect(setValue).not.toHaveBeenCalled();
});
it("creates and persists a new id when storage is empty", () => {
const getValue = jest.fn(() => "");
const setValue = jest.fn();
const value = ensureRuntimeClientId(getValue, setValue, () => "generated-client-id");
expect(value).toBe("generated-client-id");
expect(setValue).toHaveBeenCalledWith("generated-client-id");
});
});
- Step 2: Run test to verify it fails
Run: npm --prefix D:\source\MusicFree test -- --runInBand src/core/pluginManager/runtimeClient.test.ts
Expected: FAIL with Cannot find module './runtimeClient'
- Step 3: Write the runtime storage plumbing
export function ensureRuntimeClientId(
getValue: () => string | null | undefined,
setValue: (value: string) => void,
createId: () => string,
) {
const current = `${getValue() ?? ""}`.trim();
if (current) {
return current;
}
const generated = createId();
setValue(generated);
return generated;
}
import { nanoid } from "nanoid";
import { ensureRuntimeClientId } from "./runtimeClient";
interface IPluginMetaStorage {
$version: number;
order: Record<IPluginPlatform, number>;
disabledPlugins: Array<IPluginPlatform>;
[key: `${IPluginPlatform}.alternativePlugin`]: IPluginPlatform | null;
[key: `${IPluginPlatform}.userVariables`]: Record<string, string>;
[key: `${IPluginPlatform}.runtime.runtimeClientId`]: string;
[key: `${IPluginPlatform}.runtime.runtimeClientLabel`]: string;
}
getRuntimeValue(pluginPlatform: IPluginPlatform, key: "runtimeClientId" | "runtimeClientLabel") {
return this.getMetaStorage(`${pluginPlatform}.runtime.${key}` as keyof IPluginMetaStorage) ?? "";
}
setRuntimeValue(pluginPlatform: IPluginPlatform, key: "runtimeClientId" | "runtimeClientLabel", value: string) {
this.setMetaStorage(`${pluginPlatform}.runtime.${key}` as keyof IPluginMetaStorage, value);
}
getOrCreateRuntimeClientId(pluginPlatform: IPluginPlatform) {
return ensureRuntimeClientId(
() => this.getRuntimeValue(pluginPlatform, "runtimeClientId"),
(value) => this.setRuntimeValue(pluginPlatform, "runtimeClientId", value),
() => nanoid(),
);
}
const pluginName = this.name;
const env = {
getUserVariables: () => _internalPluginMeta.getUserVariables(pluginName),
getRuntimeValue: (key: "runtimeClientId" | "runtimeClientLabel") => {
if (key === "runtimeClientId") {
return _internalPluginMeta.getOrCreateRuntimeClientId(pluginName);
}
return _internalPluginMeta.getRuntimeValue(pluginName, "runtimeClientLabel");
},
get runtimeValues() {
return {
runtimeClientId: _internalPluginMeta.getOrCreateRuntimeClientId(pluginName),
runtimeClientLabel: _internalPluginMeta.getRuntimeValue(pluginName, "runtimeClientLabel"),
};
},
get userVariables() {
return this.getUserVariables() ?? {};
},
appVersion,
os: "android",
lang: "zh-CN",
};
- Step 4: Run test to verify it passes
Run: npm --prefix D:\source\MusicFree test -- --runInBand src/core/pluginManager/runtimeClient.test.ts
Expected: PASS, and a fresh install path will now generate a stable runtimeClientId
- Step 5: Commit
git -C D:\source\MusicFree add src/core/pluginManager/runtimeClient.ts src/core/pluginManager/runtimeClient.test.ts src/core/pluginManager/meta.ts src/core/pluginManager/plugin.ts
git -C D:\source\MusicFree commit -m "feat: persist runtime client id for plugins"
Task 5: Upgrade Music_Server Plugin Asset To Send Client Headers And Fetch Status
Files:
-
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server.js -
Modify:
D:\source\MusicFree\keep-alive-master\Music_Free\music_server.js -
Modify:
D:\source\MusicFree\keep-alive-master\Music_Free\music_server.test.cjs -
Modify:
D:\source\MusicFree\release\music_server_latest.js -
Modify:
D:\source\MusicFree\release\music_server_final.js -
Modify:
D:\source\MusicFree\src\types\plugin.d.ts -
Modify:
D:\source\MusicFree\src\core\pluginManager\plugin.ts -
Step 1: Add failing plugin asset tests
test("default client sends Authorization and X-Music-Client-Id headers", async () => {
const plugin = loadPluginFresh();
plugin.__clearTestState();
plugin.__setConfigForTests({
baseUrl: "https://music-server.example.com",
accessToken: "token-a",
runtimeClientId: "client-a",
runtimeClientLabel: "Alice iPhone",
});
let createdConfig = null;
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
if (request === "axios") {
return {
create: (config) => {
createdConfig = config;
return { get() {}, post() {} };
},
};
}
return originalLoad(request, parent, isMain);
}, async () => {
plugin.getClient();
});
assert.equal(createdConfig.headers.Authorization, "Bearer token-a");
assert.equal(createdConfig.headers["X-Music-Client-Id"], "client-a");
assert.equal(createdConfig.headers["X-Music-Client-Label"], "Alice iPhone");
});
test("getPluginStatus requests /auth/v1/token-status", async () => {
const plugin = loadPluginFresh();
plugin.__clearTestState();
const stub = createHttpClientStub((call) => {
if (call.url === "/auth/v1/token-status") {
return {
data: {
valid: true,
status: "active",
remainingDays: 89,
playableSongCount: 12345,
isCurrentClientBound: true,
},
};
}
throw new Error(`Unexpected request: ${call.url}`);
});
plugin.__setHttpClientForTests(stub.client);
const result = await plugin.getPluginStatus();
assert.deepEqual(stub.calls, [{ url: "/auth/v1/token-status", params: undefined }]);
assert.deepEqual(result, {
valid: true,
status: "active",
remainingDays: 89,
playableSongCount: 12345,
isCurrentClientBound: true,
});
});
- Step 2: Run test to verify it fails
Run: node --test D:\source\MusicFree\keep-alive-master\Music_Free\music_server.test.cjs
Expected: FAIL because buildConfigSnapshot() does not expose runtime client values and plugin script has no getPluginStatus()
- Step 3: Update plugin asset, wrapper types, and published copies
function buildConfigSnapshot() {
return {
baseUrl: normalizeBaseUrl(readConfigValue("baseUrl")),
accessToken: String(readConfigValue("accessToken") || ""),
runtimeClientId: String(readConfigValue("runtimeClientId") || readRuntimeValue("runtimeClientId") || ""),
runtimeClientLabel: String(readConfigValue("runtimeClientLabel") || readRuntimeValue("runtimeClientLabel") || ""),
};
}
function readRuntimeValue(key) {
if (testConfig && Object.prototype.hasOwnProperty.call(testConfig, key)) {
return testConfig[key];
}
let runtimeEnv = null;
if (typeof env !== "undefined" && env) {
runtimeEnv = env;
} else if (typeof globalThis !== "undefined" && globalThis.env) {
runtimeEnv = globalThis.env;
}
if (runtimeEnv && typeof runtimeEnv.getRuntimeValue === "function") {
try {
return runtimeEnv.getRuntimeValue(key);
} catch (_error) {
return undefined;
}
}
if (runtimeEnv && runtimeEnv.runtimeValues && typeof runtimeEnv.runtimeValues === "object") {
return runtimeEnv.runtimeValues[key];
}
return undefined;
}
function createDefaultClient(snapshot) {
const axios = require("axios");
const currentSnapshot = snapshot || buildConfigSnapshot();
const headers = {};
if (currentSnapshot.accessToken) {
headers.Authorization = `Bearer ${currentSnapshot.accessToken}`;
}
if (currentSnapshot.runtimeClientId) {
headers["X-Music-Client-Id"] = currentSnapshot.runtimeClientId;
}
if (currentSnapshot.runtimeClientLabel) {
headers["X-Music-Client-Label"] = currentSnapshot.runtimeClientLabel;
}
return axios.create({
baseURL: currentSnapshot.baseUrl || undefined,
headers,
});
}
async function getPluginStatus() {
try {
const payload = await requestGet("/auth/v1/token-status");
return payload && typeof payload === "object" ? payload : null;
} catch (_error) {
return null;
}
}
module.exports = {
platform: "Music_Server",
version: "0.0.3",
author: "Codex",
srcUrl: "__MUSIC_SERVER_PLUGIN_SRC_URL__",
cacheControl: "no-cache",
primaryKey: ["id"],
description: "Music_Server private plugin for playlists, toplists, search, playback, and token status.",
supportedSearchType: ["music"],
userVariables: [
{ key: "baseUrl", name: "Base URL" },
{ key: "accessToken", name: "Access Token" },
],
search,
getMediaSource,
getRecommendSheetTags,
getRecommendSheetsByTag,
getMusicSheetInfo,
getTopLists,
getTopListDetail,
getPluginStatus,
};
export interface IPluginStatusResult {
valid: boolean;
status: string;
tokenId?: string | null;
label?: string | null;
issuedAt?: string | null;
expiresAt?: string | null;
remainingSeconds?: number | null;
remainingDays?: number | null;
playableSongCount?: number | null;
bound?: boolean;
isCurrentClientBound?: boolean;
boundClientLabel?: string | null;
}
interface IPluginDefine {
getPluginStatus?: () => Promise<IPluginStatusResult | null>;
}
async getPluginStatus(): Promise<IPlugin.IPluginStatusResult | null> {
await this.ensurePluginIsMounted();
if (!this.plugin.instance.getPluginStatus) {
return null;
}
try {
return (await this.plugin.instance.getPluginStatus()) ?? null;
} catch (e: any) {
devLog("error", "getPluginStatus failed", e, e?.message);
return null;
}
}
Copy-Item D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server.js D:\source\MusicFree\keep-alive-master\Music_Free\music_server.js -Force
Copy-Item D:\source\MusicFree\keep-alive-master\Music_Free\music_server.js D:\source\MusicFree\release\music_server_latest.js -Force
Copy-Item D:\source\MusicFree\keep-alive-master\Music_Free\music_server.js D:\source\MusicFree\release\music_server_final.js -Force
- Step 4: Run tests to verify plugin behavior passes
Run: node --test D:\source\MusicFree\keep-alive-master\Music_Free\music_server.test.cjs
Expected: PASS, including new assertions for X-Music-Client-Id and /auth/v1/token-status
- Step 5: Commit
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server add src/music_server/plugin_assets/music_server.js
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server commit -m "feat: publish token-aware music server plugin asset"
git -C D:\source\MusicFree add keep-alive-master/Music_Free/music_server.js keep-alive-master/Music_Free/music_server.test.cjs release/music_server_latest.js release/music_server_final.js src/types/plugin.d.ts src/core/pluginManager/plugin.ts
git -C D:\source\MusicFree commit -m "feat: add plugin token status support"
Task 6: Show Token Status In MusicFree Plugin List
Files:
-
Create:
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\hooks\usePluginTokenStatus.ts -
Create:
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\utils\formatPluginTokenStatus.ts -
Create:
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\utils\formatPluginTokenStatus.test.ts -
Create:
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\components\pluginItem.test.tsx -
Modify:
D:\source\MusicFree\src\pages\setting\settingTypes\pluginSetting\components\pluginItem.tsx -
Modify:
D:\source\MusicFree\src\core\i18n\languages\zh-cn.json -
Modify:
D:\source\MusicFree\src\core\i18n\languages\en-us.json -
Modify:
D:\source\MusicFree\src\core\i18n\languages\zh-tw.json -
Modify:
D:\source\MusicFree\src\types\core\i18n\index.d.ts -
Step 1: Write the failing formatter and component tests
import { formatPluginTokenStatus } from "./formatPluginTokenStatus";
describe("formatPluginTokenStatus", () => {
const t = (key: string, params?: Record<string, any>) => {
if (key === "pluginSetting.pluginItem.tokenStatus.remainingDays") {
return `Token 剩余 ${params?.days} 天`;
}
if (key === "pluginSetting.pluginItem.tokenStatus.playableCount") {
return `可播 ${params?.count} 首`;
}
return key;
};
it("maps active tokens to remaining-days copy and playable count", () => {
expect(
formatPluginTokenStatus(
{
valid: true,
status: "active",
remainingDays: 89,
playableSongCount: 12345,
isCurrentClientBound: true,
} as any,
t as any,
),
).toEqual("Token 剩余 89 天 · 可播 12345 首");
});
it("maps bound_to_other_client to a blocking message", () => {
expect(
formatPluginTokenStatus(
{
valid: false,
status: "bound_to_other_client",
} as any,
t as any,
),
).toEqual("pluginSetting.pluginItem.tokenStatus.boundOther");
});
});
import React from "react";
import renderer from "react-test-renderer";
import PluginItem from "./pluginItem";
let mockThemeTextValues: string[] = [];
jest.mock("@/components/base/themeText", () => {
return ({ children }: any) => {
if (children) {
mockThemeTextValues.push(String(children));
}
return children ?? null;
};
});
jest.mock("../hooks/usePluginTokenStatus", () => ({
__esModule: true,
default: () => ({
text: "Token 剩余 89 天 · 可播 12345 首",
tone: "success",
loading: false,
}),
}));
describe("PluginItem", () => {
beforeEach(() => {
mockThemeTextValues = [];
});
it("renders token status line for Music_Server plugin", () => {
renderer.create(
<PluginItem
plugin={{
name: "Music_Server",
hash: "hash-1",
instance: { version: "0.0.3", author: "Codex", srcUrl: "http://example/plugins/music_server.js" },
supportedMethods: new Set(["getPluginStatus"]),
} as any}
/>,
);
expect(mockThemeTextValues.join(" ")).toContain("Token 剩余 89 天 · 可播 12345 首");
});
});
- Step 2: Run test to verify it fails
Run: npm --prefix D:\source\MusicFree test -- --runInBand src/pages/setting/settingTypes/pluginSetting/utils/formatPluginTokenStatus.test.ts src/pages/setting/settingTypes/pluginSetting/components/pluginItem.test.tsx
Expected: FAIL because formatter/hook do not exist and pluginItem.tsx renders no token status line
- Step 3: Implement formatter, hook, UI, and i18n keys
export function formatPluginTokenStatus(
payload: IPlugin.IPluginStatusResult | null,
t: (key: string, params?: Record<string, any>) => string,
) {
if (!payload) {
return t("pluginSetting.pluginItem.tokenStatus.fetchFailed");
}
if (payload.status === "active") {
const segments: string[] = [];
if ((payload.remainingDays ?? 0) > 0) {
segments.push(t("pluginSetting.pluginItem.tokenStatus.remainingDays", {
days: payload.remainingDays,
}));
} else {
segments.push(t("pluginSetting.pluginItem.tokenStatus.expiresToday"));
}
if (typeof payload.playableSongCount === "number") {
segments.push(
t("pluginSetting.pluginItem.tokenStatus.playableCount", {
count: payload.playableSongCount,
}),
);
}
return segments.join(" · ");
}
if (payload.status === "expired") {
return t("pluginSetting.pluginItem.tokenStatus.expired");
}
if (payload.status === "revoked") {
return t("pluginSetting.pluginItem.tokenStatus.revoked");
}
if (payload.status === "bound_to_other_client") {
return t("pluginSetting.pluginItem.tokenStatus.boundOther");
}
if (payload.status === "token_not_found") {
return t("pluginSetting.pluginItem.tokenStatus.invalid");
}
if (payload.status === "client_id_missing") {
return t("pluginSetting.pluginItem.tokenStatus.clientIdMissing");
}
return t("pluginSetting.pluginItem.tokenStatus.fetchFailed");
}
import { useEffect, useState } from "react";
import { Plugin } from "@/core/pluginManager";
import { useI18N } from "@/core/i18n";
import { formatPluginTokenStatus } from "../utils/formatPluginTokenStatus";
export default function usePluginTokenStatus(plugin: Plugin) {
const { t } = useI18N();
const [text, setText] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
let disposed = false;
if (plugin.name !== "Music_Server" || !plugin.supportedMethods.has("getPluginStatus")) {
setText("");
return;
}
setLoading(true);
plugin.methods.getPluginStatus()
.then((payload) => {
if (!disposed) {
setText(formatPluginTokenStatus(payload, t));
}
})
.catch(() => {
if (!disposed) {
setText(t("pluginSetting.pluginItem.tokenStatus.fetchFailed"));
}
})
.finally(() => {
if (!disposed) {
setLoading(false);
}
});
return () => {
disposed = true;
};
}, [plugin, t]);
return { text, loading };
}
import usePluginTokenStatus from "../hooks/usePluginTokenStatus";
function _PluginItem(props: IPluginItemProps) {
const { plugin } = props;
const tokenStatus = usePluginTokenStatus(plugin);
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<View style={styles.header}>
<View style={styles.headerPluginContainer}>
<ThemeText numberOfLines={1} fontSize="title">
{plugin.name}
</ThemeText>
{plugin.instance.description?.length ? (
<IconButton
name="question-mark-circle"
sizeType="light"
onPress={() => {
showDialog("MarkdownDialog", {
title: plugin.name,
markdownContent: plugin.instance.description!,
});
}}
/>
) : null}
</View>
<ThemeSwitch
value={enabled}
onValueChange={(val) => {
pluginManager.setPluginEnabled(plugin, val);
}}
/>
</View>
<View style={styles.description}>
<ThemeText fontSize="subTitle" fontColor="textSecondary">
{t("pluginSetting.pluginItem.versionHint", {
version: plugin.instance.version,
})}
</ThemeText>
{plugin.instance.author ? (
<ThemeText
fontSize="subTitle"
fontColor="textSecondary"
numberOfLines={1}
style={styles.author}
>
{t("pluginSetting.pluginItem.author", {
author: plugin.instance.author,
})}
</ThemeText>
) : null}
</View>
{tokenStatus.text ? (
<View style={styles.tokenStatusRow}>
<ThemeText fontSize="subTitle" fontColor="textSecondary">
{tokenStatus.text}
</ThemeText>
</View>
) : null}
{alternativePluginName ? (
<View style={styles.alternativePluginDescription}>
<ThemeText fontSize="subTitle" fontColor="textSecondary">
{t("pluginSetting.pluginItem.alternativePlugin", {
name: alternativePluginName,
})}
</ThemeText>
</View>
) : null}
<View style={styles.contents}>
{options.map((it, index) =>
it.show !== false ? (
<IconTextButton
key={index}
icon={it.icon}
onPress={it.onPress}
>
{it.title}
</IconTextButton>
) : null,
)}
</View>
</View>
);
}
{
"pluginSetting.pluginItem.tokenStatus.remainingDays": "Token 剩余 {days} 天",
"pluginSetting.pluginItem.tokenStatus.expiresToday": "Token 今日到期",
"pluginSetting.pluginItem.tokenStatus.playableCount": "可播 {count} 首",
"pluginSetting.pluginItem.tokenStatus.expired": "Token 已过期",
"pluginSetting.pluginItem.tokenStatus.revoked": "Token 已撤销",
"pluginSetting.pluginItem.tokenStatus.boundOther": "Token 已绑定其他终端",
"pluginSetting.pluginItem.tokenStatus.invalid": "Token 无效",
"pluginSetting.pluginItem.tokenStatus.clientIdMissing": "Token 缺少终端标识",
"pluginSetting.pluginItem.tokenStatus.fetchFailed": "Token 状态获取失败"
}
- Step 4: Run tests to verify UI behavior passes
Run: npm --prefix D:\source\MusicFree test -- --runInBand src/pages/setting/settingTypes/pluginSetting/utils/formatPluginTokenStatus.test.ts src/pages/setting/settingTypes/pluginSetting/components/pluginItem.test.tsx src/components/musicSheetPage/components/header.test.tsx
Expected: PASS, and the existing playlist count header test remains green
- Step 5: Commit
git -C D:\source\MusicFree add src/pages/setting/settingTypes/pluginSetting/hooks/usePluginTokenStatus.ts src/pages/setting/settingTypes/pluginSetting/utils/formatPluginTokenStatus.ts src/pages/setting/settingTypes/pluginSetting/utils/formatPluginTokenStatus.test.ts src/pages/setting/settingTypes/pluginSetting/components/pluginItem.tsx src/pages/setting/settingTypes/pluginSetting/components/pluginItem.test.tsx src/core/i18n/languages/zh-cn.json src/core/i18n/languages/en-us.json src/core/i18n/languages/zh-tw.json src/types/core/i18n/index.d.ts
git -C D:\source\MusicFree commit -m "feat: show music server token status in plugin list"
Task 7: Document And Smoke-Test The Full Flow
Files:
-
Modify:
D:\source\musicdl-catalog-sync-worktrees\Music_Server\docs\nas-docker-deployment.md -
Step 1: Write the failing documentation diff
## Token Operations
Issue token:
```bash
python -m music_server.tools.issue_token --days 90 --label iphone16
```
List tokens:
```bash
python -m music_server.tools.list_tokens
```
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
```
- Step 2: Run the verification commands
Run: python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests -q
Expected: PASS
Run: node --test D:\source\MusicFree\keep-alive-master\Music_Free\music_server.test.cjs
Expected: PASS
Run: npm --prefix D:\source\MusicFree test -- --runInBand src/core/pluginManager/runtimeClient.test.ts src/pages/setting/settingTypes/pluginSetting/utils/formatPluginTokenStatus.test.ts src/pages/setting/settingTypes/pluginSetting/components/pluginItem.test.tsx
Expected: PASS
- Step 3: Update deployment docs and do a live smoke checklist
## MusicFree Token Status Smoke
1. 用 `python -m music_server.tools.issue_token --days 90 --label iphone16` 签发一个新 token。
2. 在 MusicFree 的 `Music_Server` 插件里填写新的 `baseUrl` 和 `accessToken`。
3. 打开插件管理页,确认显示 `Token 剩余 89 天 · 可播 12345 首` 或等价文案。
4. 在另一台设备复用同一个 token,确认插件管理页显示 `Token 已绑定其他终端`。
5. 执行 `python -m music_server.tools.unbind_token --token-id <tok_xxx>` 后重新打开插件管理页,确认新设备可绑定成功。
- Step 4: Run the exact smoke commands
Run: python -m music_server.tools.issue_token --days 90 --label smoke-device
Expected: prints token_id=, token=, expires_at=
Run: curl -H "Authorization: Bearer <paste-issued-token>" -H "X-Music-Client-Id: smoke-client" http://127.0.0.1:18081/auth/v1/token-status
Expected: JSON with "status":"active"、"isCurrentClientBound":true and "playableSongCount":<number>
Run: python -m music_server.tools.list_tokens
Expected: one row containing the new token_id and smoke-client
- Step 5: Commit
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server add docs/nas-docker-deployment.md
git -C D:\source\musicdl-catalog-sync-worktrees\Music_Server commit -m "docs: add token operation and smoke instructions"