添加程序源码 app/ 与数据 data/,更新 README、.gitignore、.gitattributes
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# 统一换行符:文本文件入库为 LF,检出时按平台自动转换
|
||||
* text=auto
|
||||
|
||||
# .bat 必须是 CRLF(Windows 批处理遇 LF 会出错)
|
||||
*.bat text eol=crlf
|
||||
|
||||
# 源码与数据文件用 LF
|
||||
*.py text eol=lf
|
||||
*.html text eol=lf
|
||||
*.json text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.md text eol=lf
|
||||
+31
-162
@@ -1,176 +1,45 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# ===== Python 运行时字节码 =====
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
# ===== 内置 Python 运行时 =====
|
||||
# 约 21MB 官方嵌入式二进制,不进仓库
|
||||
# 源码版获取方式见 README「快速上手」第 0 步
|
||||
python/
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
# ===== 运行生成物 =====
|
||||
data/导览.txt
|
||||
data/识别缓存.json
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
# ===== 虚拟环境 =====
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# ===== 打包与分发 =====
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
*.zip
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
# ===== 日志与缓存 =====
|
||||
*.log
|
||||
.cache/
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pytest_cache/
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
# ===== IDE / 编辑器 =====
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
# ===== Windows 杂物 =====
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# ===== Claude Code 本地设置 =====
|
||||
.claude/settings.local.json
|
||||
|
||||
+1592
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
@@ -0,0 +1,638 @@
|
||||
{
|
||||
"_说明": "keys: 标签/文件名中可能出现的原始曲名变体(程序会自动规范化后再匹配); title: 最终使用的曲名。series 按首播日期排列, 系列内 songs 按发售/播出顺序排列。",
|
||||
"series": [
|
||||
{
|
||||
"name": "新世紀エヴァンゲリオン",
|
||||
"date": "1995-10-04",
|
||||
"songs": [
|
||||
{
|
||||
"title": "残酷な天使のテーゼ",
|
||||
"keys": [
|
||||
"残酷な天使のテーゼ <Director's Edit. Version II>",
|
||||
"残酷な天使のテーゼ Director's Edit. Version II"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "超魔神英雄伝ワタル",
|
||||
"date": "1997-10-02",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ひとつのハートで"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "犬夜叉",
|
||||
"date": "2000-10-16",
|
||||
"songs": [
|
||||
{
|
||||
"title": "疾風の如く",
|
||||
"keys": [
|
||||
"疾风の如く"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "慕情"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "涼宮ハルヒの憂鬱",
|
||||
"date": "2006-04-02",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ハレ晴レユカイ"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ハヤテのごとく!",
|
||||
"date": "2007-04-01",
|
||||
"songs": [
|
||||
{
|
||||
"title": "七転八起☆至上主義!"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "らき☆すた",
|
||||
"date": "2007-04-08",
|
||||
"songs": [
|
||||
{
|
||||
"title": "もってけ!セーラーふく"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "みなみけ",
|
||||
"date": "2007-10-07",
|
||||
"songs": [
|
||||
{
|
||||
"title": "経験値上昇中☆"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "空の境界",
|
||||
"date": "2007-12-01",
|
||||
"songs": [
|
||||
{
|
||||
"title": "oblivious"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "To LOVEる",
|
||||
"date": "2008-04-03",
|
||||
"songs": [
|
||||
{
|
||||
"title": "forever we can make it!"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ソウルイーター",
|
||||
"date": "2008-04-07",
|
||||
"songs": [
|
||||
{
|
||||
"title": "resonance"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "とらドラ!",
|
||||
"date": "2008-10-02",
|
||||
"songs": [
|
||||
{
|
||||
"title": "プレパレード"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "とあるシリーズ",
|
||||
"date": "2008-10-04",
|
||||
"songs": [
|
||||
{
|
||||
"title": "only my railgun"
|
||||
},
|
||||
{
|
||||
"title": "Real Force"
|
||||
},
|
||||
{
|
||||
"title": "LEVEL5-judgelight-"
|
||||
},
|
||||
{
|
||||
"title": "No buts!"
|
||||
},
|
||||
{
|
||||
"title": "sister's noise"
|
||||
},
|
||||
{
|
||||
"title": "Gravitation"
|
||||
},
|
||||
{
|
||||
"title": "final phase"
|
||||
},
|
||||
{
|
||||
"title": "青嵐のあとで"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "鋼の錬金術師 FULLMETAL ALCHEMIST",
|
||||
"date": "2009-04-05",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Again"
|
||||
},
|
||||
{
|
||||
"title": "嘘"
|
||||
},
|
||||
{
|
||||
"title": "レイン"
|
||||
},
|
||||
{
|
||||
"title": "瞬間センチメンタル"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "デュラララ!!",
|
||||
"date": "2010-01-07",
|
||||
"songs": [
|
||||
{
|
||||
"title": "裏切りの夕焼け"
|
||||
},
|
||||
{
|
||||
"title": "Steppin' out"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "俺の妹がこんなに可愛いわけがない",
|
||||
"date": "2010-10-03",
|
||||
"songs": [
|
||||
{
|
||||
"title": "irony"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "それでも町は廻っている",
|
||||
"date": "2010-10-07",
|
||||
"songs": [
|
||||
{
|
||||
"title": "DOWN TOWN",
|
||||
"keys": [
|
||||
"DOWN TOWN (OPテーマ)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "メイズ参上!"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "日常",
|
||||
"date": "2011-04-02",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ヒャダインのカカカタ☆カタオモイ-C"
|
||||
},
|
||||
{
|
||||
"title": "Zzz"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ゆるゆり",
|
||||
"date": "2011-07-04",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ゆりゆららららゆるゆり大事件"
|
||||
},
|
||||
{
|
||||
"title": "いぇす!ゆゆゆ☆ゆるゆり♪♪"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "バカとテストと召喚獣にっ!",
|
||||
"date": "2011-07-07",
|
||||
"songs": [
|
||||
{
|
||||
"title": "君+謎+私でJUMP!!",
|
||||
"keys": [
|
||||
"君+谜+私でJUMP!!"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "カーニバル・ファンタズム",
|
||||
"date": "2011-08-13",
|
||||
"songs": [
|
||||
{
|
||||
"title": "すーぱー☆あふぇくしょん"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "キルミーベイベー",
|
||||
"date": "2012-01-05",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ふたりのきもちのほんとのひみつ"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "這いよれ!ニャル子さん",
|
||||
"date": "2012-04-09",
|
||||
"songs": [
|
||||
{
|
||||
"title": "太陽曰く燃えよカオス"
|
||||
},
|
||||
{
|
||||
"title": "恋は渾沌の隷也"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "中二病でも恋がしたい!",
|
||||
"date": "2012-10-03",
|
||||
"songs": [
|
||||
{
|
||||
"title": "INSIDE IDENTITY"
|
||||
},
|
||||
{
|
||||
"title": "Sparkling Daydream"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "たまこまーけっと",
|
||||
"date": "2013-01-09",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ドラマチックマーケットライド"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "キルラキル",
|
||||
"date": "2013-10-03",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Before my body is dry"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "機巧少女は傷つかない",
|
||||
"date": "2013-10-07",
|
||||
"songs": [
|
||||
{
|
||||
"title": "回レ!雪月花"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ニセコイ",
|
||||
"date": "2014-01-11",
|
||||
"songs": [
|
||||
{
|
||||
"title": "CLICK"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "甘城ブリリアントパーク",
|
||||
"date": "2014-10-06",
|
||||
"songs": [
|
||||
{
|
||||
"title": "エクストラ・マジック・アワー"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ジョジョの奇妙な冒険 ダイヤモンドは砕けない",
|
||||
"date": "2016-04-01",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Great Days"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Re:ゼロから始める異世界生活",
|
||||
"date": "2016-04-03",
|
||||
"songs": [
|
||||
{
|
||||
"title": "STYX HELIX"
|
||||
},
|
||||
{
|
||||
"title": "Memento"
|
||||
},
|
||||
{
|
||||
"title": "Long shot"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "斉木楠雄のΨ難",
|
||||
"date": "2016-07-04",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Duet♡してくだΨ"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ガヴリールドロップアウト",
|
||||
"date": "2017-01-09",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ガヴリールドロップキック"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "小林さんちのメイドラゴン",
|
||||
"date": "2017-01-11",
|
||||
"songs": [
|
||||
{
|
||||
"title": "青空のラプソディ"
|
||||
},
|
||||
{
|
||||
"title": "愛のシュプリーム!"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "エロマンガ先生",
|
||||
"date": "2017-04-08",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ヒトリゴト"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Fate/Apocrypha",
|
||||
"date": "2017-07-01",
|
||||
"songs": [
|
||||
{
|
||||
"title": "英雄 運命の詩",
|
||||
"keys": [
|
||||
"英雄 運命の詩 (from BEST AL“ALTER EGO”)"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "少女終末旅行",
|
||||
"date": "2017-10-06",
|
||||
"songs": [
|
||||
{
|
||||
"title": "動く、動く"
|
||||
},
|
||||
{
|
||||
"title": "More One Night"
|
||||
},
|
||||
{
|
||||
"title": "雨だれの歌"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ぐらんぶる",
|
||||
"date": "2018-07-13",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Grand Blue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "青春ブタ野郎はバニーガール先輩の夢を見ない",
|
||||
"date": "2018-10-03",
|
||||
"songs": [
|
||||
{
|
||||
"title": "不可思議のカルテ"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "かぐや様は告らせたい",
|
||||
"date": "2019-01-12",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ラブ・ドラマティック"
|
||||
},
|
||||
{
|
||||
"title": "Daddy ! Daddy ! Do !"
|
||||
},
|
||||
{
|
||||
"title": "GIRI GIRI"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "慎重勇者",
|
||||
"date": "2019-10-02",
|
||||
"songs": [
|
||||
{
|
||||
"title": "TIT FOR TAT"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "魔入りました!入間くん",
|
||||
"date": "2019-10-05",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Magical Babyrinth"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "虚構推理",
|
||||
"date": "2020-01-11",
|
||||
"songs": [
|
||||
{
|
||||
"title": "モノノケ・イン・ザ・フィクション"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "天地創造デザイン部",
|
||||
"date": "2021-01-07",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Give It Up?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "極主夫道",
|
||||
"date": "2021-04-08",
|
||||
"songs": [
|
||||
{
|
||||
"title": "シュフノミチ"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ラブライブ!スーパースター!!",
|
||||
"date": "2021-07-11",
|
||||
"songs": [
|
||||
{
|
||||
"title": "未来は風のように"
|
||||
},
|
||||
{
|
||||
"title": "Tiny Stars"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ぼっち・ざ・ろっく!",
|
||||
"date": "2022-10-08",
|
||||
"songs": [
|
||||
{
|
||||
"title": "青春コンプレックス"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "お兄ちゃんはおしまい!",
|
||||
"date": "2023-01-05",
|
||||
"songs": [
|
||||
{
|
||||
"title": "アイデン貞貞メルトダウン"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "葬送のフリーレン",
|
||||
"date": "2023-09-29",
|
||||
"songs": [
|
||||
{
|
||||
"title": "晴る"
|
||||
},
|
||||
{
|
||||
"title": "lulu.",
|
||||
"keys": [
|
||||
"lulu."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ダンジョン飯",
|
||||
"date": "2024-01-04",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Sleep Walking Orchestra"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "逃げ上手の若君",
|
||||
"date": "2024-07-06",
|
||||
"songs": [
|
||||
{
|
||||
"title": "プランA"
|
||||
},
|
||||
{
|
||||
"title": "鎌倉STYLE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "負けヒロインが多すぎる!",
|
||||
"date": "2024-07-13",
|
||||
"songs": [
|
||||
{
|
||||
"title": "つよがるガール"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "まったく最近の探偵ときたら",
|
||||
"date": "2025-07-01",
|
||||
"songs": [
|
||||
{
|
||||
"title": "GORI☆GORI Feez e-Girl!!"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CITY THE ANIMATION",
|
||||
"date": "2025-07-06",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Hello"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "出禁のモグラ",
|
||||
"date": "2025-07-07",
|
||||
"songs": [
|
||||
{
|
||||
"title": "tumult"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ぬきたし THE ANIMATION",
|
||||
"date": "2025-07-18",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Utopia or Dystopia"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ヤニねこ",
|
||||
"date": "2026-07-02",
|
||||
"songs": [
|
||||
{
|
||||
"title": "なんもねえ"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "グロウアップショウ ~ひまわりのサーカス団~",
|
||||
"date": "2026-07-04",
|
||||
"songs": [
|
||||
{
|
||||
"title": "ユラリユレル"
|
||||
},
|
||||
{
|
||||
"title": "DAYS!"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "其他(非动漫)",
|
||||
"date": "9999-01-01",
|
||||
"songs": []
|
||||
},
|
||||
{
|
||||
"name": "機動戦士ガンダム00",
|
||||
"date": "2007-10-06",
|
||||
"songs": [
|
||||
{
|
||||
"title": "Power",
|
||||
"keys": [
|
||||
"Power"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"_说明": "MusicBrainz 歌手查询结果缓存。country: ISO 国家码(JP/KR/HK/MO/TW/CN/US…,空=未查到);begin: 成立/出生年份(分类内排序用);type: Person/Group 等;area: 地区名。可手工编辑/预填,离线时仍生效。",
|
||||
"artists": {
|
||||
"Justin Timberlake": {
|
||||
"name": "Justin Timberlake",
|
||||
"type": "Person",
|
||||
"country": "US",
|
||||
"begin": "1981",
|
||||
"area": "美国"
|
||||
},
|
||||
"川井憲次": {
|
||||
"name": "川井憲次",
|
||||
"type": "Person",
|
||||
"country": "JP",
|
||||
"begin": "1957",
|
||||
"area": "日本"
|
||||
},
|
||||
"和田薫": {
|
||||
"name": "和田薫",
|
||||
"type": "Person",
|
||||
"country": "JP",
|
||||
"begin": "1962",
|
||||
"area": "日本"
|
||||
},
|
||||
"redballoon": {
|
||||
"name": "redballoon",
|
||||
"type": "Group",
|
||||
"country": "JP",
|
||||
"begin": "2005",
|
||||
"area": "日本"
|
||||
},
|
||||
"キャプテンストライダム": {
|
||||
"name": "キャプテンストライダム",
|
||||
"type": "Group",
|
||||
"country": "JP",
|
||||
"begin": "2003",
|
||||
"area": "日本"
|
||||
},
|
||||
"DISH": {
|
||||
"name": "DISH",
|
||||
"type": "Group",
|
||||
"country": "JP",
|
||||
"begin": "2011",
|
||||
"area": "日本"
|
||||
},
|
||||
"Beyond": {
|
||||
"name": "Beyond",
|
||||
"type": "Group",
|
||||
"country": "HK",
|
||||
"begin": "1983",
|
||||
"area": "香港"
|
||||
},
|
||||
"朴树": {
|
||||
"name": "朴树",
|
||||
"type": "Person",
|
||||
"country": "CN",
|
||||
"begin": "1973",
|
||||
"area": "北京"
|
||||
},
|
||||
"帆足圭吾": {
|
||||
"name": "帆足圭吾",
|
||||
"type": "Person",
|
||||
"country": "JP",
|
||||
"begin": "1982-03-26",
|
||||
"area": "Japan"
|
||||
},
|
||||
"サカナクション": {
|
||||
"name": "サカナクション",
|
||||
"type": "Group",
|
||||
"country": "JP",
|
||||
"begin": "2005",
|
||||
"area": "Japan"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"_说明": "数据源开关与整理选项。sources: 各联网识别源的启停(anison/wikipedia/musicbrainz 需要加速器/代理,连不上时程序会自动提示)。decrypt: ncm 解密与封面提取。output: 分类输出根目录,空 = 被处理文件夹本身。categories: 六个分类的文件夹名(没有内容的分类不会创建)。folders: 每个分类内的四个子文件夹名。",
|
||||
"sources": {
|
||||
"animethemes": {"enabled": true},
|
||||
"anison": {"enabled": true},
|
||||
"wikipedia": {"enabled": true},
|
||||
"moegirl": {"enabled": true}
|
||||
},
|
||||
"musicbrainz": {
|
||||
"enabled": true,
|
||||
"rate_limit": 1.05,
|
||||
"user_agent": "AnisongOrganizer/1.0 ( contact )",
|
||||
"max_failures": 3
|
||||
},
|
||||
"decrypt": {
|
||||
"enabled": true,
|
||||
"save_cover": true
|
||||
},
|
||||
"output": "",
|
||||
"categories": {
|
||||
"anisong": "Anisong",
|
||||
"jp_kr": "日韩",
|
||||
"hk_mo_tw": "中国港澳台",
|
||||
"cn_mainland": "中国大陆",
|
||||
"western": "欧美",
|
||||
"other": "其他(未被识别)"
|
||||
},
|
||||
"folders": {
|
||||
"songs": "歌曲",
|
||||
"originals": "加密原件",
|
||||
"lyrics": "歌词",
|
||||
"covers": "封面"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user