tunetidy 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. tunetidy-1.0.0/.github/workflows/release.yml +47 -0
  2. tunetidy-1.0.0/.github/workflows/test.yml +36 -0
  3. tunetidy-1.0.0/.gitignore +43 -0
  4. tunetidy-1.0.0/LICENSE +21 -0
  5. tunetidy-1.0.0/PKG-INFO +140 -0
  6. tunetidy-1.0.0/README.en.md +122 -0
  7. tunetidy-1.0.0/README.md +118 -0
  8. tunetidy-1.0.0/docs/ARCHITECTURE.md +109 -0
  9. tunetidy-1.0.0/pyproject.toml +74 -0
  10. tunetidy-1.0.0/scripts/evaluate.py +40 -0
  11. tunetidy-1.0.0/scripts/hooks/personal_scan.py +117 -0
  12. tunetidy-1.0.0/scripts/hooks/pre-commit +5 -0
  13. tunetidy-1.0.0/scripts/hooks/pre-push +14 -0
  14. tunetidy-1.0.0/src/tunetidy/__init__.py +3 -0
  15. tunetidy-1.0.0/src/tunetidy/analyze.py +444 -0
  16. tunetidy-1.0.0/src/tunetidy/apply/__init__.py +1 -0
  17. tunetidy-1.0.0/src/tunetidy/apply/approval.py +126 -0
  18. tunetidy-1.0.0/src/tunetidy/apply/undo.py +204 -0
  19. tunetidy-1.0.0/src/tunetidy/apply/writer.py +220 -0
  20. tunetidy-1.0.0/src/tunetidy/backends/__init__.py +34 -0
  21. tunetidy-1.0.0/src/tunetidy/backends/base.py +73 -0
  22. tunetidy-1.0.0/src/tunetidy/backends/web/__init__.py +12 -0
  23. tunetidy-1.0.0/src/tunetidy/backends/web/backend.py +241 -0
  24. tunetidy-1.0.0/src/tunetidy/backends/web/client.py +174 -0
  25. tunetidy-1.0.0/src/tunetidy/backends/web/crypto.py +86 -0
  26. tunetidy-1.0.0/src/tunetidy/backends/web/parse.py +178 -0
  27. tunetidy-1.0.0/src/tunetidy/backends/web/session.py +122 -0
  28. tunetidy-1.0.0/src/tunetidy/classify/__init__.py +1 -0
  29. tunetidy-1.0.0/src/tunetidy/classify/pipeline.py +229 -0
  30. tunetidy-1.0.0/src/tunetidy/classify/rules.py +38 -0
  31. tunetidy-1.0.0/src/tunetidy/classify/scoring.py +53 -0
  32. tunetidy-1.0.0/src/tunetidy/classify/split.py +183 -0
  33. tunetidy-1.0.0/src/tunetidy/classify/taxonomy.py +85 -0
  34. tunetidy-1.0.0/src/tunetidy/cli.py +462 -0
  35. tunetidy-1.0.0/src/tunetidy/config.py +141 -0
  36. tunetidy-1.0.0/src/tunetidy/evaluate.py +60 -0
  37. tunetidy-1.0.0/src/tunetidy/export.py +210 -0
  38. tunetidy-1.0.0/src/tunetidy/jobs.py +227 -0
  39. tunetidy-1.0.0/src/tunetidy/mcp/__init__.py +1 -0
  40. tunetidy-1.0.0/src/tunetidy/mcp/server.py +75 -0
  41. tunetidy-1.0.0/src/tunetidy/mcp/tools.py +206 -0
  42. tunetidy-1.0.0/src/tunetidy/models.py +105 -0
  43. tunetidy-1.0.0/src/tunetidy/plan.py +134 -0
  44. tunetidy-1.0.0/src/tunetidy/server/__init__.py +1 -0
  45. tunetidy-1.0.0/src/tunetidy/server/api.py +342 -0
  46. tunetidy-1.0.0/src/tunetidy/server/app.py +138 -0
  47. tunetidy-1.0.0/src/tunetidy/server/static/assets/index-CRt4YQCx.css +2 -0
  48. tunetidy-1.0.0/src/tunetidy/server/static/assets/index-Dj1Vs3CY.js +11 -0
  49. tunetidy-1.0.0/src/tunetidy/server/static/index.html +14 -0
  50. tunetidy-1.0.0/src/tunetidy/signals/__init__.py +1 -0
  51. tunetidy-1.0.0/src/tunetidy/signals/official.py +63 -0
  52. tunetidy-1.0.0/src/tunetidy/storage/__init__.py +1 -0
  53. tunetidy-1.0.0/src/tunetidy/storage/db.py +251 -0
  54. tunetidy-1.0.0/src/tunetidy/storage/jobs.py +157 -0
  55. tunetidy-1.0.0/src/tunetidy/storage/plans.py +197 -0
  56. tunetidy-1.0.0/src/tunetidy/storage/repo.py +169 -0
  57. tunetidy-1.0.0/src/tunetidy/storage/runs.py +159 -0
  58. tunetidy-1.0.0/taxonomies/examples/mixed.toml +21 -0
  59. tunetidy-1.0.0/taxonomies/examples/pop-only.toml +8 -0
  60. tunetidy-1.0.0/taxonomies/netease_style_tree.json +6597 -0
  61. tunetidy-1.0.0/tests/__init__.py +0 -0
  62. tunetidy-1.0.0/tests/apply/__init__.py +0 -0
  63. tunetidy-1.0.0/tests/apply/test_approval.py +217 -0
  64. tunetidy-1.0.0/tests/apply/test_undo.py +231 -0
  65. tunetidy-1.0.0/tests/apply/test_writer.py +300 -0
  66. tunetidy-1.0.0/tests/backends/__init__.py +0 -0
  67. tunetidy-1.0.0/tests/backends/web/__init__.py +0 -0
  68. tunetidy-1.0.0/tests/backends/web/fake_netease.py +87 -0
  69. tunetidy-1.0.0/tests/backends/web/test_backend.py +319 -0
  70. tunetidy-1.0.0/tests/backends/web/test_backend_writes.py +129 -0
  71. tunetidy-1.0.0/tests/backends/web/test_crypto.py +30 -0
  72. tunetidy-1.0.0/tests/backends/web/test_parse.py +104 -0
  73. tunetidy-1.0.0/tests/backends/web/test_session.py +66 -0
  74. tunetidy-1.0.0/tests/classify/__init__.py +0 -0
  75. tunetidy-1.0.0/tests/classify/helpers.py +50 -0
  76. tunetidy-1.0.0/tests/classify/test_pipeline.py +141 -0
  77. tunetidy-1.0.0/tests/classify/test_rules_and_taxonomy.py +89 -0
  78. tunetidy-1.0.0/tests/classify/test_scoring.py +54 -0
  79. tunetidy-1.0.0/tests/classify/test_split.py +157 -0
  80. tunetidy-1.0.0/tests/classify/test_split_pipeline.py +245 -0
  81. tunetidy-1.0.0/tests/cli/__init__.py +0 -0
  82. tunetidy-1.0.0/tests/cli/conftest.py +45 -0
  83. tunetidy-1.0.0/tests/cli/test_cli.py +233 -0
  84. tunetidy-1.0.0/tests/cli/test_cli_split.py +104 -0
  85. tunetidy-1.0.0/tests/conftest.py +28 -0
  86. tunetidy-1.0.0/tests/fake_keyring.py +23 -0
  87. tunetidy-1.0.0/tests/fakes.py +188 -0
  88. tunetidy-1.0.0/tests/fixtures/netease/account_get.json +57 -0
  89. tunetidy-1.0.0/tests/fixtures/netease/login_qr_check_801.json +4 -0
  90. tunetidy-1.0.0/tests/fixtures/netease/login_qr_check_802.json +6 -0
  91. tunetidy-1.0.0/tests/fixtures/netease/login_qr_check_803.json +4 -0
  92. tunetidy-1.0.0/tests/fixtures/netease/login_qr_unikey.json +4 -0
  93. tunetidy-1.0.0/tests/fixtures/netease/playlist_detail.json +9970 -0
  94. tunetidy-1.0.0/tests/fixtures/netease/song_detail.json +3359 -0
  95. tunetidy-1.0.0/tests/fixtures/netease/song_detail_edge_cases.json +316 -0
  96. tunetidy-1.0.0/tests/fixtures/netease/song_wiki_0.json +2975 -0
  97. tunetidy-1.0.0/tests/fixtures/netease/song_wiki_1.json +2981 -0
  98. tunetidy-1.0.0/tests/fixtures/netease/style_list.json +9828 -0
  99. tunetidy-1.0.0/tests/fixtures/netease/user_playlist.json +247 -0
  100. tunetidy-1.0.0/tests/live/__init__.py +0 -0
  101. tunetidy-1.0.0/tests/live/test_order_live.py +40 -0
  102. tunetidy-1.0.0/tests/live/test_web_live.py +31 -0
  103. tunetidy-1.0.0/tests/mcp/__init__.py +0 -0
  104. tunetidy-1.0.0/tests/mcp/test_server.py +141 -0
  105. tunetidy-1.0.0/tests/mcp/test_tools.py +127 -0
  106. tunetidy-1.0.0/tests/server/__init__.py +0 -0
  107. tunetidy-1.0.0/tests/server/conftest.py +46 -0
  108. tunetidy-1.0.0/tests/server/test_frontend.py +87 -0
  109. tunetidy-1.0.0/tests/server/test_read_api.py +179 -0
  110. tunetidy-1.0.0/tests/server/test_security.py +115 -0
  111. tunetidy-1.0.0/tests/server/test_write_api.py +186 -0
  112. tunetidy-1.0.0/tests/storage/__init__.py +0 -0
  113. tunetidy-1.0.0/tests/storage/test_db.py +129 -0
  114. tunetidy-1.0.0/tests/storage/test_jobs.py +132 -0
  115. tunetidy-1.0.0/tests/storage/test_plans.py +62 -0
  116. tunetidy-1.0.0/tests/test_analyze.py +192 -0
  117. tunetidy-1.0.0/tests/test_backends_registry.py +18 -0
  118. tunetidy-1.0.0/tests/test_config.py +62 -0
  119. tunetidy-1.0.0/tests/test_evaluate.py +79 -0
  120. tunetidy-1.0.0/tests/test_export.py +147 -0
  121. tunetidy-1.0.0/tests/test_isolation.py +44 -0
  122. tunetidy-1.0.0/tests/test_jobs.py +224 -0
  123. tunetidy-1.0.0/tests/test_keyring_guard.py +10 -0
  124. tunetidy-1.0.0/tests/test_move.py +194 -0
  125. tunetidy-1.0.0/tests/test_personal_scan.py +114 -0
  126. tunetidy-1.0.0/tests/test_pre_push.py +27 -0
  127. tunetidy-1.0.0/tests/test_reclassify.py +147 -0
  128. tunetidy-1.0.0/tests/test_rename.py +123 -0
  129. tunetidy-1.0.0/tests/test_ungrouped.py +182 -0
  130. tunetidy-1.0.0/tests/test_version.py +13 -0
  131. tunetidy-1.0.0/uv.lock +1571 -0
  132. tunetidy-1.0.0/web/index.html +13 -0
  133. tunetidy-1.0.0/web/package-lock.json +1577 -0
  134. tunetidy-1.0.0/web/package.json +24 -0
  135. tunetidy-1.0.0/web/src/App.tsx +41 -0
  136. tunetidy-1.0.0/web/src/api.ts +206 -0
  137. tunetidy-1.0.0/web/src/edit.ts +63 -0
  138. tunetidy-1.0.0/web/src/hooks.ts +57 -0
  139. tunetidy-1.0.0/web/src/index.css +210 -0
  140. tunetidy-1.0.0/web/src/layout.tsx +65 -0
  141. tunetidy-1.0.0/web/src/main.tsx +16 -0
  142. tunetidy-1.0.0/web/src/pages/Confirm.tsx +121 -0
  143. tunetidy-1.0.0/web/src/pages/Home.tsx +97 -0
  144. tunetidy-1.0.0/web/src/pages/Pending.tsx +110 -0
  145. tunetidy-1.0.0/web/src/pages/Result.tsx +387 -0
  146. tunetidy-1.0.0/web/src/pages/SongTable.tsx +124 -0
  147. tunetidy-1.0.0/web/src/pages/Songs.tsx +204 -0
  148. tunetidy-1.0.0/web/src/pages/SplitEditor.tsx +319 -0
  149. tunetidy-1.0.0/web/src/router.ts +73 -0
  150. tunetidy-1.0.0/web/src/split.ts +110 -0
  151. tunetidy-1.0.0/web/src/ui.tsx +374 -0
  152. tunetidy-1.0.0/web/tsconfig.json +18 -0
  153. tunetidy-1.0.0/web/vite.config.ts +24 -0
