Files
musicdl-catalog-sync-suite/Music_Server/docs/superpowers/plans/2026-04-20-music-server-token-binding-implementation.md
T

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.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
    • IPluginStatusResultplayableSongCountgetPluginStatus() 类型。
  • 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"