Files

409 lines
18 KiB
Python

# -*- 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 界面。')