# 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.py` - `python -m music_server.tools.issue_token` - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\list_tokens.py` - `python -m music_server.tools.list_tokens` - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\revoke_token.py` - `python -m music_server.tools.revoke_token` - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\tools\unbind_token.py` - `python -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` 单测。 - `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.cjs` - `node: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`。 - `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()`。 - `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** ```python 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** ```python 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** ```bash 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** ```python 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"]) ``` ```python 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()) ``` ```python 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** ```python 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) ``` ```python 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") ``` ```python 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 ``` ```python 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 ``` ```python 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** ```bash 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** ```python 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** ```python 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) ``` ```python 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() ``` ```python 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() ``` ```python 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() ``` ```python 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** ```bash 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** ```typescript 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** ```typescript 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; } ``` ```typescript import { nanoid } from "nanoid"; import { ensureRuntimeClientId } from "./runtimeClient"; interface IPluginMetaStorage { $version: number; order: Record; disabledPlugins: Array; [key: `${IPluginPlatform}.alternativePlugin`]: IPluginPlatform | null; [key: `${IPluginPlatform}.userVariables`]: Record; [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(), ); } ``` ```typescript 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** ```bash 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** ```javascript 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** ```javascript 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, }; ``` ```typescript 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; } ``` ```typescript async getPluginStatus(): Promise { 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; } } ``` ```bash 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** ```bash 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** ```typescript import { formatPluginTokenStatus } from "./formatPluginTokenStatus"; describe("formatPluginTokenStatus", () => { const t = (key: string, params?: Record) => { 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"); }); }); ``` ```tsx 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( , ); 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** ```typescript export function formatPluginTokenStatus( payload: IPlugin.IPluginStatusResult | null, t: (key: string, params?: Record) => 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"); } ``` ```typescript 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 }; } ``` ```tsx import usePluginTokenStatus from "../hooks/usePluginTokenStatus"; function _PluginItem(props: IPluginItemProps) { const { plugin } = props; const tokenStatus = usePluginTokenStatus(plugin); return ( {plugin.name} {plugin.instance.description?.length ? ( { showDialog("MarkdownDialog", { title: plugin.name, markdownContent: plugin.instance.description!, }); }} /> ) : null} { pluginManager.setPluginEnabled(plugin, val); }} /> {t("pluginSetting.pluginItem.versionHint", { version: plugin.instance.version, })} {plugin.instance.author ? ( {t("pluginSetting.pluginItem.author", { author: plugin.instance.author, })} ) : null} {tokenStatus.text ? ( {tokenStatus.text} ) : null} {alternativePluginName ? ( {t("pluginSetting.pluginItem.alternativePlugin", { name: alternativePluginName, })} ) : null} {options.map((it, index) => it.show !== false ? ( {it.title} ) : null, )} ); } ``` ```json { "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** ```bash 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** ````md ## 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 " -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** ```md ## 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 ` 后重新打开插件管理页,确认新设备可绑定成功。 ``` - [ ] **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 " -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":` Run: `python -m music_server.tools.list_tokens` Expected: one row containing the new `token_id` and `smoke-client` - [ ] **Step 5: Commit** ```bash 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" ```