添加程序源码 app/ 与数据 data/,更新 README、.gitignore、.gitattributes
This commit is contained in:
@@ -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 界面。')
|
||||
Reference in New Issue
Block a user