添加程序源码 app/ 与数据 data/,更新 README、.gitignore、.gitattributes

This commit is contained in:
UMR
2026-08-31 00:06:15 +08:00
parent cbf1001162
commit 4fd28965e9
9 changed files with 3571 additions and 162 deletions
+1592
View File
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
"""网易云音乐 .ncm 解密模块 v1
纯标准库实现(零依赖):AES-128-ECB 仅用于解密几百字节的密钥块和元数据;
音频为 256 字节 RC4 式密钥盒循环 XOR,已装 numpy 时自动向量化加速(98MB 约 0.1 秒),
未装则用预计算转换表回退(纯 Python 约 10~30 MB/s)。
算法已与开源实现(ncmdump-py / UnlockMusic)端到端验证,4 个测试文件字节级一致。
用法:
import ncm_decrypt
ncm_decrypt.decrypt_file('xxx.ncm', 'xxx.flac') # 解密音频
key, meta, cover, audio_off = ncm_decrypt.parse_ncm(...) # 只解析头/元数据
"""
import base64
import json
import os
import struct
__all__ = ['parse_ncm', 'decrypt_audio', 'decrypt_file']
CORE_KEY = bytes.fromhex('687a4852416d736f356b496e62617857') # 'hzHRAmso5kInbaxW'
META_KEY = bytes.fromhex('2331346C6A6B5F215C5D2630553C2728') # '#14ljk_!\]&0U<\'('
MAGIC = b'CTENFDAM'
# ---------- 微型 AES-128(仅解密方向,ECB) ----------
# 逆 S 盒由正 S 盒程序化推导,杜绝手打笔误
_SBOX = (
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)
_INV_SBOX = [0] * 256
for _i, _v in enumerate(_SBOX):
_INV_SBOX[_v] = _i
_RCON = (0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36)
_ROUNDS = 10
def _bytes2matrix(text):
return [list(text[i:i + 4]) for i in range(0, 16, 4)]
def _matrix2bytes(matrix):
return bytes(sum(matrix, []))
def _xtime(a):
return (((a << 1) ^ 0x1B) & 0xFF) if (a & 0x80) else (a << 1)
def _add_round_key(s, k):
for i in range(4):
for j in range(4):
s[i][j] ^= k[i][j]
def _expand_key(master_key):
key_columns = _bytes2matrix(master_key)
i = 1
while len(key_columns) < (_ROUNDS + 1) * 4:
word = list(key_columns[-1])
if len(key_columns) % 4 == 0:
word.append(word.pop(0))
word = [_SBOX[b] for b in word]
word[0] ^= _RCON[i]
i += 1
word = bytes(p ^ q for p, q in zip(word, key_columns[-4]))
key_columns.append(word)
return [key_columns[4 * i:4 * (i + 1)] for i in range(len(key_columns) // 4)]
def _inv_shift_rows(s):
s[0][1], s[1][1], s[2][1], s[3][1] = s[3][1], s[0][1], s[1][1], s[2][1]
s[0][2], s[1][2], s[2][2], s[3][2] = s[2][2], s[3][2], s[0][2], s[1][2]
s[0][3], s[1][3], s[2][3], s[3][3] = s[1][3], s[2][3], s[3][3], s[0][3]
def _inv_sub_bytes(s):
for i in range(4):
for j in range(4):
s[i][j] = _INV_SBOX[s[i][j]]
def _mix_columns(s):
for i in range(4):
t = s[i][0] ^ s[i][1] ^ s[i][2] ^ s[i][3]
u = s[i][0]
s[i][0] ^= t ^ _xtime(s[i][0] ^ s[i][1])
s[i][1] ^= t ^ _xtime(s[i][1] ^ s[i][2])
s[i][2] ^= t ^ _xtime(s[i][2] ^ s[i][3])
s[i][3] ^= t ^ _xtime(s[i][3] ^ u)
def _inv_mix_columns(s):
for i in range(4):
u = _xtime(_xtime(s[i][0] ^ s[i][2]))
v = _xtime(_xtime(s[i][1] ^ s[i][3]))
s[i][0] ^= u
s[i][1] ^= v
s[i][2] ^= u
s[i][3] ^= v
_mix_columns(s)
def _aes_ecb_decrypt(data, key16):
rk = _expand_key(key16)
out = bytearray()
for i in range(0, len(data), 16):
s = _bytes2matrix(data[i:i + 16])
_add_round_key(s, rk[-1])
for rnd in range(_ROUNDS - 1, 0, -1):
_inv_shift_rows(s)
_inv_sub_bytes(s)
_add_round_key(s, rk[rnd])
_inv_mix_columns(s)
_inv_shift_rows(s)
_inv_sub_bytes(s)
_add_round_key(s, rk[0])
out += _matrix2bytes(s)
return bytes(out)
def _pkcs7_unpad(data):
"""去掉 PKCS#7 填充(填充长度 = 末尾字节值)。"""
n = data[-1]
if not (1 <= n <= 16 and data[-n:] == bytes([n]) * n):
raise ValueError('PKCS#7 填充无效')
return data[:-n]
# ---------- ncm 解析与解密 ----------
def _keybox(key):
"""RC4 式密钥盒: 标准 KSA + 一轮 PRGA 生成 256 字节循环密钥流。"""
S = list(range(256))
j = 0
for a in range(256):
j = (S[a] + j + key[a % len(key)]) & 255
S[a], S[j] = S[j], S[a]
ks = [0] * 256
for k in range(256):
a = S[(k + 1) & 255]
b = S[((k + 1) + a) & 255]
ks[k] = S[(a + b) & 255]
return ks
def parse_ncm(path):
"""解析 .ncm 文件头。
返回 (rc4_key, meta_dict, cover_bytes, audio_offset)。
meta_dict 含 musicName/artist/album/format 等字段(元数据为空时为 {})。
"""
with open(path, 'rb') as f:
b = f.read()
if b[:8] != MAGIC:
raise ValueError('不是有效的 ncm 文件(魔数不匹配)')
# 密钥块: XOR 0x64 -> AES-128-ECB(Core Key) -> 去填充 -> 去 17 字节前缀
key_len = struct.unpack('<I', b[10:14])[0]
key_block = bytes(c ^ 0x64 for c in b[14:14 + key_len])
dec = _pkcs7_unpad(_aes_ecb_decrypt(key_block, CORE_KEY))
if not dec.startswith(b'neteasecloudmusic'):
raise ValueError('密钥块解密失败(前缀不匹配)')
rc4_key = dec[len(b'neteasecloudmusic'):] # 17 字节
# 元数据: XOR 0x63 -> 去 22 字节前缀 -> base64 -> AES-128-ECB(Meta Key)
meta_off = 14 + key_len
meta_len = struct.unpack('<I', b[meta_off:meta_off + 4])[0]
meta = {}
if meta_len:
m_block = bytes(c ^ 0x63 for c in b[meta_off + 4:meta_off + 4 + meta_len])
m_dec = _pkcs7_unpad(_aes_ecb_decrypt(base64.b64decode(m_block[22:]), META_KEY))
i = m_dec.index(b':')
data = json.loads(m_dec[i + 1:].decode('utf-8', 'replace'))
meta = data['mainMusic'] if m_dec[:i] == b'dj' else data # dj 电台变体取 mainMusic
# 音频起点: CRC(4) + 未知(5) + 封面长度(4) + 封面(未加密)
meta_end = meta_off + 4 + meta_len
cover_len = struct.unpack('<I', b[meta_end + 9:meta_end + 13])[0]
cover = b[meta_end + 13:meta_end + 13 + cover_len]
audio_off = meta_end + 13 + cover_len
return rc4_key, meta, cover, audio_off
def decrypt_audio(path, out_path):
"""解密 .ncm 的音频部分写入 out_path,返回 meta_dict。
numpy 已装时向量化解密(百 MB 秒级),否则用预计算转换表回退。
"""
rc4_key, meta, _, audio_off = parse_ncm(path)
ks = _keybox(rc4_key)
with open(path, 'rb') as f:
f.seek(audio_off)
enc = f.read()
n = len(enc)
try: # numpy 加速路径
import numpy as np
kst = np.tile(np.frombuffer(bytes(ks), dtype=np.uint8), (n + 255) // 256)[:n]
out = (np.frombuffer(enc, dtype=np.uint8) ^ kst).tobytes()
except ImportError: # 纯标准库回退: 预计算 256 张 XOR 转换表
tables = [bytes(x ^ k for x in range(256)) for k in ks]
out = bytearray()
for j in range(0, n, 1 << 20): # 按 1MB 分块,块内每 256 字节轮换表
chunk = enc[j:j + (1 << 20)]
out += b''.join(chunk[t:t + 256].translate(tables[(j // 256 + t // 256) & 255])
for t in range(0, len(chunk), 256))
out = bytes(out)
with open(out_path, 'wb') as f:
f.write(out)
return meta
def decrypt_file(src_path, out_path=None):
"""解密整个 .ncm 文件。out_path 缺省时输出到同目录同名 .flac/.mp3(按元数据格式)。"""
meta = parse_ncm(src_path)[1]
if out_path is None:
ext = '.' + (meta.get('format') or 'mp3')
out_path = os.path.splitext(src_path)[0] + ext
decrypt_audio(src_path, out_path)
return out_path
if __name__ == '__main__':
# 自检: NIST SP 800-38A F.1.1 标准向量(AES-128-ECB 解密)
k = bytes(range(16))
ct = bytes.fromhex('69c4e0d86a7b0430d8cdb78070b4c55a')
ok = _aes_ecb_decrypt(ct, k).hex() == '00112233445566778899aabbccddeeff'
print('AES 自检:', '通过' if ok else '失败!')
sys_exit = 0 if ok else 1
import sys
sys.exit(sys_exit)
+408
View File
@@ -0,0 +1,408 @@
# -*- coding: utf-8 -*-
"""二次元音乐整理工具 Web 界面(v4)
纯标准库本地服务: 仅绑定 127.0.0.1 随机端口,自动打开浏览器。
与命令行共用 anime_sorter.run_pipeline / execute_moves 等核心函数,零逻辑重复。
用法: python anime_sorter.py --web [文件夹路径]
"""
import json
import os
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import anime_sorter
# 共享状态(所有变更持锁)
STATE = {
'lock': threading.Lock(),
'phase': 'idle', # idle / scanning / planned / applying / done / error / stopped
'progress': {'phase': '', 'i': 0, 'n': 0, 'label': '', 'result': ''},
'folder': '',
'offline': True,
'result': None,
'stop': False,
'error': None,
'last': None, # 最近一次执行的统计
'found': [], # 已识别歌曲明细(进度栏下方罗列用)
'tool_dir': '',
}
_BUSY = ('scanning', 'applying')
def _progress_cb(phase, i, n, *rest):
with STATE['lock']:
if phase == 'identify' and len(rest) >= 2:
STATE['progress'] = {'phase': phase, 'i': i, 'n': n,
'label': rest[0], 'result': rest[1]}
# 累积已识别明细(Web 界面进度栏下方罗列)
extra = rest[2] if len(rest) > 2 else {}
STATE['found'].append({'i': i, 'n': n, 'label': rest[0], 'result': rest[1],
'category': extra.get('category', ''),
'group': extra.get('group', ''),
'date': extra.get('date', '')})
else:
STATE['progress'] = {'phase': phase, 'i': i, 'n': n,
'label': rest[-1] if rest else '', 'result': ''}
def _stop_check():
with STATE['lock']:
return STATE['stop']
def _worker_scan(folder, offline, recursive, output):
try:
opts = {'offline': offline, 'recursive': recursive}
if output:
opts['output'] = output
result = anime_sorter.run_pipeline(folder, opts, _progress_cb, _stop_check)
anime_sorter.write_guide(STATE['tool_dir'], anime_sorter.render_guide(result))
with STATE['lock']:
STATE['result'] = result
STATE['phase'] = 'planned'
STATE['stop'] = False
except Exception as e:
with STATE['lock']:
STATE['phase'] = 'error'
STATE['error'] = str(e)
def _worker_apply():
with STATE['lock']:
result = STATE['result']
if result is None:
with STATE['lock']:
STATE['phase'] = 'planned'
return
stats = anime_sorter.execute_moves(result['moves'], _progress_cb, _stop_check)
anime_sorter.write_anime_map(STATE['tool_dir'], result['recs'])
with STATE['lock']:
if STATE.get('recursive'):
anime_sorter.remove_empty_dirs(STATE['folder']) # 清理源文件夹空壳目录
with STATE['lock']:
# 把已成功移动的文件新位置回写记录(音频/ncm/歌词/封面),计划回到可编辑态,
# 支持用户继续调整分类/顺序并再次应用。
# 用一次性快照映射匹配(rec['src'] 会随音频移动更新,不能用它匹配后续 ncm/歌词条目)。
rec_by_src = {r['src']: r for r in result['recs']}
for m in result['moves']:
if not m.get('done') or not m.get('rec_src'):
continue
rec = rec_by_src.get(m['rec_src'])
if rec is None:
continue
if m['kind'] == 'audio':
rec['src'] = m['dst']
elif m['kind'] == 'ncm':
rec['ncm_pair'] = m['dst']
elif m['kind'] == 'lrc':
rec['lrc_pair'] = m['dst']
elif m['kind'] == 'cover':
rec['cover'] = None
STATE['last'] = stats
STATE['phase'] = 'planned' # 可继续编辑并再次应用
STATE['stop'] = False
_replan_ordered()
def _replan_ordered():
"""编辑后重算编号与移动计划(保持当前列表顺序,不重新按规则分组排序),并刷新导览文件。"""
with STATE['lock']:
result = STATE['result']
if result is None:
return False
anime_sorter.assign_numbers(result['by_cat'], result['ctx']['root'], result['ctx']['cfg'])
moves, skips, warnings = anime_sorter.build_move_plan(result['by_cat'], result['ctx'])
stats = {k: {'moves': 0, 'skips': 0} for k in anime_sorter.CATEGORY_KEYS}
for m in moves:
stats[m['category']]['moves'] += 1
for s in skips:
stats[s['category']]['skips'] += 1
with STATE['lock']:
result['moves'] = moves
result['skips'] = skips
result['ctx']['warnings'] = warnings
result['stats'] = stats
# 标注/换分类/排序等编辑后立即刷新导览,磁盘文件与界面保持一致
anime_sorter.write_guide(STATE['tool_dir'], anime_sorter.render_guide(result))
return True
def _move_records(srcs, category, before=None):
"""把一批记录移入指定分类(before 非空则插到该曲目之前,否则追加末尾),保持列表其余顺序。
已在目标分类且无插入点的记录原地不动(避免"没改动也被挪到末尾"的错乱)。
"""
src_set = set(srcs)
moved = []
new_by_cat = {k: [] for k in anime_sorter.CATEGORY_KEYS}
with STATE['lock']:
result = STATE['result']
if result is None:
return None
_ANISONG_SRCS = ('本地', '缓存', '手动', 'animethemes', 'anison', 'wikipedia', 'moegirl')
for key in anime_sorter.CATEGORY_KEYS:
for r in result['by_cat'][key]:
if r['src'] in src_set:
if r['category'] == category and before is None:
new_by_cat[key].append(r) # 同分类无插入点: 原地保留
continue
r['category'] = category
# 移入 Anisong 但没有系列来源时清空 group(下拉显示「新建系列」占位,
# 而不是把歌手名误当系列名)
if category == 'anisong' and r.get('source') not in _ANISONG_SRCS:
r['group'] = ''
moved.append(r)
else:
new_by_cat[key].append(r)
target = new_by_cat[category]
if before and any(r['src'] == before for r in target):
idx = next(i for i, r in enumerate(target) if r['src'] == before)
target[idx:idx] = moved
else:
target.extend(moved)
result['by_cat'] = new_by_cat
_replan_ordered()
return result
def _plan_json():
with STATE['lock']:
result = STATE['result']
if result is None:
return None
cfg = result['ctx']['cfg']
categories = [{'key': k, 'name': cfg['categories'][k]}
for k in anime_sorter.CATEGORY_KEYS]
cats = {}
for k in anime_sorter.CATEGORY_KEYS:
cats[k] = [{
'src': r['src'], 'raw': r['raw'], 'artist': r['artist'],
'title': r['title'], 'group': r['group'], 'source': r['source'],
'date': r.get('date') or '', 'number': r['number'], 'final_name': r['final_name'],
'has_ncm': bool(r.get('ncm_pair')), 'has_lrc': bool(r.get('lrc_pair')),
'has_cover': bool(r.get('cover')),
} for r in result['by_cat'][k]]
stats = result['stats']
# 数据源状态(供界面面板显示)
su = result['ctx']['source_use']
broken = result['ctx'].get('broken', {})
sources = []
for name, (_, needs_proxy) in anime_sorter.SOURCES.items():
u = su[name]
sources.append({'name': name, 'needs_proxy': needs_proxy,
'ok': u['ok'], 'fail': u['fail'], 'err': u.get('err'),
'enabled': bool(result['ctx']['enabled'].get(name)),
'disabled': broken.get(name, 0) >= anime_sorter.SOURCE_MAX_FAILS})
mb = su['musicbrainz']
sources.append({'name': 'musicbrainz', 'needs_proxy': True,
'ok': mb['ok'], 'fail': mb['fail'], 'cache': mb.get('cache', 0),
'err': mb.get('err'),
'enabled': bool(result['ctx']['mb_state']['enabled']),
'disabled': mb['fail'] > 0 and not result['ctx']['mb_state']['enabled']})
# 已知动漫系列名(供手动标注时的候选下拉)
series = [s['name'] for s in result['ctx']['series_list']]
return {'categories': categories, 'cats': cats, 'stats': stats,
'warnings': result['ctx']['warnings'], 'sources': sources,
'series': series}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # 静默请求日志
pass
def _send(self, obj, code=200):
body = json.dumps(obj, ensure_ascii=False).encode('utf-8')
self.send_response(code)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_body(self):
n = int(self.headers.get('Content-Length') or 0)
return json.loads(self.rfile.read(n).decode('utf-8')) if n else {}
def do_GET(self):
if self.path in ('/', '/index.html'):
ui = os.path.join(STATE['tool_dir'], 'app', 'web_ui.html')
if os.path.exists(ui):
body = open(ui, 'rb').read()
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self._send({'ok': False, 'error': '缺少 web_ui.html'}, 404)
return
if self.path == '/api/status':
with STATE['lock']:
self._send({'ok': True, 'phase': STATE['phase'],
'progress': dict(STATE['progress']),
'folder': STATE['folder'], 'error': STATE['error'],
'last': STATE['last'],
'found': [dict(x) for x in STATE['found']]})
return
if self.path == '/api/plan':
self._send({'ok': True, 'plan': _plan_json()})
return
if self.path == '/api/guide':
with STATE['lock']:
result = STATE['result']
if result is None:
self._send({'ok': False, 'error': '请先扫描'}, 400)
return
self._send({'ok': True, 'text': anime_sorter.render_guide(result)})
return
self._send({'ok': False, 'error': '未知接口'}, 404)
def do_POST(self):
try:
# 关闭网页即退出: 页面 pagehide 时通过 sendBeacon 调用,服务随之停机
if self.path == '/api/exit':
self._send({'ok': True})
threading.Thread(target=self.server.shutdown, daemon=True).start()
return
body = self._read_body()
if self.path == '/api/scan':
folder = str(body.get('folder') or '').strip()
offline = bool(body.get('offline', False)) # 默认联网(本地映射表始终第一优先)
recursive = bool(body.get('recursive', False))
output = str(body.get('output') or '').strip()
if output and not os.path.isdir(output):
# 输出目录允许尚不存在(执行时自动创建),仅校验父级
if not os.path.isdir(os.path.dirname(os.path.abspath(output)) or os.getcwd()):
self._send({'ok': False, 'error': '输出目录无效'}, 400)
return
with STATE['lock']:
if STATE['phase'] in _BUSY:
self._send({'ok': False, 'error': '正在处理中,请稍候'}, 409)
return
if not folder or not os.path.isdir(folder):
self._send({'ok': False, 'error': '文件夹不存在'}, 400)
return
STATE['phase'] = 'scanning'
STATE['folder'] = folder
STATE['recursive'] = recursive # 应用后据此清理源目录空壳
STATE['error'] = None
STATE['last'] = None
STATE['stop'] = False
STATE['found'] = []
STATE['progress'] = {'phase': '', 'i': 0, 'n': 0, 'label': '', 'result': ''}
threading.Thread(target=_worker_scan, args=(folder, offline, recursive, output),
daemon=True).start()
self._send({'ok': True})
return
if self.path == '/api/apply':
with STATE['lock']:
if STATE['phase'] in _BUSY:
self._send({'ok': False, 'error': '正在处理中,请稍候'}, 409)
return
if STATE['phase'] != 'planned':
self._send({'ok': False, 'error': '请先扫描'}, 400)
return
STATE['phase'] = 'applying'
STATE['stop'] = False
threading.Thread(target=_worker_apply, daemon=True).start()
self._send({'ok': True})
return
if self.path == '/api/stop':
with STATE['lock']:
STATE['stop'] = True
self._send({'ok': True})
return
if self.path == '/api/override':
src, category = str(body.get('src') or ''), str(body.get('category') or '')
if category not in anime_sorter.CATEGORY_KEYS:
self._send({'ok': False, 'error': '分类无效'}, 400)
return
with STATE['lock']:
if STATE['result'] is None:
self._send({'ok': False, 'error': '请先扫描'}, 400)
return
if _move_records([src], category) is None:
self._send({'ok': False, 'error': '请先扫描'}, 400)
return
self._send({'ok': True, 'plan': _plan_json()})
return
if self.path == '/api/move':
srcs = [str(s) for s in (body.get('srcs') or [])]
category = str(body.get('category') or '')
before = body.get('before') or None
if not srcs or category not in anime_sorter.CATEGORY_KEYS:
self._send({'ok': False, 'error': '参数无效(需 srcs 与合法分类)'}, 400)
return
if _move_records(srcs, category, before) is None:
self._send({'ok': False, 'error': '请先扫描'}, 400)
return
self._send({'ok': True, 'plan': _plan_json()})
return
if self.path == '/api/assign':
src = str(body.get('src') or '')
anime = str(body.get('anime') or '').strip()
if not anime:
self._send({'ok': False, 'error': '请输入动漫名'}, 400)
return
with STATE['lock']:
result = STATE['result']
if result is None:
self._send({'ok': False, 'error': '请先扫描'}, 400)
return
rec = next((r for r in result['recs'] if r['src'] == src), None)
if rec is None:
self._send({'ok': False, 'error': '找不到该曲目'}, 400)
return
rec['group'] = anime
rec['source'] = '手动'
# 已存在的系列带出其首播年份;新建系列无年份(排序置后)
s_entry = next((s for s in result['ctx']['series_list'] if s['name'] == anime), None)
rec['date'] = (s_entry.get('date') or '')[:7] if s_entry else ''
# 分类变更交给 _move_records(已在 Anisong 时原地更新,不挪位置)
if _move_records([src], 'anisong') is None:
self._send({'ok': False, 'error': '请先扫描'}, 400)
return
self._send({'ok': True, 'plan': _plan_json()})
return
if self.path == '/api/reorder':
src, direction = str(body.get('src') or ''), int(body.get('dir') or 0)
with STATE['lock']:
result = STATE['result']
if result is None:
self._send({'ok': False, 'error': '请先扫描'}, 400)
return
rec = next((r for r in result['recs'] if r['src'] == src), None)
if rec is None:
self._send({'ok': False, 'error': '找不到该曲目'}, 400)
return
cat = rec['category']
lst = result['by_cat'].get(cat, [])
idx = next((i for i, r in enumerate(lst) if r['src'] == src), None)
j = idx + (-1 if direction < 0 else 1)
if idx is not None and 0 <= j < len(lst):
lst[idx], lst[j] = lst[j], lst[idx]
_replan_ordered()
self._send({'ok': True, 'plan': _plan_json()})
return
self._send({'ok': False, 'error': '未知接口'}, 404)
except Exception as e:
self._send({'ok': False, 'error': str(e)}, 500)
def start(tool_dir, folder=None):
"""启动本地服务并打开浏览器(阻塞运行)。"""
STATE['tool_dir'] = tool_dir
if folder:
STATE['folder'] = folder
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
port = server.server_address[1]
print(f'Web 界面: http://127.0.0.1:{port}/ (关闭网页或 Ctrl+C 退出)', flush=True)
threading.Thread(target=lambda: webbrowser.open(f'http://127.0.0.1:{port}/'),
daemon=True).start()
try:
server.serve_forever()
except KeyboardInterrupt:
print('\n已退出 Web 界面。')
+534
View File
@@ -0,0 +1,534 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anisong Organizer</title>
<style>
:root {
--bg: #f6f7f9; --card: #ffffff; --line: #e3e6ea; --text: #26303b; --muted: #7a8694;
--accent: #3b82f6; --green: #16a34a; --red: #dc2626; --yellow: #d97706;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #171a1f; --card: #20252c; --line: #333a44; --text: #e6eaf0; --muted: #8b95a1;
--accent: #60a5fa; --green: #4ade80; --red: #f87171; --yellow: #fbbf24; }
}
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 920px; margin: 0 auto; padding: 24px 16px 80px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { color: var(--muted); font-size: 13px; margin-bottom: 20px; }
.toolbar { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.trow { display: flex; gap: 12px; align-items: center; }
.spacer { flex: 1; }
input[type=text] { flex: 1; min-width: 240px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); color: var(--text); font-size: 14px; }
button { padding: 10px 20px; border: none; border-radius: 10px; font-size: 14px; cursor: pointer; background: var(--accent); color: #fff; }
button:hover { filter: brightness(1.08); }
button.primary { padding: 11px 28px; font-weight: 600; }
button.ghost { background: var(--card); color: var(--text); border: 1px solid var(--line); }
button.danger { background: var(--red); }
button:disabled { opacity: .45; cursor: not-allowed; }
select.series-sel { background: color-mix(in srgb, var(--accent) 10%, var(--card)); border-color: var(--line);
color: var(--accent); font-size: 12.5px; padding: 5px 22px 5px 10px; max-width: 200px; }
/* 无效占位项不显示在下拉列表里(只用于按钮封面显示「标注系列」占位) */
option:disabled { display: none; }
label.chk { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); cursor: pointer; }
.progress { display: none; margin: 12px 0; }
.bar { height: 8px; background: var(--line); border-radius: 4px; overflow: hidden; }
.bar > div { height: 100%; width: 0; background: var(--accent); transition: width .3s; }
.ptext { font-size: 13px; color: var(--muted); margin-top: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.found { background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 10px 16px; margin: 12px 0; display: none; max-height: 300px; overflow-y: auto; }
.found h3 { font-size: 13px; color: var(--muted); margin: 0 0 8px; font-weight: normal; }
.found .frow { display: flex; gap: 8px; align-items: baseline; font-size: 13px; padding: 3px 0; }
.found .fnum { color: var(--muted); font-variant-numeric: tabular-nums; min-width: 40px; }
.found .fname { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.found .fres { white-space: nowrap; color: var(--accent); }
.found .fanno { white-space: nowrap; color: var(--muted); font-size: 12px; }
.sources { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.src-chip { display: flex; align-items: center; gap: 6px; background: var(--card); border: 1px solid var(--line);
border-radius: 8px; padding: 6px 12px; font-size: 13px; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.ok { background: var(--green); } .dot.bad { background: var(--red); } .dot.warn { background: var(--yellow); }
.src-chip .hint { color: var(--muted); font-size: 12px; }
.batch { display: none; align-items: center; gap: 10px; margin: 10px 0; padding: 10px 14px;
background: var(--card); border: 1px solid var(--line); border-radius: 10px; font-size: 13px; }
.batch select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--text); }
.btn-mini { padding: 6px 14px; font-size: 13px; border-radius: 6px; }
.cat { background: var(--card); border: 1px solid var(--line); border-radius: 10px; margin: 14px 0; overflow: hidden; }
.cat.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; }
.cat h2 { font-size: 15px; margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }
.cat h2 .cnt { color: var(--muted); font-weight: normal; font-size: 13px; }
.row { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--line); font-size: 14px; cursor: default; }
.row:last-child { border-bottom: none; }
.row.dragging { opacity: .4; }
.row.drop-above { box-shadow: 0 -2px 0 0 var(--accent); }
.row.sel-on { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.sel { width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); flex: none; }
.num { min-width: 34px; text-align: center; background: var(--bg); border-radius: 6px; padding: 3px 6px; font-variant-numeric: tabular-nums; font-size: 13px; color: var(--muted); }
.info { flex: 1; min-width: 0; }
.info .t { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.info .m { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.src-badge { font-size: 11px; color: var(--muted); background: var(--bg); border-radius: 6px; padding: 2px 8px; white-space: nowrap; }
select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--text); font-size: 13px; }
.mini { padding: 4px 9px; font-size: 13px; border-radius: 6px; }
.assign-edit { display: flex; gap: 6px; align-items: center; flex: 1; min-width: 0; }
.assign-edit select { padding: 5px 8px; border: 1px solid var(--accent); border-radius: 6px;
background: var(--card); color: var(--text); font-size: 13px; max-width: 220px; }
.assign-edit input { flex: 1; min-width: 140px; padding: 5px 8px; border: 1px solid var(--accent);
border-radius: 6px; background: var(--card); color: var(--text); font-size: 13px; }
.foot { display: flex; gap: 8px; margin: 20px 0; align-items: center; }
.report { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; font-size: 14px; display: none; }
.report .ok { color: var(--green); } .report .bad { color: var(--red); } .report .warn { color: var(--yellow); }
.warnings { color: var(--yellow); font-size: 13px; margin-top: 10px; }
.empty { color: var(--muted); text-align: center; padding: 40px 0; font-size: 14px; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none;
align-items: center; justify-content: center; z-index: 100; padding: 20px; }
.modal { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
max-width: 860px; width: 100%; max-height: 82vh; display: flex; flex-direction: column; }
.modal h3 { margin: 0; padding: 14px 18px; font-size: 15px; border-bottom: 1px solid var(--line);
display: flex; justify-content: space-between; align-items: center; }
.modal .mbody { padding: 14px 18px; overflow-y: auto; font-family: Consolas, "Microsoft YaHei", monospace;
font-size: 12.5px; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
.modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex;
gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<div class="wrap">
<h1>Anisong Organizer</h1>
<div class="sub">扫描 → 预览分类计划 → 勾选/拖动调整 → 应用。可多次应用;点击歌曲旁的系列标签可直接更换或标注所属动漫。</div>
<div class="toolbar">
<div class="trow">
<input type="text" id="folder" placeholder="来源文件夹(如网易云下载目录)">
<input type="text" id="output" placeholder="输出目录(留空=来源文件夹)">
</div>
<div class="trow actions">
<label class="chk"><input type="checkbox" id="recursive">递归整理子目录(下载目录勾选)</label>
<span class="spacer"></span>
<button id="guideBtn" class="ghost">📖 导览</button>
<button id="stopBtn" class="danger" disabled>停止</button>
<button id="scanBtn" class="primary">扫描</button>
</div>
</div>
<div class="progress" id="progress">
<div class="bar"><div id="pbar"></div></div>
<div class="ptext" id="ptext"></div>
</div>
<div class="found" id="found">
<h3>已识别的歌曲</h3>
<div id="foundList"></div>
</div>
<div class="batch" id="batch">
<span>已选 <b id="batchN">0</b> 首 → 移到</span>
<select id="batchCat"></select>
<button class="btn-mini" onclick="batchMove()">移动</button>
<button class="btn-mini ghost" onclick="clearSel()">取消选择</button>
</div>
<div id="plans"></div>
<div class="foot" id="foot" style="display:none">
<button id="applyBtn">应用全部移动</button>
<span id="planNote" style="color:var(--muted);font-size:13px"></span>
</div>
<div class="report" id="report"></div>
<div class="modal-overlay" id="guideModal">
<div class="modal">
<h3>整理导览(移动对照 · 撤销日志)<button class="mini ghost" data-closeguide=""></button></h3>
<div class="mbody" id="guideBody">加载中…</div>
<div class="mfoot">
<button class="btn-mini ghost" id="guideCopy">复制</button>
<button class="btn-mini ghost" id="guideDownload">下载 导览.txt</button>
<button class="btn-mini" data-closeguide="">关闭</button>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
let planData = null, catMeta = [];
let selSet = new Set(); // 批量选择(src 集合)
let applyShownSig = 'null'; // 已展示过的应用统计签名
let newSeriesEdit = null; // 正在新建系列的编辑状态 {src, value}(外置保存,重渲染不丢失)
let pollTimer = null;
async function api(path, body) {
const r = await fetch(path, body === undefined ? {} : {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body),
});
return r.json();
}
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function srcLabel(s) {
return {'本地': '本地', 'animethemes': '网络', 'anison': '网络', 'wikipedia': '网络',
'moegirl': '网络', 'musicbrainz': 'MB', '手动': '手动', '': ''}[s] || s;
}
function renderSources(sources) {
const el = document.createElement('div');
el.className = 'sources';
for (const s of sources) {
let cls = 'ok', hint = '';
if (s.disabled) { cls = 'bad'; hint = `已停用(连续失败)`; }
else if (s.fail) { cls = 'warn'; hint = `失败 ${s.fail}`; }
else if (s.ok || s.cache) { hint = s.cache !== undefined ? `联网 ${s.ok}·缓存 ${s.cache}` : `正常`; }
else { cls = 'warn'; hint = '未使用'; }
if (s.needs_proxy) hint += '·需代理';
el.innerHTML += `<span class="src-chip" title="${esc(s.err || '')}">
<span class="dot ${cls}"></span>${esc(s.name)}<span class="hint">${esc(hint)}</span></span>`;
}
return el;
}
// 系列控件(蓝色下拉按钮): 封面显示该行所属动漫系列名;
// 无系列时封面显示「+ 标注系列…」占位(禁用项,不显示在下拉列表里);
// 下拉列表只有两个有效项: 第一行「+ 新建系列…」(选中进输入态) + 现存系列(选中即生效)。
function seriesControl(it, catKey) {
if (newSeriesEdit && newSeriesEdit.src === it.src) {
return `<span class="assign-edit">
<input type="text" data-atext placeholder="输入新系列名(如 犬夜叉)" value="${esc(newSeriesEdit.value)}">
<button class="mini" data-confirm="${esc(it.src)}">✓</button>
<button class="mini ghost" data-cancel="">✕</button></span>`;
}
// 注意: 计划 JSON 的分类条目没有 category 字段(分类由外层键表示),
// 必须用渲染循环传入的 catKey 判断——曾误用 it.category,导致封面永远显示占位符
const cur = catKey === 'anisong' ? (it.group || '') : '';
const names = (planData.series || []).slice();
if (cur && !names.includes(cur)) names.unshift(cur); // 本次新建的系列也要可选中
return `<select class="series-sel" data-series="${esc(it.src)}" title="所属动漫系列(第一行可新建)">
<option value="__new__"> 新建系列…</option>
${cur ? '' : '<option value="__none__" selected disabled> 标注系列…</option>'}
${names.map(n => `<option value="${esc(n)}"${n === cur ? ' selected' : ''}>${esc(n)}</option>`).join('')}
</select>`;
}
function renderPlan() {
const box = $('plans');
box.innerHTML = '';
if (!planData) return;
catMeta = planData.categories;
if (planData.sources) box.appendChild(renderSources(planData.sources));
let any = false;
for (const cat of catMeta) {
const items = planData.cats[cat.key] || [];
if (!items.length) continue;
any = true;
const el = document.createElement('div');
el.className = 'cat';
el.dataset.cat = cat.key;
el.innerHTML = `<h2>${esc(cat.name)}<span class="cnt">${items.length} 首 · 可拖动到此移动</span></h2>` +
items.map((it) => `
<div class="row${selSet.has(it.src) ? ' sel-on' : ''}" draggable="true"
data-src="${esc(it.src)}" data-cat="${cat.key}">
<input type="checkbox" class="sel"${selSet.has(it.src) ? ' checked' : ''}>
<span class="num">${String(it.number).padStart(2, '0')}</span>
<div class="info">
<div class="t">${esc(it.title)}</div>
<div class="m">${esc(it.artist || '')} → ${esc(it.final_name)}</div>
</div>
${seriesControl(it, cat.key)}
<span class="src-badge">${srcLabel(it.source)}</span>
<select data-selcat="${esc(it.src)}">
${catMeta.map(c => `<option value="${c.key}"${c.key === cat.key ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}
</select>
<button class="mini ghost" title="上移" data-up="${esc(it.src)}">↑</button>
<button class="mini ghost" title="下移" data-down="${esc(it.src)}">↓</button>
</div>`).join('');
box.appendChild(el);
}
if (!any) {
box.innerHTML = '<div class="empty">暂无计划。扫描一个包含音乐文件的文件夹开始。</div>';
}
$('foot').style.display = any ? 'flex' : 'none';
const total = Object.values(planData.stats || {}).reduce((s, c) => s + (c.moves || 0), 0);
$('planNote').textContent = `${total} 个移动。勾选多行后拖动可批量移动/排序,✎ 手动标注动漫。`;
if (planData.warnings && planData.warnings.length) {
const w = document.createElement('div');
w.className = 'warnings';
w.innerHTML = planData.warnings.map(esc).join('<br>');
box.appendChild(w);
}
updateBatch();
}
function updateBatch() {
const b = $('batch');
const n = selSet.size;
b.style.display = n ? 'flex' : 'none';
$('batchN').textContent = n;
if (n && !$('batchCat').options.length) {
$('batchCat').innerHTML = catMeta.map(c => `<option value="${c.key}">${esc(c.name)}</option>`).join('');
}
}
function clearSel() { selSet.clear(); if (planData) renderPlan(); }
// ---- 交互(事件委托) ----
$('plans').addEventListener('click', async (e) => {
const row = e.target.closest('.row');
if (!row) return;
const src = row.dataset.src;
if (e.target.classList.contains('sel')) {
if (e.target.checked) selSet.add(src); else selSet.delete(src);
renderPlan();
return;
}
// 注意: 系列下拉的点击不做任何拦截——点击自然打开原生下拉供浏览;
// 输入态只由 change 事件触发(选中第一行「新建系列」),见下方 change 监听。
if (e.target.dataset.confirm) {
// 确认新建系列(状态存于 newSeriesEdit,不依赖可能被重建的 DOM)
const val = newSeriesEdit ? newSeriesEdit.value.trim() : '';
const tgt = newSeriesEdit ? newSeriesEdit.src : '';
if (!val) { alert('请输入新系列名'); return; }
newSeriesEdit = null;
const r = await api('/api/assign', {src: tgt, anime: val});
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
else { alert(r.error || '标注失败'); renderPlan(); }
return;
}
if (e.target.dataset.cancel !== undefined) { newSeriesEdit = null; renderPlan(); return; }
if (e.target.dataset.up || e.target.dataset.down) {
const dir = e.target.dataset.up ? -1 : 1;
const r = await api('/api/reorder', {src, dir});
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
return;
}
// 注意: select[data-selcat] 只在 change 事件里处理(点击打开下拉时 click 不应触发移动,
// 否则"没改动也会把歌曲挪到末尾")。
});
$('plans').addEventListener('change', async (e) => {
// 分类下拉: 换分类
const sel = e.target.closest('select[data-selcat]');
if (sel) {
const row = sel.closest('.row');
const r = await api('/api/override', {src: row.dataset.src, category: sel.value});
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
return;
}
// 系列下拉: 选现存系列直接标注;第一行「新建系列」进入输入态
const ssel = e.target.closest('select[data-series]');
if (!ssel) return;
const src = ssel.dataset.series;
if (ssel.value === '__new__') {
newSeriesEdit = {src, value: ''};
renderPlan();
const inp = document.querySelector('input[data-atext]');
if (inp) inp.focus();
} else {
const r = await api('/api/assign', {src, anime: ssel.value});
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
else alert(r.error || '标注失败');
}
});
// 新建系列输入: 输入内容同步到外部状态,任何重渲染都不丢失
$('plans').addEventListener('input', (e) => {
if (e.target.dataset.atext !== undefined && newSeriesEdit) {
newSeriesEdit.value = e.target.value;
}
});
// ---- 拖拽(勾选多行时整组拖动) ----
let dragSrcs = [];
$('plans').addEventListener('dragstart', (e) => {
// 表单控件(下拉/输入/按钮)内不触发行拖拽,避免打断操作
if (e.target.closest('select, input, button, .assign-edit')) return;
const row = e.target.closest('.row');
if (!row) return;
const src = row.dataset.src;
if (!selSet.has(src)) { selSet.clear(); selSet.add(src); renderPlan(); }
dragSrcs = [...selSet];
e.dataTransfer.setData('text/plain', JSON.stringify(dragSrcs));
e.dataTransfer.effectAllowed = 'move';
document.querySelectorAll('.row').forEach(r => {
if (dragSrcs.includes(r.dataset.src)) r.classList.add('dragging');
});
});
$('plans').addEventListener('dragend', () => {
document.querySelectorAll('.row').forEach(r => r.classList.remove('dragging', 'drop-above'));
document.querySelectorAll('.cat').forEach(c => c.classList.remove('drop-target'));
});
$('plans').addEventListener('dragover', (e) => {
if (!dragSrcs.length) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.row').forEach(r => r.classList.remove('drop-above'));
document.querySelectorAll('.cat').forEach(c => c.classList.remove('drop-target'));
const row = e.target.closest('.row');
if (row && !dragSrcs.includes(row.dataset.src)) row.classList.add('drop-above');
else if (!row) {
const cat = e.target.closest('.cat');
if (cat) cat.classList.add('drop-target');
}
});
$('plans').addEventListener('drop', async (e) => {
if (!dragSrcs.length) return;
e.preventDefault();
const row = e.target.closest('.row');
const cat = e.target.closest('.cat');
let payload;
if (row && !dragSrcs.includes(row.dataset.src)) {
payload = {srcs: dragSrcs, category: row.dataset.cat, before: row.dataset.src};
} else if (cat) {
payload = {srcs: dragSrcs, category: cat.dataset.cat, before: null};
}
dragSrcs = [];
selSet.clear(); // 拖动完成即取消勾选
document.querySelectorAll('.row').forEach(r => r.classList.remove('dragging', 'drop-above'));
document.querySelectorAll('.cat').forEach(c => c.classList.remove('drop-target'));
if (!payload) return;
const r = await api('/api/move', payload);
if (r.ok && r.plan) { planData = r.plan; renderPlan(); }
else alert(r.error || '移动失败');
});
async function batchMove() {
const r = await api('/api/move', {srcs: [...selSet], category: $('batchCat').value, before: null});
if (r.ok && r.plan) { planData = r.plan; selSet.clear(); renderPlan(); }
else alert(r.error || '移动失败');
}
// ---- 顶部操作 ----
async function scan() {
const folder = $('folder').value.trim();
const r = await api('/api/scan', {
folder,
output: $('output').value.trim(),
recursive: $('recursive').checked,
});
if (!r.ok) { alert(r.error || '扫描失败'); return; }
planData = null; selSet.clear(); applyShownSig = 'null';
$('report').style.display = 'none';
$('plans').innerHTML = '<div class="empty">扫描中…</div>';
$('found').style.display = 'none';
$('foundList').innerHTML = '';
}
async function apply() {
if (!confirm('确认执行全部移动?此操作可通过「导览」中的移动对照回退。')) return;
await api('/api/apply', {});
}
async function stop() { await api('/api/stop', {}); }
// ---- 导览弹窗 ----
let guideText = '';
async function openGuide() {
const r = await api('/api/guide');
if (!r.ok) { alert(r.error || '暂无导览(请先扫描)'); return; }
guideText = r.text || '';
$('guideBody').textContent = guideText || '无需处理。';
$('guideModal').style.display = 'flex';
}
function closeGuide() { $('guideModal').style.display = 'none'; }
$('guideBtn').onclick = openGuide;
$('guideModal').addEventListener('click', (e) => {
if (e.target.dataset.closeguide !== undefined) closeGuide();
if (e.target === $('guideModal')) closeGuide();
});
$('guideCopy').onclick = async () => {
try {
await navigator.clipboard.writeText(guideText);
alert('已复制到剪贴板');
} catch (e) {
const ta = document.createElement('textarea');
ta.value = guideText;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
alert('已复制到剪贴板');
}
};
$('guideDownload').onclick = () => {
const blob = new Blob(['' + guideText], {type: 'text/plain;charset=utf-8'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '导览.txt';
a.click();
URL.revokeObjectURL(a.href);
};
// 已识别歌曲的注解: Anisong 标注所属动漫系列(结果即系列名,附首播年份);其他类标注乐队组建年份
function foundAnno(it) {
const y = (it.date || '').slice(0, 4);
if (it.category === 'anisong') return y ? `${y}` : '';
return y ? `${y}年组建` : '组建时间未知';
}
function renderFound(list) {
const box = $('found');
if (!list || !list.length) { box.style.display = 'none'; return; }
box.style.display = 'block';
$('foundList').innerHTML = list.map(it => `
<div class="frow">
<span class="fnum">${String(it.i).padStart(2, '0')}/${it.n}</span>
<span class="fname">${esc(it.label)}</span>
<span class="fres">→ ${esc(it.result)}</span>
<span class="fanno">${esc(foundAnno(it))}</span>
</div>`).join('');
box.scrollTop = box.scrollHeight; // 扫描中自动滚到最新
}
async function poll() {
try {
const st = await api('/api/status');
if (st.found) renderFound(st.found);
const busy = ['scanning', 'applying'].includes(st.phase);
$('scanBtn').disabled = busy;
$('applyBtn').disabled = busy || st.phase !== 'planned';
$('stopBtn').disabled = !busy;
$('progress').style.display = busy ? 'block' : 'none';
if (busy && st.progress) {
const p = st.progress;
if (p.n > 0) $('pbar').style.width = Math.round(p.i / p.n * 100) + '%';
else $('pbar').style.width = '3%';
$('ptext').textContent = p.i && p.n ? `[${p.i}/${p.n}] ${p.label}${p.result}` : (p.label || '处理中…');
}
if (st.phase === 'planned' && !planData) {
const r = await api('/api/plan');
if (r.ok) { planData = r.plan; renderPlan(); }
}
// 应用完成的统计报告(应用后计划自动回到可编辑态,可继续调整再次应用)
const sig = JSON.stringify(st.last || null);
if (sig !== applyShownSig) {
applyShownSig = sig;
if (st.last) {
const l = st.last;
const rep = $('report');
rep.style.display = 'block';
rep.innerHTML = `<span class="ok">✓ 应用完成</span>: 移动 ${l.moved},跳过 ${l.skipped},失败 ${l.failed}` +
(l.stopped ? ' <span class="warn">(已停止)</span>' : ' <span class="warn">· 可继续调整并再次应用</span>');
planData = null; // 强制重新加载计划(文件位置已更新)
selSet.clear();
}
} else if (st.phase === 'error') {
const rep = $('report');
rep.style.display = 'block';
rep.innerHTML = `<span class="bad">✗ 出错</span>: ${esc(st.error)}`;
}
} catch (e) { /* 服务未就绪 */ }
}
$('scanBtn').onclick = scan;
$('applyBtn').onclick = apply;
$('stopBtn').onclick = stop;
pollTimer = setInterval(poll, 800);
poll();
// 关闭网页即退出服务进程(用户要求: 页面关闭后不留后台进程)
window.addEventListener('pagehide', () => {
try { navigator.sendBeacon('/api/exit'); } catch (e) { /* 忽略 */ }
});
</script>
</body>
</html>