@@ -0,0 +1,47 @@
1
+ name: release
2
+
3
+ # 推送 v* 标签时构建并发布到 PyPI。用 PyPI 可信发布(trusted publishing),仓库里不存任何 token;
4
+ # 需要先在 PyPI 的项目设置里把本仓库的这个工作流、环境 pypi 登记为可信发布者。
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ - uses: astral-sh/setup-uv@v10.3.3
18
+ - uses: actions/setup-node@v7
19
+ with:
20
+ node-version: 22
21
+ cache: npm
22
+ cache-dependency-path: web/package-lock.json
23
+ - name: 标签必须与版本号一致
24
+ run: |
25
+ version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
26
+ test "v$version" = "$GITHUB_REF_NAME" || { echo "标签 $GITHUB_REF_NAME 与版本 $version 不一致"; exit 1; }
27
+ - run: uv sync
28
+ - run: uv run pytest
29
+ - run: cd web && npm ci && npm run build
30
+ - run: uv build
31
+ - uses: actions/upload-artifact@v7
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+
36
+ publish:
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment: pypi
40
+ permissions:
41
+ id-token: write
42
+ steps:
43
+ - uses: actions/download-artifact@v8
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,36 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os: [ubuntu-latest, macos-latest]
16
+ runs-on: ${{ matrix.os }}
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ - uses: astral-sh/setup-uv@v10.3.3
20
+ - uses: actions/setup-node@v7
21
+ with:
22
+ node-version: 22
23
+ cache: npm
24
+ cache-dependency-path: web/package-lock.json
25
+ - run: uv sync
26
+ - run: uv run ruff format --check . && uv run ruff check .
27
+ - run: uv run mypy src
28
+ # 默认不跑 live(真实接口)测试;钥匙串由 tests/conftest.py 强制换成假实现
29
+ - run: >-
30
+ uv run pytest --cov=tunetidy.classify --cov=tunetidy.apply --cov=tunetidy.storage
31
+ --cov-fail-under=80
32
+ - name: 构建前端和安装包,检查网页文件打进了包里
33
+ run: |
34
+ cd web && npm ci && npm run build && cd ..
35
+ uv build
36
+ python -c "import sys, zipfile, glob; names = zipfile.ZipFile(glob.glob('dist/*.whl')[0]).namelist(); sys.exit(0 if 'tunetidy/server/static/index.html' in names else 'wheel 里没有前端构建产物')"
@@ -0,0 +1,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .pytest_cache/
8
+ .coverage
9
+ htmlcov/
10
+ dist/
11
+ build/
12
+ *.egg-info/
13
+
14
+ # 登录态与凭据:绝不提交
15
+ *.cookie
16
+ *cookie*.json
17
+ storage-state*
18
+ .env
19
+ .env.*
20
+
21
+ # 原始接口录制(未脱敏,绝不提交)
22
+ .spike-data/
23
+ .tunetidy-spike/
24
+ *.qr.png
25
+
26
+ # Node
27
+ node_modules/
28
+
29
+ # OS
30
+ .DS_Store
31
+
32
+ # 前端构建产物:发布时构建后打进安装包,不提交
33
+ src/tunetidy/server/static/
34
+
35
+ # 本地数据库(含个人歌单数据):绝不提交
36
+ *.db
37
+ *.db-wal
38
+ *.db-shm
39
+ *.sqlite
40
+ *.sqlite3
41
+
42
+ # Claude Code 本机设置
43
+ .claude/settings.local.json
tunetidy-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xhhhz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.5
2
+ Name: tunetidy
3
+ Version: 1.0.0
4
+ Summary: 理音:把网易云歌单按曲风拆成多个新歌单,本地生成整理结果,人工确认后再写回(非官方项目)。
5
+ Project-URL: Homepage, https://github.com/xhhhz/tunetidy
6
+ Project-URL: Issues, https://github.com/xhhhz/tunetidy/issues
7
+ Author: xhhhz
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: cryptography
12
+ Requires-Dist: fastapi
13
+ Requires-Dist: httpx
14
+ Requires-Dist: keyring
15
+ Requires-Dist: mcp<3,>=2
16
+ Requires-Dist: platformdirs
17
+ Requires-Dist: qrcode
18
+ Requires-Dist: rich
19
+ Requires-Dist: typer
20
+ Requires-Dist: uvicorn
21
+ Description-Content-Type: text/markdown
22
+
23
+ # 理音 TuneTidy
24
+
25
+ 中文 | [English](README.en.md)
26
+
27
+ 把网易云歌单按曲风拆成几个新歌单:先在本地生成整理结果,你看过、调整过、亲自确认后,才在账号里新建歌单。
28
+
29
+ > **非官方项目**,与网易云音乐及其运营方无关,也未获其授权。只读写你自己账号里的歌单信息,不下载音频。详见文末[免责声明](#免责声明)。
30
+
31
+ <!-- 演示动图:docs/demo.gif(由作者录制) -->
32
+
33
+ ## 30 秒上手
34
+
35
+ ```bash
36
+ pipx install tunetidy
37
+ tunetidy login # 用网易云 App 扫码登录,登录态存进系统钥匙串
38
+ tunetidy playlists # 看看有哪些歌单
39
+ tunetidy analyze "我喜欢的音乐" # 读取曲风、生成整理(不改账号;首次约每首 1.5 秒,之后有缓存)
40
+ tunetidy ui # 在网页里看结果、换拆法、改歌单名、把分错的歌挪走
41
+ tunetidy approve <整理id> # 你本人在终端确认(要输入屏幕上显示的确认码)
42
+ tunetidy apply <整理id> # 新建私密歌单并加歌;中断后再运行即续传
43
+ ```
44
+
45
+ 不满意可以撤销:`tunetidy undo <整理id或运行id>`。撤销前会先回读歌单,只删理音新建、你没改过的歌单;你改过的只移除理音加进去的歌。
46
+
47
+ ## 拆法
48
+
49
+ 默认按大类(一级曲风)拆。`analyze` 可以换拆法,标签已缓存时几秒出结果;在网页里也能随时切换:
50
+
51
+ ```bash
52
+ tunetidy analyze "我喜欢的音乐" --only 流行 # 只分出流行,其余放进「其他」
53
+ tunetidy analyze "我喜欢的音乐" --only 流行 --match any # 带流行标签的都算
54
+ tunetidy analyze "我喜欢的音乐" --level 2 # 按细类(二级曲风)
55
+ tunetidy analyze "我喜欢的音乐" --top 5 # 最多建 5 个歌单(不算「其他」)
56
+ tunetidy analyze "我喜欢的音乐" --groups my.toml --no-other # 自己定怎么分,不建「其他」
57
+ ```
58
+
59
+ 自定义分组的写法见 [taxonomies/examples/](taxonomies/examples/)。网页里"自己定怎么分"可以导入、导出同样格式的文件。
60
+
61
+ ## 网页
62
+
63
+ `tunetidy ui` 在浏览器里打开理音(只监听本机)。首页每个歌单一张卡片;点进去能看到将要新建哪几个歌单、各多少首,可以切换拆法、给歌单改名、把分错的歌移到别的歌单,再把"没分好"的歌一首首选好去处。这些都只改本地的整理结果,不访问网易云。确认和建歌单在终端里完成,网页的确认页会给出要运行的命令。
64
+
65
+ ## 在 Claude Code、Codex 里用(MCP)
66
+
67
+ 理音自带 MCP 服务,AI 助手可以帮你列歌单、生成整理、查看明细,并在你确认后建歌单:
68
+
69
+ ```bash
70
+ claude mcp add tunetidy -- tunetidy mcp # Claude Code
71
+ ```
72
+
73
+ Codex 在 `~/.codex/config.toml` 里加:
74
+
75
+ ```toml
76
+ [mcp_servers.tunetidy]
77
+ command = "tunetidy"
78
+ args = ["mcp"]
79
+ ```
80
+
81
+ 五个工具:`list_playlists`、`create_plan`、`get_job_status`、`get_plan_items`、`apply_plan`。分析和建歌单都在后台运行,工具立即返回任务 id,再用 `get_job_status` 查进度。**MCP 里没有确认工具**:确认只能由你本人在终端运行 `tunetidy approve`,`apply_plan` 只接受你确认过、之后内容没变的整理。
82
+
83
+ ## 效果
84
+
85
+ 作者自己的"我喜欢的音乐"(1033 首,2026-10-31),默认拆法(按大类):
86
+
87
+ | | 歌曲数 | 没分好 |
88
+ | --- | --- | --- |
89
+ | 全部 | 1033 | 2.7% |
90
+ | 网易云给了曲风标签的 | 1026(99.3%) | 2.1% |
91
+ | 没有曲风标签的 | 7 | 85.7% |
92
+
93
+ "没分好"的歌不会放进新歌单,可以在网页里一首首选好去处。人工改动比例要等更多人用过才有意义,暂不列出。这些数字只来自一个歌单,你的歌单可能不同;用 `uv run python scripts/evaluate.py <整理id>` 可以算自己的。
94
+
95
+ ## 安全约定
96
+
97
+ - 只新建歌单,绝不修改或删除你原有的歌单。新歌单默认私密。
98
+ - `approve`、`apply`(包括 `--yes`)、`undo` 只能在交互式终端里由你本人完成。如果你使用 Claude Code、Codex 等 AI 助手,请不要在它们的权限设置里自动放行这几个命令。
99
+ - 整理在确认后有任何改动,确认即作废;已建好的整理不能再建第二次。
100
+ - 访问网易云带随机间隔限速,限速只读配置文件,任何命令或 MCP 工具都没有提速参数。默认直连,不使用系统代理。
101
+ - 登录凭据存在系统钥匙串里;日志和报错信息里不打印凭据。网页服务只监听 127.0.0.1,并校验随机口令。
102
+
103
+ ## 常见问题
104
+
105
+ **第一次分析为什么这么慢?** 网易云的曲风标签要逐首读取,为了不被限制,理音会放慢速度,1000 首约 25 分钟。读过的标签会缓存,之后换拆法、重新分析都只要几秒。
106
+
107
+ **"没分好"是什么?** 网易云没给这首歌标曲风,或者几个曲风一样多、分不出主次。不处理的话它们不会放进新歌单;可以在网页的"没分好的歌"里一首首选好去处。
108
+
109
+ **AI 助手能替我确认吗?** 不能。确认要在终端输入屏幕上显示的确认码,MCP 里也没有确认工具。助手只能执行你已经确认过的整理。
110
+
111
+ **怎么撤销?** 在终端运行 `tunetidy undo <整理id或运行id>`。
112
+
113
+ ## 免责声明
114
+
115
+ - 本项目是个人开源项目,与网易云音乐及其运营方没有任何关系。
116
+ - 理音通过网易云网页版使用的非公开接口访问你的账号。这些接口随时可能变化或失效,使用它们可能违反平台的服务条款,账号因此受到限制的风险由使用者自行承担。
117
+ - 理音只读写你自己账号里的歌单信息(歌单名、简介、歌曲列表),不下载、不分发任何音频。
118
+ - 软件按"现状"提供,不提供任何担保,详见 [LICENSE](LICENSE)。
119
+
120
+ ## 开发
121
+
122
+ 这一节写给想改理音代码的人,只是使用的话不用看。
123
+
124
+ 从源码使用时,命令要通过 `uv run` 运行(如 `uv run tunetidy playlists`),或者先用 `uv tool install --editable .` 把源码装成全局命令。
125
+
126
+ ```bash
127
+ uv sync
128
+ uv run ruff format . && uv run ruff check .
129
+ uv run mypy src
130
+ uv run pytest # 离线测试,不访问网络,钥匙串用测试专用的假实现
131
+ uv run pytest -m live # 只读的真实接口测试,需要先登录
132
+ ```
133
+
134
+ 前端在 `web/`(React + TypeScript + Vite + Tailwind),构建产物不提交。从源码运行 `tunetidy ui` 前先构建一次:
135
+
136
+ ```bash
137
+ cd web && npm ci && npm run build # 输出到 src/tunetidy/server/static/
138
+ ```
139
+
140
+ 改前端时可以用 `npm run dev`(另开终端运行 `uv run tunetidy ui --no-browser`,再用它打印的口令打开 `http://localhost:5173/#token=...`)。代码结构和关键设计见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
@@ -0,0 +1,122 @@
1
+ # TuneTidy (理音)
2
+
3
+ [中文](README.md) | English
4
+
5
+ Split a NetEase Cloud Music playlist into several new playlists by genre. TuneTidy builds the result locally first; new playlists are created in your account only after you have reviewed it, adjusted it and confirmed it yourself.
6
+
7
+ > **Unofficial project.** Not affiliated with, endorsed by or authorized by NetEase Cloud Music or its operator. TuneTidy only reads and writes playlist information in your own account and never downloads audio. See the [disclaimer](#disclaimer).
8
+
9
+ <!-- Demo GIF: docs/demo.gif (recorded by the author) -->
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ pipx install tunetidy
15
+ tunetidy login # scan a QR code with the NetEase Cloud Music app; the session goes to the system keychain
16
+ tunetidy playlists # list your playlists
17
+ tunetidy analyze "我喜欢的音乐" # read genres and build a plan (no account changes; first run about 1.5 s per song, cached afterwards)
18
+ tunetidy ui # review in the browser: switch split modes, rename playlists, move misplaced songs
19
+ tunetidy approve <plan-id> # confirm in the terminal yourself (type the code shown on screen)
20
+ tunetidy apply <plan-id> # create private playlists and add songs; rerun to resume after an interruption
21
+ ```
22
+
23
+ `我喜欢的音乐` ("My liked music") is the name NetEase gives to your liked-songs playlist; pass any playlist name or id.
24
+
25
+ To undo: `tunetidy undo <plan-id or run-id>`. Before undoing, TuneTidy reads the playlists back: it deletes only the playlists it created that you haven't changed; from playlists you have changed it removes only the songs it added.
26
+
27
+ ## Split modes
28
+
29
+ By default songs are split by top-level genre. `analyze` takes other split modes (seconds once tags are cached), and the web page can switch them at any time:
30
+
31
+ ```bash
32
+ tunetidy analyze "我喜欢的音乐" --only 流行 # split out pop only; everything else goes to 其他 (Other)
33
+ tunetidy analyze "我喜欢的音乐" --only 流行 --match any # count every song that carries a pop tag
34
+ tunetidy analyze "我喜欢的音乐" --level 2 # split by second-level genre
35
+ tunetidy analyze "我喜欢的音乐" --top 5 # at most 5 playlists (not counting Other)
36
+ tunetidy analyze "我喜欢的音乐" --groups my.toml --no-other # your own groups, no Other playlist
37
+ ```
38
+
39
+ Genre names are NetEase's Chinese genre names. For custom groups, see [taxonomies/examples/](taxonomies/examples/); the web page can import and export the same TOML format.
40
+
41
+ ## Web page
42
+
43
+ `tunetidy ui` opens TuneTidy in your browser (listening on localhost only). The home page has one card per playlist. Open one to see which new playlists will be created and how many songs each gets. There you can switch split modes, rename playlists, move misplaced songs, and pick a home for each song TuneTidy couldn't sort. All of this changes only the local plan and never contacts NetEase. Confirming and creating playlists happen in the terminal; the confirm page shows the commands to run.
44
+
45
+ The interface is in Chinese.
46
+
47
+ ## Use it from Claude Code or Codex (MCP)
48
+
49
+ TuneTidy ships an MCP server, so an AI assistant can list playlists, build a plan, browse it, and create the playlists once you have confirmed:
50
+
51
+ ```bash
52
+ claude mcp add tunetidy -- tunetidy mcp # Claude Code
53
+ ```
54
+
55
+ For Codex, add to `~/.codex/config.toml`:
56
+
57
+ ```toml
58
+ [mcp_servers.tunetidy]
59
+ command = "tunetidy"
60
+ args = ["mcp"]
61
+ ```
62
+
63
+ Five tools: `list_playlists`, `create_plan`, `get_job_status`, `get_plan_items`, `apply_plan`. Analysis and playlist creation run in the background: the tools return a job id at once, and `get_job_status` reports progress. **There is no confirmation tool.** Only you can confirm, by running `tunetidy approve` in a terminal, and `apply_plan` accepts only plans you have confirmed and that haven't changed since.
64
+
65
+ ## Results
66
+
67
+ The author's own liked-songs playlist (1033 songs, 2026-10-31), default split (top-level genre):
68
+
69
+ | | Songs | Unsorted |
70
+ | --- | --- | --- |
71
+ | All | 1033 | 2.7% |
72
+ | Tagged with a genre by NetEase | 1026 (99.3%) | 2.1% |
73
+ | No genre tag | 7 | 85.7% |
74
+
75
+ Unsorted songs are left out of the new playlists until you place them in the web page. The share of manual changes will mean something only once more people have used TuneTidy, so it isn't listed yet. These numbers come from a single playlist and yours may differ; run `uv run python scripts/evaluate.py <plan-id>` to compute your own.
76
+
77
+ ## Safety rules
78
+
79
+ - TuneTidy only creates playlists. It never changes or deletes playlists you already have. New playlists are private by default.
80
+ - `approve`, `apply` (including `--yes`) and `undo` work only in an interactive terminal, run by you. If you use an AI assistant such as Claude Code or Codex, don't auto-allow these commands in its permission settings.
81
+ - Any change to a plan after you confirm it voids the confirmation. A plan that has been applied can't be applied a second time.
82
+ - Requests to NetEase are rate-limited with random pauses. The limits come from the config file only; no command or MCP tool can speed them up. Connections go direct, without the system proxy.
83
+ - Login credentials live in the system keychain and are never printed in logs or error messages. The web server listens on 127.0.0.1 only and checks a random token.
84
+
85
+ ## FAQ
86
+
87
+ **Why is the first analysis so slow?** Genre tags have to be fetched one song at a time, and TuneTidy goes slowly to avoid being rate-limited: about 25 minutes for 1000 songs. Tags are cached, so switching split modes or analyzing again takes seconds.
88
+
89
+ **What does "unsorted" (没分好) mean?** NetEase gave the song no genre, or several genres tie and TuneTidy can't tell which comes first. Unsorted songs are left out of the new playlists unless you place them on the web page's unsorted-songs view.
90
+
91
+ **Can an AI assistant confirm for me?** No. Confirming means typing the code shown in your terminal, and the MCP server has no confirmation tool. An assistant can only apply plans you have already confirmed.
92
+
93
+ **How do I undo?** Run `tunetidy undo <plan-id or run-id>` in a terminal.
94
+
95
+ ## Disclaimer
96
+
97
+ - This is a personal open-source project with no connection to NetEase Cloud Music or its operator.
98
+ - TuneTidy reaches your account through the non-public interfaces used by the NetEase Cloud Music website. They may change or stop working at any time, and using them may breach the platform's terms of service. Any restriction placed on your account as a result is your own risk.
99
+ - TuneTidy only reads and writes playlist information in your own account (names, descriptions, song lists). It does not download or distribute any audio.
100
+ - The software is provided "as is", without warranty of any kind; see [LICENSE](LICENSE).
101
+
102
+ ## Development
103
+
104
+ This section is for people who want to work on TuneTidy's code; you don't need it to use the tool.
105
+
106
+ From a source checkout, run commands through `uv run` (for example `uv run tunetidy playlists`), or install the checkout as a command once with `uv tool install --editable .`.
107
+
108
+ ```bash
109
+ uv sync
110
+ uv run ruff format . && uv run ruff check .
111
+ uv run mypy src
112
+ uv run pytest # offline tests: no network, a fake keychain
113
+ uv run pytest -m live # read-only tests against the real service; log in first
114
+ ```
115
+
116
+ The front end lives in `web/` (React, TypeScript, Vite, Tailwind). Its build output isn't committed, so build it once before running `tunetidy ui` from source:
117
+
118
+ ```bash
119
+ cd web && npm ci && npm run build # writes to src/tunetidy/server/static/
120
+ ```
121
+
122
+ While working on the front end, use `npm run dev`: in another terminal run `uv run tunetidy ui --no-browser`, then open `http://localhost:5173/#token=...` with the token it prints. For the code layout and key design decisions, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) (in Chinese).
@@ -0,0 +1,118 @@
1
+ # 理音 TuneTidy
2
+
3
+ 中文 | [English](README.en.md)
4
+
5
+ 把网易云歌单按曲风拆成几个新歌单:先在本地生成整理结果,你看过、调整过、亲自确认后,才在账号里新建歌单。
6
+
7
+ > **非官方项目**,与网易云音乐及其运营方无关,也未获其授权。只读写你自己账号里的歌单信息,不下载音频。详见文末[免责声明](#免责声明)。
8
+
9
+ <!-- 演示动图:docs/demo.gif(由作者录制) -->
10
+
11
+ ## 30 秒上手
12
+
13
+ ```bash
14
+ pipx install tunetidy
15
+ tunetidy login # 用网易云 App 扫码登录,登录态存进系统钥匙串
16
+ tunetidy playlists # 看看有哪些歌单
17
+ tunetidy analyze "我喜欢的音乐" # 读取曲风、生成整理(不改账号;首次约每首 1.5 秒,之后有缓存)
18
+ tunetidy ui # 在网页里看结果、换拆法、改歌单名、把分错的歌挪走
19
+ tunetidy approve <整理id> # 你本人在终端确认(要输入屏幕上显示的确认码)
20
+ tunetidy apply <整理id> # 新建私密歌单并加歌;中断后再运行即续传
21
+ ```
22
+
23
+ 不满意可以撤销:`tunetidy undo <整理id或运行id>`。撤销前会先回读歌单,只删理音新建、你没改过的歌单;你改过的只移除理音加进去的歌。
24
+
25
+ ## 拆法
26
+
27
+ 默认按大类(一级曲风)拆。`analyze` 可以换拆法,标签已缓存时几秒出结果;在网页里也能随时切换:
28
+
29
+ ```bash
30
+ tunetidy analyze "我喜欢的音乐" --only 流行 # 只分出流行,其余放进「其他」
31
+ tunetidy analyze "我喜欢的音乐" --only 流行 --match any # 带流行标签的都算
32
+ tunetidy analyze "我喜欢的音乐" --level 2 # 按细类(二级曲风)
33
+ tunetidy analyze "我喜欢的音乐" --top 5 # 最多建 5 个歌单(不算「其他」)
34
+ tunetidy analyze "我喜欢的音乐" --groups my.toml --no-other # 自己定怎么分,不建「其他」
35
+ ```
36
+
37
+ 自定义分组的写法见 [taxonomies/examples/](taxonomies/examples/)。网页里"自己定怎么分"可以导入、导出同样格式的文件。
38
+
39
+ ## 网页
40
+
41
+ `tunetidy ui` 在浏览器里打开理音(只监听本机)。首页每个歌单一张卡片;点进去能看到将要新建哪几个歌单、各多少首,可以切换拆法、给歌单改名、把分错的歌移到别的歌单,再把"没分好"的歌一首首选好去处。这些都只改本地的整理结果,不访问网易云。确认和建歌单在终端里完成,网页的确认页会给出要运行的命令。
42
+
43
+ ## 在 Claude Code、Codex 里用(MCP)
44
+
45
+ 理音自带 MCP 服务,AI 助手可以帮你列歌单、生成整理、查看明细,并在你确认后建歌单:
46
+
47
+ ```bash
48
+ claude mcp add tunetidy -- tunetidy mcp # Claude Code
49
+ ```
50
+
51
+ Codex 在 `~/.codex/config.toml` 里加:
52
+
53
+ ```toml
54
+ [mcp_servers.tunetidy]
55
+ command = "tunetidy"
56
+ args = ["mcp"]
57
+ ```
58
+
59
+ 五个工具:`list_playlists`、`create_plan`、`get_job_status`、`get_plan_items`、`apply_plan`。分析和建歌单都在后台运行,工具立即返回任务 id,再用 `get_job_status` 查进度。**MCP 里没有确认工具**:确认只能由你本人在终端运行 `tunetidy approve`,`apply_plan` 只接受你确认过、之后内容没变的整理。
60
+
61
+ ## 效果
62
+
63
+ 作者自己的"我喜欢的音乐"(1033 首,2026-10-31),默认拆法(按大类):
64
+
65
+ | | 歌曲数 | 没分好 |
66
+ | --- | --- | --- |
67
+ | 全部 | 1033 | 2.7% |
68
+ | 网易云给了曲风标签的 | 1026(99.3%) | 2.1% |
69
+ | 没有曲风标签的 | 7 | 85.7% |
70
+
71
+ "没分好"的歌不会放进新歌单,可以在网页里一首首选好去处。人工改动比例要等更多人用过才有意义,暂不列出。这些数字只来自一个歌单,你的歌单可能不同;用 `uv run python scripts/evaluate.py <整理id>` 可以算自己的。
72
+
73
+ ## 安全约定
74
+
75
+ - 只新建歌单,绝不修改或删除你原有的歌单。新歌单默认私密。
76
+ - `approve`、`apply`(包括 `--yes`)、`undo` 只能在交互式终端里由你本人完成。如果你使用 Claude Code、Codex 等 AI 助手,请不要在它们的权限设置里自动放行这几个命令。
77
+ - 整理在确认后有任何改动,确认即作废;已建好的整理不能再建第二次。
78
+ - 访问网易云带随机间隔限速,限速只读配置文件,任何命令或 MCP 工具都没有提速参数。默认直连,不使用系统代理。
79
+ - 登录凭据存在系统钥匙串里;日志和报错信息里不打印凭据。网页服务只监听 127.0.0.1,并校验随机口令。
80
+
81
+ ## 常见问题
82
+
83
+ **第一次分析为什么这么慢?** 网易云的曲风标签要逐首读取,为了不被限制,理音会放慢速度,1000 首约 25 分钟。读过的标签会缓存,之后换拆法、重新分析都只要几秒。
84
+
85
+ **"没分好"是什么?** 网易云没给这首歌标曲风,或者几个曲风一样多、分不出主次。不处理的话它们不会放进新歌单;可以在网页的"没分好的歌"里一首首选好去处。
86
+
87
+ **AI 助手能替我确认吗?** 不能。确认要在终端输入屏幕上显示的确认码,MCP 里也没有确认工具。助手只能执行你已经确认过的整理。
88
+
89
+ **怎么撤销?** 在终端运行 `tunetidy undo <整理id或运行id>`。
90
+
91
+ ## 免责声明
92
+
93
+ - 本项目是个人开源项目,与网易云音乐及其运营方没有任何关系。
94
+ - 理音通过网易云网页版使用的非公开接口访问你的账号。这些接口随时可能变化或失效,使用它们可能违反平台的服务条款,账号因此受到限制的风险由使用者自行承担。
95
+ - 理音只读写你自己账号里的歌单信息(歌单名、简介、歌曲列表),不下载、不分发任何音频。
96
+ - 软件按"现状"提供,不提供任何担保,详见 [LICENSE](LICENSE)。
97
+
98
+ ## 开发
99
+
100
+ 这一节写给想改理音代码的人,只是使用的话不用看。
101
+
102
+ 从源码使用时,命令要通过 `uv run` 运行(如 `uv run tunetidy playlists`),或者先用 `uv tool install --editable .` 把源码装成全局命令。
103
+
104
+ ```bash
105
+ uv sync
106
+ uv run ruff format . && uv run ruff check .
107
+ uv run mypy src
108
+ uv run pytest # 离线测试,不访问网络,钥匙串用测试专用的假实现
109
+ uv run pytest -m live # 只读的真实接口测试,需要先登录
110
+ ```
111
+
112
+ 前端在 `web/`(React + TypeScript + Vite + Tailwind),构建产物不提交。从源码运行 `tunetidy ui` 前先构建一次:
113
+
114
+ ```bash
115
+ cd web && npm ci && npm run build # 输出到 src/tunetidy/server/static/
116
+ ```
117
+
118
+ 改前端时可以用 `npm run dev`(另开终端运行 `uv run tunetidy ui --no-browser`,再用它打印的口令打开 `http://localhost:5173/#token=...`)。代码结构和关键设计见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
@@ -0,0 +1,109 @@
1
+ # 理音 TuneTidy · 架构说明
2
+
3
+ 写给想读懂或修改理音代码的人。使用方法见 [README](../README.md)。
4
+
5
+ ## 整体流程
6
+
7
+ ```
8
+ 读取歌单与曲风标签 ──► 分类 ──► 整理方案(本地) ──► 人工确认 ──► 写回账号 ──► 可撤销
9
+ analyze.py classify/ plan.py apply/approval apply/writer apply/undo
10
+ ```
11
+
12
+ 1. **读取**:通过后端接口拉取来源歌单的歌曲和每首歌的官方曲风标签。标签逐首写进本地数据库,中断后再运行会接着取,并按配置缓存一段时间。
13
+ 2. **分类**:只用本地数据完成,不访问网络。同一份输入一定得到同一份结果。
14
+ 3. **整理方案**:分类结果、将要新建的歌单、以及当时用到的全部输入(信号快照)一起存进数据库。之后切换拆法、移动歌曲、改歌单名,都基于这份快照重新计算,不再访问网络。
15
+ 4. **确认**:只能由人在交互式终端里输入确认码完成。确认时记下方案内容的哈希。
16
+ 5. **写回**:执行前重新核对哈希;先把"要建哪些歌单、各加哪些歌"写进数据库,再逐步执行,中断后可以续传。
17
+ 6. **撤销**:先回读歌单现状、和写入记录对比,再决定整删还是只移除理音加入的歌。
18
+
19
+ 命令行、网页和 MCP 服务是同一套核心库的三个入口。
20
+
21
+ ## 目录
22
+
23
+ | 位置 | 作用 |
24
+ | --- | --- |
25
+ | `src/tunetidy/models.py` | 平台无关的数据类型(歌曲、歌单、标签等),歌曲主键一律是 `(platform, id)` |
26
+ | `src/tunetidy/backends/` | 访问音乐平台的唯一位置。`base.py` 定义 `MusicBackend` 接口,`web/` 是网易云扫码登录后端 |
27
+ | `src/tunetidy/signals/` | 把平台数据转成分类用的信号(目前是官方曲风标签) |
28
+ | `src/tunetidy/classify/` | 曲风树、多标签规则、打分、拆分方式、小类合并 |
29
+ | `src/tunetidy/analyze.py` | 串起读取、分类、生成方案;按快照重新分类、移动歌曲、改歌单名 |
30
+ | `src/tunetidy/plan.py` | 方案、将要新建的歌单、内容哈希 |
31
+ | `src/tunetidy/apply/` | 确认、写回、续传、撤销 |
32
+ | `src/tunetidy/storage/` | SQLite 读写(WAL 模式)、表结构迁移 |
33
+ | `src/tunetidy/jobs.py` | 后台任务(分析、写回),供 MCP 使用 |
34
+ | `src/tunetidy/server/` | 本地网页服务(FastAPI)和接口 |
35
+ | `src/tunetidy/mcp/` | MCP 服务:五个工具的逻辑和注册 |
36
+ | `src/tunetidy/cli.py` | 命令行入口(Typer + Rich) |
37
+ | `web/` | 网页前端(React + TypeScript + Vite + Tailwind),构建产物打进 Python 包 |
38
+ | `taxonomies/` | 网易云曲风树快照与自定义分组示例 |
39
+ | `scripts/` | 评估脚本、本地 pre-commit 钩子 |
40
+
41
+ ## 关键设计
42
+
43
+ ### 平台隔离
44
+
45
+ 上层模块只依赖 `MusicBackend` 接口,不直接发请求。后端按名字注册,用到时才导入,所以删掉 `backends/web/` 整个目录,其他模块仍能编译、测试仍能运行。以后接入其他平台,只需新增一个后端。
46
+
47
+ ### 确定性分类
48
+
49
+ 1. 每首歌的官方标签先按曲风树归到大类,再去掉用途类标签(原声带、二次元、儿童)和泛化标签(流行),前提是还剩别的曲风。
50
+ 2. 剩下的曲风按当前拆法映射到分类:按大类、按细类、只分一类、自定义分组。
51
+ 3. 只对应一个分类就放进去;对应多个分类、分不出主次,或者没有标签,就标为"没分好",不写入。
52
+ 4. 再按设置把太小的分类并入「其他」、只保留最大的 N 类,或者不建「其他」。
53
+
54
+ 结果与歌曲顺序无关,同一份快照必然得到同一份方案。人工调整(移动歌曲)单独标记,重新分类时保留。
55
+
56
+ ### 方案状态与确认
57
+
58
+ 状态依次为:草稿 → 已确认 → 正在创建 → 已建好 → 已撤销。
59
+
60
+ - 内容哈希只覆盖"会对账号做什么":来源、每个新歌单的名称和歌曲顺序、不写入的歌。确认后内容有任何变化,确认自动作废,方案回到草稿。
61
+ - `approve`,以及命令行里的 `apply`(不论带不带 `--yes`),都只在交互式终端下工作,并要求输入随机确认码。
62
+ - 非交互地执行已确认的方案,只有 MCP 的 `apply_plan` 一条路径;它同样核对状态和哈希,没有确认工具。
63
+ - 已建好的方案不能再执行一次。
64
+
65
+ ### 写回与撤销
66
+
67
+ - 写回前先在 `runs`、`run_playlists`、`run_tracks` 三张表里记下意图,再建歌单、分批加歌。每个新歌单的简介里写入运行标记。
68
+ - 中断后续传:按记录跳过已完成的部分,按运行标记找回已建的歌单,不会建出第二个。
69
+ - 只新建歌单,从不修改或删除用户原有的歌单。
70
+ - 撤销前回读歌单现状:和写入记录完全一致才整删;用户改过(或找不到运行标记)的,只移除理音加入的歌。
71
+
72
+ ### 存储与并发
73
+
74
+ SQLite 开启 WAL 模式;命令行、网页服务、MCP 服务可能同时打开同一个库。写操作都是短事务(`BEGIN IMMEDIATE`),表结构变化通过只追加的迁移完成。
75
+
76
+ 后台任务记在 `jobs` 表里:
77
+
78
+ - 同一时间只允许一个在运行,检查和插入在同一个写事务里完成,跨进程也成立;
79
+ - 运行中每 10 秒刷新心跳;
80
+ - 任何入口启动时,把进程已不在或心跳超时的任务标为"已中断"。
81
+
82
+ ### 本地网页服务
83
+
84
+ - 只监听 `127.0.0.1`;
85
+ - 每次启动生成随机口令,放在网址的 `#token=` 里(不会发给服务器、不进日志);
86
+ - 每个接口请求都要带口令请求头,并校验 `Host` 和 `Origin`;
87
+ - 写接口只接受 POST;
88
+ - 网页只能查看和修改本地方案,不能确认、写回或撤销。
89
+
90
+ ### MCP 服务
91
+
92
+ `tunetidy mcp` 以 stdio 启动,五个工具:`list_playlists`、`create_plan`、`get_job_status`、`get_plan_items`、`apply_plan`。
93
+
94
+ - 分析和写回都只启动后台任务、立即返回任务 id。
95
+ - 默认只返回摘要,明细分页。
96
+ - 参数里没有限速、间隔、并发之类的设置。
97
+ - stdout 只输出协议消息。
98
+
99
+ ### 凭据与隐私
100
+
101
+ - 登录态优先存系统钥匙串,不可用时存仅本人可读的文件;日志和报错不打印凭据。
102
+ - 访问平台时按配置限速,并带随机间隔。
103
+ - `scripts/hooks/` 提供一个本地 pre-commit 钩子:从本机数据库和钥匙串读出自己的歌单 id、用户 id、昵称和 Cookie,暂存内容命中就拒绝提交。启用方法:`git config core.hooksPath scripts/hooks`。
104
+
105
+ ## 测试
106
+
107
+ - `uv run pytest` 全部离线:平台响应来自 `tests/fixtures/` 里脱敏后的录制数据,后端用 `tests/fakes.py` 的假实现,钥匙串在测试里被强制换成内存实现。
108
+ - 真实接口测试在 `tests/live/`,标记为 `live`,默认不运行。
109
+ - 写回、续传、撤销、确认失效、后台任务中断等边界情况都有测试。