tunetidy 1.0.0__py3-none-any.whl

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 (49) hide show
  1. tunetidy/__init__.py +3 -0
  2. tunetidy/analyze.py +444 -0
  3. tunetidy/apply/__init__.py +1 -0
  4. tunetidy/apply/approval.py +126 -0
  5. tunetidy/apply/undo.py +204 -0
  6. tunetidy/apply/writer.py +220 -0
  7. tunetidy/backends/__init__.py +34 -0
  8. tunetidy/backends/base.py +73 -0
  9. tunetidy/backends/web/__init__.py +12 -0
  10. tunetidy/backends/web/backend.py +241 -0
  11. tunetidy/backends/web/client.py +174 -0
  12. tunetidy/backends/web/crypto.py +86 -0
  13. tunetidy/backends/web/parse.py +178 -0
  14. tunetidy/backends/web/session.py +122 -0
  15. tunetidy/classify/__init__.py +1 -0
  16. tunetidy/classify/pipeline.py +229 -0
  17. tunetidy/classify/rules.py +38 -0
  18. tunetidy/classify/scoring.py +53 -0
  19. tunetidy/classify/split.py +183 -0
  20. tunetidy/classify/taxonomy.py +85 -0
  21. tunetidy/cli.py +462 -0
  22. tunetidy/config.py +141 -0
  23. tunetidy/evaluate.py +60 -0
  24. tunetidy/export.py +210 -0
  25. tunetidy/jobs.py +227 -0
  26. tunetidy/mcp/__init__.py +1 -0
  27. tunetidy/mcp/server.py +75 -0
  28. tunetidy/mcp/tools.py +206 -0
  29. tunetidy/models.py +105 -0
  30. tunetidy/plan.py +134 -0
  31. tunetidy/server/__init__.py +1 -0
  32. tunetidy/server/api.py +342 -0
  33. tunetidy/server/app.py +138 -0
  34. tunetidy/server/static/assets/index-CRt4YQCx.css +2 -0
  35. tunetidy/server/static/assets/index-Dj1Vs3CY.js +11 -0
  36. tunetidy/server/static/index.html +14 -0
  37. tunetidy/signals/__init__.py +1 -0
  38. tunetidy/signals/official.py +63 -0
  39. tunetidy/storage/__init__.py +1 -0
  40. tunetidy/storage/db.py +251 -0
  41. tunetidy/storage/jobs.py +157 -0
  42. tunetidy/storage/plans.py +197 -0
  43. tunetidy/storage/repo.py +169 -0
  44. tunetidy/storage/runs.py +159 -0
  45. tunetidy-1.0.0.dist-info/METADATA +140 -0
  46. tunetidy-1.0.0.dist-info/RECORD +49 -0
  47. tunetidy-1.0.0.dist-info/WHEEL +4 -0
  48. tunetidy-1.0.0.dist-info/entry_points.txt +2 -0
  49. tunetidy-1.0.0.dist-info/licenses/LICENSE +21 -0
