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