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.
- tunetidy/__init__.py +3 -0
- tunetidy/analyze.py +444 -0
- tunetidy/apply/__init__.py +1 -0
- tunetidy/apply/approval.py +126 -0
- tunetidy/apply/undo.py +204 -0
- tunetidy/apply/writer.py +220 -0
- tunetidy/backends/__init__.py +34 -0
- tunetidy/backends/base.py +73 -0
- tunetidy/backends/web/__init__.py +12 -0
- tunetidy/backends/web/backend.py +241 -0
- tunetidy/backends/web/client.py +174 -0
- tunetidy/backends/web/crypto.py +86 -0
- tunetidy/backends/web/parse.py +178 -0
- tunetidy/backends/web/session.py +122 -0
- tunetidy/classify/__init__.py +1 -0
- tunetidy/classify/pipeline.py +229 -0
- tunetidy/classify/rules.py +38 -0
- tunetidy/classify/scoring.py +53 -0
- tunetidy/classify/split.py +183 -0
- tunetidy/classify/taxonomy.py +85 -0
- tunetidy/cli.py +462 -0
- tunetidy/config.py +141 -0
- tunetidy/evaluate.py +60 -0
- tunetidy/export.py +210 -0
- tunetidy/jobs.py +227 -0
- tunetidy/mcp/__init__.py +1 -0
- tunetidy/mcp/server.py +75 -0
- tunetidy/mcp/tools.py +206 -0
- tunetidy/models.py +105 -0
- tunetidy/plan.py +134 -0
- tunetidy/server/__init__.py +1 -0
- tunetidy/server/api.py +342 -0
- tunetidy/server/app.py +138 -0
- tunetidy/server/static/assets/index-CRt4YQCx.css +2 -0
- tunetidy/server/static/assets/index-Dj1Vs3CY.js +11 -0
- tunetidy/server/static/index.html +14 -0
- tunetidy/signals/__init__.py +1 -0
- tunetidy/signals/official.py +63 -0
- tunetidy/storage/__init__.py +1 -0
- tunetidy/storage/db.py +251 -0
- tunetidy/storage/jobs.py +157 -0
- tunetidy/storage/plans.py +197 -0
- tunetidy/storage/repo.py +169 -0
- tunetidy/storage/runs.py +159 -0
- tunetidy-1.0.0.dist-info/METADATA +140 -0
- tunetidy-1.0.0.dist-info/RECORD +49 -0
- tunetidy-1.0.0.dist-info/WHEEL +4 -0
- tunetidy-1.0.0.dist-info/entry_points.txt +2 -0
- tunetidy-1.0.0.dist-info/licenses/LICENSE +21 -0
tunetidy/__init__.py
ADDED
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("确认码不符,已取消,没有写入任何内容")
|