tunetidy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """理音 TuneTidy:按曲风整理网易云歌单。"""
2
+
3
+ __version__ = "1.0.0"
tunetidy/analyze.py ADDED
@@ -0,0 +1,444 @@
1
+ """analyze:拉歌曲 → 取标签(逐首落盘,可续传)→ 分类 → 生成整理方案。
2
+
3
+ 所有平台访问都经由 MusicBackend。标签和曲风树按配置缓存(默认 30 天),
4
+ 第二次分析同一歌单只需要读歌单与歌曲详情。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ from collections import Counter
12
+ from collections.abc import Callable, Sequence
13
+ from dataclasses import asdict, dataclass, replace
14
+ from datetime import UTC, datetime
15
+ from typing import Any
16
+
17
+ from tunetidy.apply.writer import ApplyError
18
+ from tunetidy.backends.base import MusicBackend
19
+ from tunetidy.classify.pipeline import Assignment, Status, classify_songs
20
+ from tunetidy.classify.split import SplitSettings, resolve_groups
21
+ from tunetidy.classify.taxonomy import Taxonomy
22
+ from tunetidy.config import Config, parse_config
23
+ from tunetidy.models import (
24
+ CopyrightStatus,
25
+ GenreTag,
26
+ PlaylistInfo,
27
+ Song,
28
+ SongKey,
29
+ SongTags,
30
+ StyleNode,
31
+ )
32
+ from tunetidy.plan import Plan, PlanStatus, Source, source_label
33
+ from tunetidy.storage import plans as plan_store
34
+ from tunetidy.storage import repo
35
+
36
+ Progress = Callable[[str, int, int], None] # (阶段, 已完成, 总数)
37
+
38
+
39
+ def utcnow() -> datetime:
40
+ return datetime.now(UTC)
41
+
42
+
43
+ def _key(k: SongKey) -> str:
44
+ return f"{k.platform}:{k.id}"
45
+
46
+
47
+ def collect_songs(backend: MusicBackend, sources: Sequence[PlaylistInfo]) -> list[Song]:
48
+ """多个来源歌单合并去重,保留第一次出现的位置。"""
49
+ seen: set[SongKey] = set()
50
+ out: list[Song] = []
51
+ for src in sources:
52
+ for s in backend.get_tracks(src.id):
53
+ if s.key not in seen:
54
+ seen.add(s.key)
55
+ out.append(s)
56
+ return out
57
+
58
+
59
+ def load_taxonomy(
60
+ backend: MusicBackend, conn: sqlite3.Connection, now: datetime, max_age_days: int
61
+ ) -> list[StyleNode]:
62
+ nodes = repo.fresh_style_tree(conn, backend.platform, now, max_age_days)
63
+ if nodes is None:
64
+ nodes = backend.get_style_tree()
65
+ repo.save_style_tree(conn, backend.platform, Taxonomy(nodes).version, nodes, now)
66
+ return nodes
67
+
68
+
69
+ def fetch_tags(
70
+ backend: MusicBackend,
71
+ conn: sqlite3.Connection,
72
+ songs: Sequence[Song],
73
+ now: Callable[[], datetime],
74
+ max_age_days: int,
75
+ on_progress: Progress | None = None,
76
+ ) -> dict[SongKey, SongTags]:
77
+ keys = [s.key for s in songs]
78
+ tags = repo.fresh_tags(conn, keys, now(), max_age_days)
79
+ # 云盘歌曲没有公共曲风数据,不去请求
80
+ need = [s.key.id for s in songs if s.key not in tags and s.copyright != CopyrightStatus.CLOUD]
81
+ if on_progress:
82
+ on_progress("tags", 0, len(need))
83
+ for i, t in enumerate(backend.get_song_tags(need), 1):
84
+ repo.save_tags(conn, t, now()) # 逐首提交,中断后重跑只取剩下的
85
+ tags[t.key] = t
86
+ if on_progress:
87
+ on_progress("tags", i, len(need))
88
+ return tags
89
+
90
+
91
+ def _settings(cfg: Config, split: SplitSettings) -> dict[str, Any]:
92
+ c = asdict(cfg.classify)
93
+ c = {k: list(v) if isinstance(v, tuple) else v for k, v in c.items()}
94
+ return {
95
+ "split": split.to_dict(),
96
+ "classify": c,
97
+ "name_template": cfg.apply.name_template,
98
+ "other_name": cfg.classify.other_name,
99
+ }
100
+
101
+
102
+ def resolve_sources(all_playlists: list[PlaylistInfo], wanted: list[str]) -> list[PlaylistInfo]:
103
+ """按歌单 id 或完整名称找来源歌单(去重保序);重名或找不到时报错。"""
104
+ by_id = {p.id: p for p in all_playlists}
105
+ out: list[PlaylistInfo] = []
106
+ for w in wanted:
107
+ if w in by_id:
108
+ out.append(by_id[w])
109
+ continue
110
+ named = [p for p in all_playlists if p.name == w]
111
+ if len(named) == 1:
112
+ out.append(named[0])
113
+ elif named:
114
+ raise ApplyError(f"有 {len(named)} 个歌单都叫「{w}」,请改用歌单 id")
115
+ else:
116
+ raise ApplyError(f"找不到歌单「{w}」,先用 tunetidy playlists 查看")
117
+ return list({p.id: p for p in out}.values())
118
+
119
+
120
+ def _snapshot(
121
+ songs: Sequence[Song], tags: dict[SongKey, SongTags], nodes: Sequence[StyleNode]
122
+ ) -> dict[str, Any]:
123
+ return {
124
+ "songs": [[s.key.platform, s.key.id, s.copyright.value] for s in songs],
125
+ "tags": {
126
+ _key(s.key): json.loads(repo.tags_to_json(tags[s.key])) for s in songs if s.key in tags
127
+ },
128
+ "style_tree": [[n.tag_id, n.name, n.parent_id, n.level] for n in nodes],
129
+ }
130
+
131
+
132
+ def analyze(
133
+ backend: MusicBackend,
134
+ conn: sqlite3.Connection,
135
+ config: Config,
136
+ sources: Sequence[PlaylistInfo],
137
+ *,
138
+ now: Callable[[], datetime] = utcnow,
139
+ on_progress: Progress | None = None,
140
+ split: SplitSettings | None = None,
141
+ ) -> Plan:
142
+ split = split or SplitSettings()
143
+ if not sources:
144
+ raise ValueError("至少需要一个来源歌单")
145
+ if on_progress:
146
+ on_progress("tracks", 0, len(sources))
147
+ songs = collect_songs(backend, sources)
148
+ if on_progress:
149
+ on_progress("tracks", len(sources), len(sources))
150
+ repo.upsert_songs(conn, songs, now())
151
+ days = config.storage.tag_cache_days
152
+ nodes = load_taxonomy(backend, conn, now(), days)
153
+ taxonomy = Taxonomy(nodes)
154
+ # 分组里写错的曲风在取标签(可能要很久)之前就报出来
155
+ resolve_groups(split, taxonomy, config.classify.other_name)
156
+ tags = fetch_tags(backend, conn, songs, now, days, on_progress)
157
+ assignments = classify_songs(songs, tags, taxonomy, config.classify, split=split)
158
+ created = now()
159
+ plan = Plan(
160
+ id=plan_store.new_plan_id(created),
161
+ created_at=repo.iso(created),
162
+ status=PlanStatus.DRAFT,
163
+ platform=backend.platform,
164
+ sources=tuple(Source(s.id, s.name) for s in sources),
165
+ taxonomy_version=taxonomy.version,
166
+ settings=_settings(config, split),
167
+ snapshot=_snapshot(songs, tags, nodes),
168
+ assignments=tuple(assignments),
169
+ )
170
+ plan_store.save_plan(conn, plan)
171
+ return plan
172
+
173
+
174
+ def _snapshot_inputs(plan: Plan) -> tuple[list[Song], dict[SongKey, SongTags], Taxonomy]:
175
+ snap = plan.snapshot
176
+ nodes = [StyleNode(a, b, c, d) for a, b, c, d in snap["style_tree"]]
177
+ songs = [
178
+ Song(SongKey(p, i), "", (), None, None, None, None, CopyrightStatus(c))
179
+ for p, i, c in snap["songs"]
180
+ ]
181
+ tags = {
182
+ s.key: repo.tags_from_json(s.key, json.dumps(snap["tags"][_key(s.key)]))
183
+ for s in songs
184
+ if _key(s.key) in snap["tags"]
185
+ }
186
+ return songs, tags, Taxonomy(nodes)
187
+
188
+
189
+ def song_genres(plan: Plan) -> dict[SongKey, list[dict[str, Any]]]:
190
+ """每首歌的官方曲风标签(快照里的,按平台给的顺序);网页"曲风标签"列用。
191
+
192
+ used:分类时有没有用上。看主要曲风时,被去掉的用途类和"流行"标签没用上(网页显示成
193
+ 浅色);"带标签就算"时每个标签都参与匹配,全算用上。
194
+ """
195
+ _, tags, taxonomy = _snapshot_inputs(plan)
196
+ any_match = plan.settings.get("split", {}).get("match") == "any"
197
+ kept = {
198
+ a.key: set((a.reason.get("official") or {}).get("kept") or []) for a in plan.assignments
199
+ }
200
+
201
+ def tag(key: SongKey, g: GenreTag) -> dict[str, Any]:
202
+ if g.tag_id not in taxonomy.nodes: # 不在本地曲风树里:按名称归类,算用上
203
+ return {"name": g.name, "used": True}
204
+ chain = taxonomy.chain(g.tag_id)
205
+ return {"name": chain[-1].name, "used": any_match or chain[0].name in kept.get(key, set())}
206
+
207
+ out: dict[SongKey, list[dict[str, Any]]] = {}
208
+ for k, t in tags.items():
209
+ seen: dict[str, dict[str, Any]] = {}
210
+ for g in t.genres:
211
+ x = tag(k, g)
212
+ seen.setdefault(x["name"], x)
213
+ out[k] = list(seen.values())
214
+ return out
215
+
216
+
217
+ def classify_from_snapshot(plan: Plan) -> list[Assignment]:
218
+ """只用方案里保存的信号快照重新分类;用于验证"同一快照必然得到同样的结果"。"""
219
+ songs, tags, taxonomy = _snapshot_inputs(plan)
220
+ cfg = parse_config({"classify": plan.settings["classify"]}).classify
221
+ split = SplitSettings.from_dict(plan.settings.get("split", {}))
222
+ return classify_songs(songs, tags, taxonomy, cfg, split=split)
223
+
224
+
225
+ class ReclassifyError(RuntimeError):
226
+ pass
227
+
228
+
229
+ class ConfirmResetError(RuntimeError):
230
+ """方案已批准,调用方没有确认"修改会使批准作废"。"""
231
+
232
+
233
+ class MoveError(ValueError):
234
+ """ "移动到"的参数不对:歌不在方案里、下架的歌、空的分类名。"""
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class ReclassifyResult:
239
+ plan: Plan
240
+ changed: bool # 方案内容(会对账号做什么)是否变化
241
+ approval_reset: bool # 原来已批准,因内容变化回到草稿、需要重新批准
242
+ kept_manual: int # 保留下来的人工调整
243
+
244
+
245
+ def _load_editable(
246
+ conn: sqlite3.Connection, plan_id: str, expected_hash: str | None, confirm_reset: bool
247
+ ) -> Plan:
248
+ """保存时 replace_assignments 还会在写事务里核对内容和状态都没变,堵住两次读取之间的空隙。"""
249
+ plan = plan_store.load_plan(conn, plan_id)
250
+ if plan.status not in (PlanStatus.DRAFT, PlanStatus.APPROVED):
251
+ raise ReclassifyError(f"方案状态为 {plan.status.value},不能再修改;请重新 analyze")
252
+ if expected_hash is not None and plan.content_hash != expected_hash:
253
+ raise plan_store.PlanStateError("方案已在别处被修改,请刷新后再试")
254
+ if plan.status == PlanStatus.APPROVED and not confirm_reset:
255
+ raise ConfirmResetError("方案已批准,修改会使批准作废")
256
+ return plan
257
+
258
+
259
+ def _save_edit(conn: sqlite3.Connection, plan: Plan, candidate: Plan) -> ReclassifyResult:
260
+ """内容变化时方案回到草稿、批准作废;只改设置、内容不变时批准保留。"""
261
+ changed = candidate.content_hash != plan.content_hash
262
+ reset = changed and plan.status == PlanStatus.APPROVED
263
+ if reset:
264
+ candidate = replace(
265
+ candidate, status=PlanStatus.DRAFT, approved_hash=None, approved_at=None
266
+ )
267
+ plan_store.replace_assignments(conn, candidate, plan.content_hash, plan.status)
268
+ kept = sum(a.manual for a in candidate.assignments)
269
+ return ReclassifyResult(plan_store.load_plan(conn, plan.id), changed, reset, kept)
270
+
271
+
272
+ def simulate(plan: Plan, split: SplitSettings, *, min_size: int | None = None) -> Plan:
273
+ """按新的拆分方式算出重新分类后的方案,不保存(拆分方式面板预估首数用)。
274
+
275
+ 只用方案里保存的信号快照,不访问平台;人工调整过的歌保留原分类(最小数量的计数
276
+ 仍按它们的自动分类算,影响很小)。
277
+ """
278
+ classify = dict(plan.settings["classify"])
279
+ if min_size is not None:
280
+ classify["min_size"] = min_size
281
+ cfg = parse_config({"classify": classify}).classify # 顺带校验 min_size
282
+ songs, tags, taxonomy = _snapshot_inputs(plan)
283
+ positions = {a.key: a.position for a in plan.assignments}
284
+ auto = classify_songs(songs, tags, taxonomy, cfg, positions, split)
285
+ manual = {a.key: a for a in plan.assignments if a.manual}
286
+ assignments = tuple(manual.get(a.key, a) for a in auto)
287
+ settings = {**plan.settings, "split": split.to_dict(), "classify": classify}
288
+ return replace(plan, assignments=assignments, settings=settings)
289
+
290
+
291
+ def reclassify(
292
+ conn: sqlite3.Connection,
293
+ plan_id: str,
294
+ split: SplitSettings,
295
+ *,
296
+ min_size: int | None = None,
297
+ expected_hash: str | None = None,
298
+ confirm_reset: bool = True,
299
+ ) -> ReclassifyResult:
300
+ """按新的拆分方式重新分类并保存(网页上切换拆法时用),规则见 simulate。
301
+
302
+ 已执行、执行中、已撤销的方案不能重新分类。传了 expected_hash 时,方案内容必须
303
+ 还是调用方看到的那一份;confirm_reset 为 False 时,已批准的方案不改动(网页先提示
304
+ "会使批准作废",用户同意后再带 True 重试)。
305
+ """
306
+ plan = _load_editable(conn, plan_id, expected_hash, confirm_reset)
307
+ return _save_edit(conn, plan, simulate(plan, split, min_size=min_size))
308
+
309
+
310
+ def _selected(plan: Plan, keys: Sequence[SongKey]) -> set[SongKey]:
311
+ wanted = set(keys)
312
+ if not wanted:
313
+ raise MoveError("没有选中任何歌")
314
+ missing = wanted - {a.key for a in plan.assignments}
315
+ if missing:
316
+ raise MoveError(f"有 {len(missing)} 首歌不在这份方案里")
317
+ return wanted
318
+
319
+
320
+ def move_songs(
321
+ conn: sqlite3.Connection,
322
+ plan_id: str,
323
+ keys: Sequence[SongKey],
324
+ category: str | None,
325
+ *,
326
+ expected_hash: str | None = None,
327
+ confirm_reset: bool = True,
328
+ ) -> ReclassifyResult:
329
+ """人工"移动到":把歌放进某个分类(已有或新建),category 为 None 时标为"不写入"。
330
+
331
+ 移动过的歌标记为人工调整,之后重新分类也不会被覆盖。下架或无版权的歌加不进歌单,
332
+ 不能移动。expected_hash 与 confirm_reset 同 reclassify。
333
+ """
334
+ plan = _load_editable(conn, plan_id, expected_hash, confirm_reset)
335
+ if category is not None:
336
+ category = category.strip()
337
+ if not category:
338
+ raise MoveError("分类名不能为空")
339
+ by_key = {a.key: a for a in plan.assignments}
340
+ wanted = _selected(plan, keys)
341
+ if any(by_key[k].status == "unavailable" for k in wanted):
342
+ raise MoveError("下架或无版权的歌无法加入歌单,不能移动")
343
+ status: Status = "assigned" if category is not None else "ungrouped"
344
+ assignments = tuple(
345
+ replace(a, status=status, category=category, manual=True) if a.key in wanted else a
346
+ for a in plan.assignments
347
+ )
348
+ return _save_edit(conn, plan, replace(plan, assignments=assignments))
349
+
350
+
351
+ def reset_songs(
352
+ conn: sqlite3.Connection,
353
+ plan_id: str,
354
+ keys: Sequence[SongKey],
355
+ *,
356
+ expected_hash: str | None = None,
357
+ confirm_reset: bool = True,
358
+ ) -> ReclassifyResult:
359
+ """撤回人工调整:这些歌按当前拆分方式重新自动分类(网页"没分好的歌"里再点一次已选项)。"""
360
+ plan = _load_editable(conn, plan_id, expected_hash, confirm_reset)
361
+ wanted = _selected(plan, keys)
362
+ cleared = replace(
363
+ plan,
364
+ assignments=tuple(
365
+ replace(a, manual=False) if a.key in wanted else a for a in plan.assignments
366
+ ),
367
+ )
368
+ split = SplitSettings.from_dict(plan.settings["split"])
369
+ return _save_edit(conn, plan, simulate(cleared, split))
370
+
371
+
372
+ NAME_MAX = 40 # 网易云的实际上限待实测,先按 40 字
373
+
374
+
375
+ def default_name(plan: Plan, category: str) -> str:
376
+ template = str(plan.settings["name_template"])
377
+ return template.format(category=category, source=source_label(plan.sources))
378
+
379
+
380
+ def rename_playlist(
381
+ conn: sqlite3.Connection,
382
+ plan_id: str,
383
+ category: str,
384
+ name: str | None,
385
+ *,
386
+ expected_hash: str | None = None,
387
+ confirm_reset: bool = True,
388
+ ) -> ReclassifyResult:
389
+ """建歌单前改某个歌单的名字;空名字或与默认名相同时恢复默认名。
390
+
391
+ 名字计入内容哈希,所以改名会使批准作废(expected_hash 与 confirm_reset 同 reclassify)。
392
+ """
393
+ plan = _load_editable(conn, plan_id, expected_hash, confirm_reset)
394
+ current = {p.category: p.name for p in plan.playlists()}
395
+ if category not in current:
396
+ raise MoveError("没有这个歌单")
397
+ name = (name or "").strip()
398
+ if len(name) > NAME_MAX:
399
+ raise MoveError(f"歌单名最多 {NAME_MAX} 个字")
400
+ names = dict(plan.settings.get("names") or {})
401
+ if not name or name == default_name(plan, category):
402
+ names.pop(category, None)
403
+ name = default_name(plan, category)
404
+ else:
405
+ names[category] = name
406
+ if any(n == name for c, n in current.items() if c != category):
407
+ raise MoveError("已经有一个同名歌单了")
408
+ settings = {k: v for k, v in plan.settings.items() if k != "names"}
409
+ if names: # 没有改名时不留这个键,旧方案的设置保持原样
410
+ settings["names"] = names
411
+ return _save_edit(conn, plan, replace(plan, settings=settings))
412
+
413
+
414
+ def genre_counts(plan: Plan) -> dict[str, list[dict[str, Any]]]:
415
+ """方案里出现过的一级曲风和"一级-二级"路径,各有多少首歌带这个标签(含下级)。
416
+
417
+ 供"只分出 X"挑选 X;数量是"含该标签"的口径,与最终分类数不同。label 是按它分出的
418
+ 分类名(曲风名本身可能含 "-",不能从路径反拆)。
419
+ """
420
+ _, tags, taxonomy = _snapshot_inputs(plan)
421
+ level1: Counter[str] = Counter()
422
+ level2: Counter[str] = Counter()
423
+ labels: dict[str, str] = {}
424
+ for t in tags.values():
425
+ l1: set[str] = set()
426
+ l2: set[str] = set()
427
+ for g in t.genres:
428
+ if g.tag_id not in taxonomy.nodes:
429
+ continue
430
+ chain = taxonomy.chain(g.tag_id)
431
+ l1.add(chain[0].name)
432
+ labels[chain[0].name] = chain[0].name
433
+ if len(chain) > 1:
434
+ path = taxonomy.path(chain[1].tag_id)
435
+ l2.add(path)
436
+ labels[path] = chain[1].name
437
+ level1.update(l1)
438
+ level2.update(l2)
439
+
440
+ def rows(c: Counter[str]) -> list[dict[str, Any]]:
441
+ ordered = sorted(c.items(), key=lambda kv: (-kv[1], kv[0]))
442
+ return [{"name": k, "label": labels[k], "count": v} for k, v in ordered]
443
+
444
+ return {"level1": rows(level1), "level2": rows(level2)}
@@ -0,0 +1 @@
1
+ """批准、写回、续传、撤销。"""
@@ -0,0 +1,126 @@
1
+ """批准与执行前检查。
2
+
3
+ - 批准只能由人在交互式终端完成:stdin 和 stdout 都必须是终端,并输入预览里显示的确认码。
4
+ - CLI 的 apply 无论是否带 --yes,都只能在交互式终端下执行;--yes 只跳过执行确认,不能代替批准。
5
+ - 非交互地执行已批准方案只走 MCP 的 apply_plan,不在这里。
6
+
7
+ 是否交互式由本模块自己读取 sys.stdin / sys.stdout 判断,不接受调用方传入;
8
+ 不提供任何绕过这一检查的参数、环境变量或代码路径。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import secrets
14
+ import sqlite3
15
+ import sys
16
+ from collections.abc import Callable
17
+ from datetime import UTC, datetime
18
+
19
+ from tunetidy.plan import Plan, PlanStatus
20
+ from tunetidy.storage import plans as plan_store
21
+
22
+ Ask = Callable[[str], str]
23
+ Say = Callable[[str], None]
24
+
25
+
26
+ class ApprovalError(RuntimeError):
27
+ pass
28
+
29
+
30
+ def _interactive() -> bool:
31
+ try:
32
+ return sys.stdin.isatty() and sys.stdout.isatty()
33
+ except (AttributeError, ValueError): # 流被替换或已关闭
34
+ return False
35
+
36
+
37
+ def require_interactive(action: str) -> None:
38
+ if not _interactive():
39
+ raise ApprovalError(
40
+ f"{action}只能在交互式终端里由你本人完成。"
41
+ "如果是 AI 助手在运行这个命令,请自己打开终端执行。"
42
+ )
43
+
44
+
45
+ def approval_is_valid(plan: Plan) -> bool:
46
+ """批准有效:状态为已批准,且批准时的哈希等于按当前内容重算的哈希。"""
47
+ return plan.status == PlanStatus.APPROVED and plan.approved_hash == plan.content_hash
48
+
49
+
50
+ def check_executable(plan: Plan) -> None:
51
+ """执行前检查。已执行、已撤销、执行中的方案不能再执行;未批准或批准失效也不行。"""
52
+ if plan.status == PlanStatus.APPLIED:
53
+ raise ApprovalError(f"方案 {plan.id} 已执行过,不能再次执行(避免生成第二批歌单)")
54
+ if plan.status == PlanStatus.UNDONE:
55
+ raise ApprovalError(f"方案 {plan.id} 已撤销,不能再次执行;请重新 analyze")
56
+ if plan.status == PlanStatus.APPLYING:
57
+ raise ApprovalError(f"方案 {plan.id} 正在执行或上次中断,请用续传,不要重新执行")
58
+ if plan.status != PlanStatus.APPROVED:
59
+ raise ApprovalError(f"方案 {plan.id} 还没有批准,请先运行 tunetidy approve {plan.id}")
60
+ if not approval_is_valid(plan):
61
+ raise ApprovalError(f"方案 {plan.id} 的内容在批准后发生了变化,批准已失效,请重新批准")
62
+
63
+
64
+ def preview_lines(plan: Plan) -> list[str]:
65
+ playlists = plan.playlists()
66
+ total = sum(len(p.songs) for p in playlists)
67
+ lines = [
68
+ f"方案 {plan.id}(来源:{'、'.join(s.name for s in plan.sources)})",
69
+ f"将新建 {len(playlists)} 个私密歌单,共写入 {total} 首:",
70
+ ]
71
+ lines += [f" {p.name} {len(p.songs)} 首" for p in playlists]
72
+ pending, unavailable = len(plan.pending()), len(plan.unavailable())
73
+ ungrouped = len(plan.ungrouped())
74
+ extra = f",未分组 {ungrouped} 首" if ungrouped else ""
75
+ lines.append(f"不写入:待确认 {pending} 首,无法加入(下架或无版权){unavailable} 首{extra}")
76
+ lines.append("只会新建歌单,不会修改或删除你原有的任何歌单。")
77
+ return lines
78
+
79
+
80
+ def new_code() -> str:
81
+ return f"{secrets.randbelow(9000) + 1000}"
82
+
83
+
84
+ def approve_interactive(
85
+ conn: sqlite3.Connection,
86
+ plan_id: str,
87
+ ask: Ask = input,
88
+ say: Say = print,
89
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
90
+ ) -> Plan:
91
+ require_interactive("批准")
92
+ plan = plan_store.load_plan(conn, plan_id)
93
+ if plan.status not in (PlanStatus.DRAFT, PlanStatus.APPROVED):
94
+ raise ApprovalError(f"方案状态为 {plan.status.value},不能批准")
95
+ if approval_is_valid(plan):
96
+ say(f"方案 {plan.id} 已经批准过,内容没有变化,无需再次批准。")
97
+ return plan
98
+ if not plan.playlists():
99
+ raise ApprovalError("这个方案没有要新建的歌单,无需批准")
100
+ for line in preview_lines(plan):
101
+ say(line)
102
+ code = new_code()
103
+ answer = ask(f"确认批准请输入 {code}(其他输入取消):").strip()
104
+ if answer != code:
105
+ raise ApprovalError("确认码不符,已取消,没有批准")
106
+ try:
107
+ plan_store.record_approval(
108
+ conn, plan.id, plan.content_hash, now().isoformat(timespec="seconds")
109
+ )
110
+ except plan_store.PlanStateError as e:
111
+ raise ApprovalError(str(e)) from e
112
+ say(f"已批准方案 {plan.id}。执行:tunetidy apply {plan.id}")
113
+ return plan_store.load_plan(conn, plan.id)
114
+
115
+
116
+ def confirm_execution(plan: Plan, yes: bool, ask: Ask = input, say: Say = print) -> None:
117
+ """apply 执行前确认。无论 yes 与否都要求交互式终端;yes 只跳过确认码这一步。"""
118
+ require_interactive("执行写回")
119
+ check_executable(plan)
120
+ if yes:
121
+ return
122
+ for line in preview_lines(plan):
123
+ say(line)
124
+ code = new_code()
125
+ if ask(f"开始写回请输入 {code}(其他输入取消):").strip() != code:
126
+ raise ApprovalError("确认码不符,已取消,没有写入任何内容")