From 4fd28965e9a79e9cc7d938df7d6d0b5b2a10fff0 Mon Sep 17 00:00:00 2001 From: UMR Date: Mon, 31 Aug 2026 00:00:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=A8=8B=E5=BA=8F=E6=BA=90?= =?UTF-8?q?=E7=A0=81=20app/=20=E4=B8=8E=E6=95=B0=E6=8D=AE=20data/=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README=E3=80=81.gitignore=E3=80=81.gitattr?= =?UTF-8?q?ibutes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 12 + .gitignore | 193 +---- app/anime_sorter.py | 1592 ++++++++++++++++++++++++++++++++++++++++++ app/ncm_decrypt.py | 247 +++++++ app/web_server.py | 408 +++++++++++ app/web_ui.html | 534 ++++++++++++++ data/anime_map.json | 638 +++++++++++++++++ data/artist_map.json | 75 ++ data/设置.json | 34 + 9 files changed, 3571 insertions(+), 162 deletions(-) create mode 100644 .gitattributes create mode 100644 app/anime_sorter.py create mode 100644 app/ncm_decrypt.py create mode 100644 app/web_server.py create mode 100644 app/web_ui.html create mode 100644 data/anime_map.json create mode 100644 data/artist_map.json create mode 100644 data/设置.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f842fd3 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 36b13f1..460beaf 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/anime_sorter.py b/app/anime_sorter.py new file mode 100644 index 0000000..a1d84e8 --- /dev/null +++ b/app/anime_sorter.py @@ -0,0 +1,1592 @@ +# -*- coding: utf-8 -*- +"""Anisong Organizer v5 + +扫描音乐文件夹,自动识别并分类整理: + - 识别到动漫(本地映射表或四源联网) → Anisong 分类,按系列首播年份排序 + - 非动漫 → MusicBrainz 查歌手地区 → 日韩 / 中国港澳台 / 中国大陆 / 欧美 + - 查不到 → 其他(未被识别) +每个分类下含四个同级子文件夹: 歌曲(解密后) / 加密原件(.ncm) / 歌词 / 封面, +编号分类内连续且三处同号;空分类不创建文件夹。 + +工具包结构(分层): + app/ 程序(本文件 / ncm_decrypt.py / web_server.py / web_ui.html) + data/ 用户数据(anime_map.json / artist_map.json / 设置.json / 导览.txt) + python/ 内置 Python 嵌入式运行时(免安装) +工具包可整体复制到任意音乐文件夹使用,双击 启动网页版.bat 即开浏览器界面。 + +用法: + python app/anime_sorter.py # 预览(dry-run),确认后输入 y 执行 + python app/anime_sorter.py --apply # 跳过确认直接执行 + python app/anime_sorter.py --offline # 禁用联网,只查本地映射表/缓存 + python app/anime_sorter.py 文件夹路径 # 处理指定文件夹 + python app/anime_sorter.py 下载文件夹 --recursive # 递归整理子目录(网易云下载结构) + python app/anime_sorter.py --web # 启动浏览器交互界面(关闭网页即退出) + +数据源(可在 data/设置.json 中启停): + animethemes AnimeThemes.moe,存罗马字/英文标题,含年份季度,无需代理 + anison anison.info 动漫歌曲库,日文曲名,需要加速器/代理 + wikipedia 日语维基百科,从歌曲条目提取动漫,需要加速器/代理 + moegirl 萌娘百科,中日文曲名,无需代理 + musicbrainz MusicBrainz 歌手地区/成立时间,需要加速器/代理(离线可用本地缓存) +""" + +import datetime +import json +import os +import re +import ssl +import struct +import sys +import time +import unicodedata +import urllib.error +import urllib.parse +import urllib.request +from email.utils import parsedate_to_datetime + +# 音乐文件扩展名(FLAC 可读标签,其他格式回退文件名解析) +AUDIO_EXTS = ('.flac', '.mp3', '.m4a', '.wav', '.ape', '.ogg') +# 可处理的全部扩展名(含待解密的 ncm,用于判断目标文件夹是否有内容) +PROC_EXTS = AUDIO_EXTS + ('.ncm',) +HTTP_HEADERS = {"User-Agent": "Mozilla/5.0 AnisongOrganizer/4.0"} + + +def tool_root(): + """工具包根目录(本文件位于根目录下的 app 子目录)。""" + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Windows 文件名禁用字符 -> 全角 +FORBIDDEN = str.maketrans({ + '\\': '\', '/': '/', ':': ':', '*': '*', '?': '?', + '"': '"', '<': '<', '>': '>', '|': '|', +}) + +# 中文简体混入日文标题时的常见替换(谜->謎 等) +CN2JP = str.maketrans({ + '谜': '謎', '风': '風', '云': '雲', '气': '気', '决': '決', + '剧': '劇', '剑': '剣', '梦': '夢', '语': '語', '经': '経', + '难': '難', '个': '個', '发': '発', +}) + +SEASON_MONTH = {'Winter': '01', 'Spring': '04', 'Summer': '07', 'Fall': '10'} + +# 数据源注册表: 名称 -> (查询函数, 是否需要加速器/代理) +SOURCES = { + 'animethemes': ('animethemes_lookup', False), + 'anison': ('anison_lookup', True), + 'wikipedia': ('wikipedia_lookup', True), + 'moegirl': ('moegirl_lookup', False), +} +SOURCE_ORDER = ['animethemes', 'anison', 'wikipedia', 'moegirl'] +# 未提供 设置.json 时的默认启用源(均为无需代理的源) +DEFAULT_ENABLED = {'animethemes': True, 'anison': False, 'wikipedia': False, 'moegirl': True} + +# 动漫源策略: 按曲名语言调整优先级(动漫歌曲独特适配)——假名=日文曲名,以日文库 +# anison 优先;纯汉字(中文或日文汉字)以覆盖中日文的萌娘优先;纯 ASCII 以存罗马字的 +# animethemes 优先。各源并行查询、按此优先级取首个命中。 +SOURCE_PRIORITY = { + 'kana': ['anison', 'moegirl', 'wikipedia', 'animethemes'], + 'cjk': ['moegirl', 'anison', 'wikipedia', 'animethemes'], + 'ascii': ['animethemes', 'moegirl', 'anison', 'wikipedia'], +} +# 源熔断: 连续失败达此数后本次运行停用该源(避免无梯子时每首歌都白等超时) +SOURCE_MAX_FAILS = 2 + + +def _title_lang(s): + """曲名语言类型: kana(含假名) / cjk(纯汉字) / ascii(拉丁字母等)。""" + if re.search(r'[぀-ヿ]', s): + return 'kana' + if re.search(r'[一-鿿]', s): + return 'cjk' + return 'ascii' + +# 六个输出分类(键稳定,显示名可在 设置.json 配置) +CATEGORY_KEYS = ['anisong', 'jp_kr', 'hk_mo_tw', 'cn_mainland', 'western', 'other'] +# MusicBrainz 国家/地区码 -> 分类键 +MB_COUNTRY_CAT = {'JP': 'jp_kr', 'KR': 'jp_kr', + 'HK': 'hk_mo_tw', 'MO': 'hk_mo_tw', 'TW': 'hk_mo_tw', + 'CN': 'cn_mainland'} + + +# ---------- 基础工具 ---------- + +def read_flac_tags(path): + """读取 FLAC 的 Vorbis 标签,返回 {KEY: [值, ...]};无标签返回空 dict;失败返回 None。""" + try: + with open(path, "rb") as f: + head = f.read(10) + if head.startswith(b"ID3"): # 跳过前置 ID3v2 标签 + size = (head[6] << 21) | (head[7] << 14) | (head[8] << 7) | head[9] + f.seek(10 + size) + head = f.read(4) + else: + f.seek(4) + if head[:4] != b"fLaC": + return None + last = False + while not last: + hdr = f.read(4) + if len(hdr) < 4: + break + last = bool(hdr[0] & 0x80) + btype = hdr[0] & 0x7F + blen = struct.unpack(">I", b"\x00" + hdr[1:4])[0] + data = f.read(blen) + if len(data) < blen: + break + if btype == 4: # Vorbis comment 块 + pos = 0 + + def u32(): + nonlocal pos + v = struct.unpack_from(" len(data): + break + kv = data[pos:pos + l].decode("utf-8", "replace") + pos += l + if "=" in kv: + k, v = kv.split("=", 1) + out.setdefault(k, []).append(v) + return out + if last: + break + return {} + except Exception: + return None + + +def normalize(s): + """曲名规范化(用于匹配):简中->日文、全角空格、去尾部注释组、合并空格。""" + s = s.translate(CN2JP) + s = s.replace(' ', ' ') + while True: # 去掉尾部的 (注释) / <注释> / 【注释】 + m = re.search(r'[\((【<\[][^()()【\]<>]*[\))】>\]](?:\s*)$', s) + if not m: + break + s = s[:m.start()].rstrip() + # 去掉 feat./ft. 及其后的合作者标注 + s = re.sub(r'\s+(?:feat\.?|ft\.?)\s+.+$', '', s, flags=re.I) + # 去掉尾部的版本标注(Director's Edit. / TV Size / Short Ver. 等) + s = re.sub( + r"\s+(?:Director's Edit\..*|TV\s*Size.*|Short\s*Ver\..*|Original\s*Ver\..*|Live\s*Ver\..*)$", + '', s, flags=re.I) + return re.sub(r'\s+', ' ', s).strip() + + +def sanitize(title): + """最终文件名清理:禁用字符转全角、尾随点转全角、去尾随空格。""" + t = title.translate(FORBIDDEN) + t = t.rstrip(' ') + if t.endswith('.'): + t = t.rstrip('.') + '.' # Windows 会静默丢弃尾随点,用全角保留 + if len(t) > 120: + t = t[:120].rstrip(' ') + return t + + +_SSL_CTX = ssl.create_default_context() +_SSL_CTX.set_ciphers("DEFAULT@SECLEVEL=1") # 兼容使用老旧 TLS 的站点 + + +def http_get_json(url, timeout=8): + req = urllib.request.Request(url, headers=HTTP_HEADERS) + with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as r: + return json.load(r) + + +def http_get_text(url, timeout=8): + req = urllib.request.Request(url, headers=HTTP_HEADERS) + with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as r: + return r.read().decode("utf-8", "replace") + + +def fold(s): + """NFKC 归一化(全角标点转半角等)+小写,用于宽松比较。""" + return unicodedata.normalize('NFKC', s).casefold().strip() + + +_KKS = None + + +def _to_romaji(s): + """日文转罗马字(依赖 pykakasi,未安装则返回 None)。""" + global _KKS + try: + if _KKS is None: + import pykakasi + _KKS = pykakasi.kakasi() + text = '' + for item in _KKS.convert(s): + text += item.get('hepburn') or item.get('orig') or '' + return text + except Exception: + return None + + +def _lev(a, b): + """编辑距离(用于歌手名的罗马字模糊比较)。""" + if abs(len(a) - len(b)) > 2: + return 99 + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + cur = [i] + for j, cb in enumerate(b, 1): + cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))) + prev = cur + return prev[-1] + + +# ---------- 联网数据源 ---------- +# 约定: 每个 lookup 函数返回 (动漫名, 日期'YYYY-MM'或None, 候选列表) 或 (None, None, []) +# 网络异常向上抛出,由主循环记录并提示用户。 + +def animethemes_lookup(title, artist): + """AnimeThemes.moe: 存罗马字/英文标题,日文曲名自动转罗马字再查。""" + def fetch(q): + url = ("https://api.animethemes.moe/song?filter[title]=" + urllib.parse.quote(q) + + "&page[size]=8&include=animethemes.anime,artists") + return http_get_json(url).get("songs", []) + + def score(song): + s = 0 + if fold(song['title']) == fold(title): + s += 50 + al = fold(artist) + al_romaji = fold(_to_romaji(artist) or artist) + for a in song.get('artists', []): + af = fold(a.get('name') or '') + if af and al and (af in al or al in af + or af in al_romaji or al_romaji in af + or (len(af) >= 3 and _lev(af, al_romaji) <= 2)): + s += 100 + break + return s + + query = unicodedata.normalize('NFKC', title) + songs = fetch(query) + if not songs: + romaji = _to_romaji(query) + if romaji: # 依次尝试: 罗马字全文 -> 逐级缩短前缀 + tried = set() + for f in (1.0, 0.7, 0.6, 0.5, 0.4, 0.35, 0.3): + q = romaji[:max(4, int(len(romaji) * f))].strip() + if q in tried: + continue + tried.add(q) + songs = fetch(q) + if songs: + break + if not songs: + return None, None, [] + + ranked = sorted(songs, key=score, reverse=True) + best, second = ranked[0], (ranked[1] if len(ranked) > 1 else None) + cands = [] + for s in ranked[:3]: + animes = [] + for at in s.get('animethemes', []): + an = at.get('anime') or {} + if an.get('name'): + animes.append((an.get('name'), an.get('year'), an.get('season'))) + cands.append((s['title'], [a.get('name') for a in s.get('artists', [])], animes)) + s_best, s_second = score(best), score(second) if second else -1 + # 高置信: 歌手匹配,或唯一候选(罗马字查询后唯一命中也算),或标题精确且明显领先 + if (s_best >= 100 or len(ranked) == 1 + or (s_best >= 50 and s_best - s_second >= 50)): + for at in best.get('animethemes', []): + an = at.get('anime') or {} + if an.get('name'): + year = an.get('year') + mm = SEASON_MONTH.get(an.get('season'), '01') + return an['name'], (f"{year}-{mm}" if year else None), cands + return None, None, cands + + +def anison_lookup(title, artist): + """anison.info: 动漫歌曲数据库,搜索结果的表格行直接包含动漫名与 OP/ED 类型。""" + page = http_get_text("http://anison.info/data/n.php?m=song&q=" + urllib.parse.quote(title)) + rows = [] # (曲名, 歌手, 动漫名, 类型, 作品页id) + for tr in re.findall(r']*>(.*?)', page, re.S): + prog = re.search(r"link\(\s*['\"]program['\"]\s*,\s*['\"](\d+)['\"]\s*\)", tr) + if not prog: + continue # 没有作品链接 = 非动漫曲 + cells = [re.sub(r'<[^>]+>', '', c).strip() for c in re.findall(r']*>(.*?)', tr, re.S)] + if len(cells) < 4 or fold(cells[0]) != fold(title): + continue + rows.append((cells[0], cells[1], cells[3], cells[4] if len(cells) > 4 else '', prog.group(1))) + if not rows: + return None, None, [] + + af = fold(artist) + if af: # 歌手匹配优先 + hits = [r for r in rows if af in fold(r[1]) or fold(r[1]) in af] + if hits: + rows = hits + + cands = [(r[0], [r[1]], [(r[2],)]) for r in rows[:3]] + if len(rows) == 1: + anime = rows[0][2] + year = None + try: # 作品页取年份(取第一个出现的年份) + ppage = http_get_text(f"http://anison.info/data/program/{rows[0][4]}.html") + ym = re.search(r'(\d{4})年', ppage) + if ym: + year = ym.group(1) + except Exception: + pass + return anime, (f"{year}-01" if year else None), cands + return None, None, cands + + +def wikipedia_lookup(title, artist): + """日语维基百科: 从歌曲条目的「テレビアニメ『X』のOP/ED」句式提取动漫。""" + def search(q): + url = ("https://ja.wikipedia.org/w/api.php?action=query&list=search&srsearch=" + + urllib.parse.quote(q) + "&srlimit=5&format=json") + return http_get_json(url)["query"]["search"] + + def extract(r): + url = ("https://ja.wikipedia.org/w/api.php?action=query&prop=extracts&explaintext=1" + "&titles=" + urllib.parse.quote(r['title']) + "&format=json") + pages = http_get_json(url)["query"]["pages"] + return list(pages.values())[0].get("extract", "") + + tf, af = fold(title), fold(artist) + results = search(f"{title} {artist}") + if not results: + results = search(title) + for r in results[:4]: + rt = fold(r['title']) + if not (tf in rt or rt in tf): # 文章标题与曲名应互相包含(如「嘘 (シドの曲)」) + continue + text = extract(r) + # 严格句式: テレビアニメ『X』之后80字内出现 OP/ED/主题曲 关键词 + for m in re.finditer( + r'テレビアニメ[『「](.+?)[』」].{0,80}?(?:オープニング|エンディング|主題歌|テーマソング|テーマ曲)', text): + anime = m.group(1) + pre = text[max(0, m.start() - 80):m.start()] + years = re.findall(r'(\d{4})年', pre) + year = years[-1] if years else None + return anime, (f"{year}-01" if year else None), [] + # 宽松句式: アニメ『X』 + 关键词 + m2 = re.search(r'アニメ[『「](.+?)[』」].{0,80}?(?:オープニング|エンディング|主題歌|テーマ)', text) + if m2: + return m2.group(1), None, [] + return None, None, [] + + +def moegirl_lookup(title, artist): + """萌娘百科: 支持日文/中文标题。先搜「曲名 歌手」,不足时再搜「intitle:歌手」。""" + + def search_heads(query): + html = http_get_text( + "https://zh.moegirl.org.cn/index.php?title=Special:%E6%90%9C%E7%B4%A2&search=" + + urllib.parse.quote(query) + "&fulltext=1") + return re.findall(r'
]*>(.*?)', html, re.S) + + def examine(heads): + for href, text in heads[:5]: + t = fold(re.sub(r'<[^>]+>', '', text)).strip() + if '歧义' in t or '消歧义' in t or 'disambiguation' in t: + continue + page = http_get_text("https://zh.moegirl.org.cn" + href) + # 强验证: 页面标题以曲名开头(允许带括号注释),或正文含 lang=ja 曲名标记 + heading_ok = (t == tf or t.startswith(tf + '(') or t.startswith(tf + '(')) + if not heading_ok and not jp_pat.search(page): + continue + # 提取动漫: 优先 meta 描述 "本条目介绍的是动画《X》的OP2" + m = re.search(r'动画《(.{1,40}?)》的', page) + if not m: + # 正文 "(是)动画《》的片头曲/片尾曲" + m = re.search(r'是(?:电视)?动画《\s*]*title="([^"]+)"', page) + if not m and heading_ok: + # 分类兜底(仅当页面标题已确认是这首歌时): Category:X音乐 + m = re.search(r'"title":"Category:([^"]{1,40})音乐"', page) + if m: + name = m.group(1).strip() + if name: + y = re.search(r'(?:发行|发售)(?:时间|日期)[^\d]{0,6}(\d{4})', page) + return name, (y.group(1) if y else None) + return None + + tf, af = fold(title), fold(artist) + # 页面精确验证: 正文中的日文原名标记 曲名 + jp_pat = re.compile(r'lang="ja"[^>]*>\s*' + re.escape(title) + r'\s*') + heads = search_heads(f"{title} {artist}") + result = examine(heads) + if result: + return result + if af: # 二次搜索: 按歌手名搜标题 + heads = search_heads(f"intitle:{artist}") + result = examine(heads) + if result: + return result + return None, None, [] + + +# ---------- MusicBrainz 歌手地区/成立时间 ---------- + +class MBError(Exception): + """MusicBrainz 查询失败(网络/限流/无结果/歧义)。""" + + +def _parse_retry_after(v, default=2): + """解析 Retry-After 头(秒数或 HTTP 日期),返回等待秒数(上限 30)。""" + if not v: + return default + try: + return max(1, min(30, int(v))) + except ValueError: + try: + dt = parsedate_to_datetime(v) + now = datetime.datetime.now(dt.tzinfo) + return max(1, min(30, (dt - now).total_seconds())) + except Exception: + return default + + +def mb_http_json(url, ua, timeout=10): + """MusicBrainz 请求(必须自定义 UA,Python-urllib 默认 UA 会被封 IP)。 + + 503(共享出口 IP 限流常见,Retry-After 常为 0)时做最多 2 次退避重试, + 间隔 = max(Retry-After, 1.5s)——实测间歇性 503 下重试成功率较高。 + """ + req = urllib.request.Request(url, headers={'User-Agent': ua}) + for attempt in range(3): + try: + with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as r: + return json.load(r) + except urllib.error.HTTPError as e: + if e.code == 503 and attempt < 2: + time.sleep(max(_parse_retry_after(e.headers.get('Retry-After')), 1.5)) + continue + raise MBError(f'HTTP {e.code}') from e + except Exception as e: + raise MBError(str(e)[:80]) from e + + +def musicbrainz_artist_lookup(artist, ctx): + """联网查歌手(写缓存),返回 {'name','type','country','begin','area'};失败抛 MBError。""" + cfg = ctx['cfg'] + mb = ctx['mb_state'] + # 自节流(官方限速 1 请求/秒) + wait = mb['last_ts'] + cfg['musicbrainz']['rate_limit'] - time.time() + if wait > 0: + time.sleep(wait) + url = ('https://musicbrainz.org/ws/2/artist/?query=artist%3A%22' + + urllib.parse.quote(artist) + '%22&fmt=json&limit=10') + data = mb_http_json(url, cfg['musicbrainz']['user_agent']) + mb['last_ts'] = time.time() + artists = data.get('artists', []) + best = None + for a in artists: + if not a.get('name'): + continue + if best is None or a.get('score', 0) > best.get('score', 0): + best = a + if best is None: + raise MBError('无结果') + # 名称校验: 精确匹配或高分,避免同名歧义歌手 + if fold(best['name']) != fold(artist) and best.get('score', 0) < 90: + raise MBError(f'歧义(最佳 {best["name"]},分数 {best.get("score", 0)})') + info = { + 'name': best.get('name', artist), + 'type': best.get('type') or '', + 'country': best.get('country') or '', + 'begin': (best.get('life-span') or {}).get('begin') or '', + 'area': (best.get('area') or {}).get('name') or '', + } + if not info['country'] and (best.get('area') or {}).get('id'): + wait = mb['last_ts'] + cfg['musicbrainz']['rate_limit'] - time.time() + if wait > 0: + time.sleep(wait) + adata = mb_http_json('https://musicbrainz.org/ws/2/area/' + best['area']['id'] + '?fmt=json', + cfg['musicbrainz']['user_agent']) + mb['last_ts'] = time.time() + codes = adata.get('iso-3166-1-codes') or [] + if codes: + info['country'] = codes[0] + ctx['artist_cache'][artist] = info + return info + + +def mb_classify(artist, ctx): + """歌手地区 -> 分类键。缓存命中零请求;未启用(离线/停用/连续失败禁用)且无缓存 -> ('other', None)。""" + if not artist: + return 'other', None + if artist in ctx['artist_cache']: + ctx['source_use']['musicbrainz']['cache'] += 1 + info = ctx['artist_cache'][artist] + elif ctx['mb_state']['enabled']: + info = musicbrainz_artist_lookup(artist, ctx) # 失败抛 MBError,由调用侧计数 + ctx['source_use']['musicbrainz']['ok'] += 1 + ctx['mb_state']['consecutive'] = 0 # 成功清零连续失败(间歇性限流不应累计误禁用) + else: + return 'other', None + cc = (info.get('country') or '').upper() + return MB_COUNTRY_CAT.get(cc, 'western' if cc else 'other'), info + + +# ---------- 控制台输出 ---------- + +COLOR = False +_ANSI = {'reset': '\033[0m', 'bold': '\033[1m', + 'green': '\033[32m', 'yellow': '\033[33m', 'red': '\033[31m', 'cyan': '\033[36m'} + + +def setup_console(): + """Windows 开启 VT 处理以支持 ANSI 颜色;非终端/重定向/NO_COLOR 时保持纯文本。""" + global COLOR + if not sys.stdout.isatty() or os.environ.get('NO_COLOR'): + return + if os.name == 'nt': + try: + import ctypes + h = ctypes.windll.kernel32.GetStdHandle(-11) + mode = ctypes.c_uint32() + ctypes.windll.kernel32.GetConsoleMode(h, ctypes.byref(mode)) + # ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + ctypes.windll.kernel32.SetConsoleMode(h, mode.value | 0x0004) + except Exception: + return + COLOR = True + + +def c(name, text): + """彩色文本(COLOR=False 时原样返回)。name: green/yellow/red/cyan/bold。""" + if not COLOR: + return text + return _ANSI.get(name, '') + text + _ANSI['reset'] + + +def print_section(title): + print() + print(c('cyan', f'=== {title} ===')) + + +# ---------- 配置与缓存 ---------- + +def load_settings(tool_dir): + """读取设置(设置.json 各段合并进默认值,损坏/缺失回退默认;--sources= 参数可临时覆盖)。""" + cfg = { + 'decrypt': True, 'save_cover': True, + 'output': '', + 'categories': {'anisong': 'Anisong', 'jp_kr': '日韩', 'hk_mo_tw': '中国港澳台', + 'cn_mainland': '中国大陆', 'western': '欧美', 'other': '其他(未被识别)'}, + 'folders': {'songs': '歌曲', 'originals': '加密原件', 'lyrics': '歌词', 'covers': '封面'}, + 'musicbrainz': {'enabled': True, 'rate_limit': 1.05, + 'user_agent': 'AnisongOrganizer/1.0 ( contact )', 'max_failures': 3}, + 'sources': dict(DEFAULT_ENABLED), + } + settings_path = os.path.join(tool_dir, 'data', '设置.json') + try: + with open(settings_path, encoding='utf-8-sig') as fp: + data = json.load(fp) + for k in cfg['sources']: + s = (data.get('sources') or {}).get(k) or {} + if 'enabled' in s: + cfg['sources'][k] = bool(s['enabled']) + d = data.get('decrypt') or {} + if isinstance(d, dict): + if 'enabled' in d: + cfg['decrypt'] = bool(d['enabled']) + if 'save_cover' in d: + cfg['save_cover'] = bool(d['save_cover']) + if data.get('output'): + cfg['output'] = str(data['output']) + fo = data.get('folders') or {} + if isinstance(fo, dict): + for sub in cfg['folders']: + if fo.get(sub): + cfg['folders'][sub] = str(fo[sub]) + ca = data.get('categories') or {} + if isinstance(ca, dict): + for k in cfg['categories']: + if ca.get(k): + cfg['categories'][k] = str(ca[k]) + mb = data.get('musicbrainz') or {} + if isinstance(mb, dict): + if 'enabled' in mb: + cfg['musicbrainz']['enabled'] = bool(mb['enabled']) + if 'rate_limit' in mb: + cfg['musicbrainz']['rate_limit'] = float(mb['rate_limit']) + if 'user_agent' in mb: + cfg['musicbrainz']['user_agent'] = str(mb['user_agent']) + if 'max_failures' in mb: + cfg['musicbrainz']['max_failures'] = int(mb['max_failures']) + except Exception: + pass + for a in sys.argv: # --sources= 覆盖 + if a.startswith('--sources='): + val = a.split('=', 1)[1].strip() + if val == 'all': + cfg['sources'] = {k: True for k in cfg['sources']} + elif val == 'off': + cfg['sources'] = {k: False for k in cfg['sources']} + else: + sel = [x.strip() for x in val.split(',') if x.strip() in cfg['sources']] + cfg['sources'] = {k: (k in sel) for k in cfg['sources']} + return cfg + + +def load_anime_map(tool_dir): + """读取映射表,返回 (lookup, series_list)。 + + 映射表 = 用户维护的 anime_map.json(种子 + ✎ 手动标注) + 识别缓存.json + (上次联网运行的识别结果,每次联网运行重新生成、不累积历史)。 + lookup: 规范化曲名 -> (系列名, 最终曲名, 'seed'|'cache'),种子优先。 + """ + lookup = {} + series_list = [] + + def load_one(path, kind): + with open(path, encoding='utf-8-sig') as fp: + sers = json.load(fp)['series'] + for s in sers: + series_list.append(s) + for song in s['songs']: + for k in song.get('keys', [song['title']]): + key = normalize(k) + if kind == 'seed' or key not in lookup: # 种子优先,缓存只补缺 + lookup[key] = (s['name'], song['title'], kind) + + load_one(os.path.join(tool_dir, 'data', 'anime_map.json'), 'seed') + cache_path = os.path.join(tool_dir, 'data', '识别缓存.json') + if os.path.exists(cache_path): + try: + load_one(cache_path, 'cache') + except Exception: + pass + return lookup, series_list + + +def write_online_cache(tool_dir, recs): + """本次运行的联网识别结果写入 识别缓存.json——每次联网运行重新生成,不累积历史。""" + hits = [(r['group'], r['title'], r['date']) for r in recs + if r['category'] == 'anisong' + and r['source'] in ('animethemes', 'anison', 'wikipedia', 'moegirl')] + p = os.path.join(tool_dir, 'data', '识别缓存.json') + if not hits: + try: # 无联网命中则清空旧缓存(不堆积过期对应关系) + if os.path.exists(p): + os.remove(p) + except OSError: + pass + return + series = [] + for sn, title, date in hits: + s = next((x for x in series if x['name'] == sn), None) + if s is None: + s = {'name': sn, 'date': ((date or '') + '-01' if date else '9999-01-01'), 'songs': []} + series.append(s) + if not any(song['title'] == title for song in s['songs']): + s['songs'].append({'title': title, 'keys': [title]}) + try: + with open(p, 'w', encoding='utf-8') as fp: + json.dump({'_说明': '联网识别结果缓存(每次联网运行自动重新生成,可安全删除)。', + 'series': series}, fp, ensure_ascii=False, indent=2) + except Exception: + pass + + +def load_artist_map(tool_dir): + """读取歌手缓存(容忍缺失/损坏)。""" + p = os.path.join(tool_dir, 'data', 'artist_map.json') + try: + with open(p, encoding='utf-8-sig') as fp: + return json.load(fp).get('artists', {}) + except Exception: + return {} + + +def save_artist_map(tool_dir, artists): + """保存歌手缓存(纯旁路元数据,识别阶段结束即存,离线复用)。""" + if not artists: + return + p = os.path.join(tool_dir, 'data', 'artist_map.json') + try: + with open(p, 'w', encoding='utf-8') as fp: + json.dump({'_说明': 'MusicBrainz 歌手查询结果缓存。country: ISO 国家码(JP/KR/HK/MO/TW/CN/US…,空=未查到);begin: 成立/出生年份(分类内排序用);type: Person/Group 等;area: 地区名。可手工编辑/预填,离线时仍生效。', + 'artists': artists}, fp, ensure_ascii=False, indent=2) + except Exception: + pass + + +def write_anime_map(tool_dir, recs): + """手动标注(✎)的动漫系列写入 anime_map.json(用户维护文件;联网结果改走 识别缓存.json)。""" + new_entries = [(r['group'], r['title']) for r in recs + if r['category'] == 'anisong' and r['source'] == '手动'] + if not new_entries: + return + map_path = os.path.join(tool_dir, 'data', 'anime_map.json') + try: + with open(map_path, encoding='utf-8-sig') as fp: + map_data = json.load(fp) + all_series = map_data['series'] + for sn, title in new_entries: + s = next((x for x in all_series if x['name'] == sn), None) + if s is None: + rec = next((r for r in recs if r['group'] == sn), None) + d = (rec or {}).get('date') or '' + date = f'{d}-01' if d else '9999-01-01' + s = {'name': sn, 'date': date, 'songs': []} + all_series.append(s) + if not any(song['title'] == title for song in s['songs']): + s['songs'].append({'title': title, 'keys': [title]}) + with open(map_path, 'w', encoding='utf-8') as fp: + json.dump(map_data, fp, ensure_ascii=False, indent=2) + except Exception as e: + print(f'(缓存写入失败: {e})') + + +# ---------- 分类路径助手 ---------- + +def cat_dir(root, cfg, key): + return os.path.join(root, sanitize(cfg['categories'][key])) + + +def cat_sub(root, cfg, key, sub): + return os.path.join(cat_dir(root, cfg, key), sanitize(cfg['folders'][sub])) + + +# ---------- 流水线 ---------- + +def find_target_folder(tool_dir): + """默认处理工具包上级文件夹;若上级没有音频/ncm 文件则处理工具包所在文件夹。""" + parent = os.path.dirname(tool_dir) + has_audio = lambda d: any(f.lower().endswith(PROC_EXTS) for f in os.listdir(d)) if os.path.isdir(d) else False + return parent if has_audio(parent) else tool_dir + + +def _walk_media(folder, cfg): + """递归收集媒体文件相对路径(跳过分类目录与工具包文件夹)。""" + skip = {sanitize(cfg['categories'][k]) for k in CATEGORY_KEYS} + skip.add(os.path.basename(tool_root())) + out = [] + for root, dirs, files in os.walk(folder): + dirs[:] = [d for d in dirs if d not in skip] + rel_root = os.path.relpath(root, folder) + for fn in files: + out.append(os.path.join(rel_root, fn) if rel_root != '.' else fn) + return out + + +def remove_empty_dirs(folder): + """自底向上删除空子目录(递归整理后清理源文件夹的空壳),返回删除数。""" + removed = 0 + for root, dirs, files in os.walk(folder, topdown=False): + for d in dirs: + try: + os.rmdir(os.path.join(root, d)) # 仅空目录可删除 + removed += 1 + except OSError: + pass + return removed + + +def collect_files(folder, cfg=None, recursive=False, lookup=None, metas=None): + """收集音频文件,返回 REC 列表(src/raw/artist/title/ext 已填)。 + recursive=True 时递归扫描子目录(网易云下载目录结构),src 为相对路径。 + + 曲名/歌手解析优先级: FLAC 标签 > ncm 元数据(文件名只有歌曲名等格式时补全歌手) + > 文件名解析。文件名含「 - 」时双方向尝试(歌曲名-歌手 与 歌手-歌曲名), + 优先取能命中本地映射表的方向(适配网易云「歌手-歌曲名」命名设置)。""" + if recursive: + candidates = sorted(p for p in _walk_media(folder, cfg) + if p.lower().endswith(AUDIO_EXTS)) + else: + candidates = sorted(f for f in os.listdir(folder) if f.lower().endswith(AUDIO_EXTS)) + recs = [] + for fn in candidates: + tags = None + if fn.lower().endswith('.flac'): + tags = read_flac_tags(os.path.join(folder, fn)) + meta = (metas or {}).get(fn) + raw = None + artist = '' + if tags is not None and tags.get('TITLE'): + raw = tags['TITLE'][0] + elif meta and meta.get('musicName'): + raw = meta['musicName'] + if raw is None: # 文件名解析(去掉可能已有的编号前缀) + stem = re.sub(r'^\d+_', '', os.path.splitext(fn)[0]) + if ' - ' in stem: + a, b = stem.split(' - ', 1) + # 双向尝试: 优先能命中本地映射表的解析(曲名-歌手 / 歌手-曲名) + if lookup and normalize(b) in lookup and normalize(a) not in lookup: + raw, artist = b, a.strip() + else: + raw, artist = a, b.strip() + else: + raw = stem + if tags is not None and tags.get('ARTIST'): + artist = tags['ARTIST'][0].split('/')[0].strip() + elif not artist and meta: # ncm 元数据补全歌手 + arr = meta.get('artist') or [] + if isinstance(arr, list) and arr: + first = arr[0] + artist = (first[0] if isinstance(first, list) else first).strip() + recs.append({'src': fn, 'orig_src': fn, 'raw': raw, 'artist': artist, 'title': normalize(raw), + 'category': '', 'group': '', 'date': '', 'source': '', + 'ext': os.path.splitext(fn)[1], 'ncm_pair': None, + 'lrc_pair': None, 'cover': None, 'number': 0, 'final_name': ''}) + return recs + + +def decrypt_ncm(folder, cfg, recursive=False): + """解密 .ncm(幂等),返回 (ncm_pairs, covers, metas, msgs)。 + recursive=True 时递归扫描子目录,键为相对路径。 + ncm_pairs: 解密产物相对路径 -> 原 .ncm 相对路径;covers: 解密产物相对路径 -> (封面字节, 目标文件名); + metas: 解密产物相对路径 -> 元数据 dict(文件名为纯歌曲名等格式时用于补全歌手)。""" + ncm_pairs = {} + covers = {} + metas = {} + msgs = [] + if recursive: + ncm_files = sorted(p for p in _walk_media(folder, cfg) if p.lower().endswith('.ncm')) + else: + ncm_files = sorted(f for f in os.listdir(folder) if f.lower().endswith('.ncm')) + if not ncm_files: + return ncm_pairs, covers, metas, msgs + try: + import ncm_decrypt + except ImportError: + if cfg['decrypt']: + msgs.append(f'检测到 {len(ncm_files)} 个 .ncm 文件,但缺少 ncm_decrypt.py 模块,本次跳过解密。') + return ncm_pairs, covers, metas, msgs + if not cfg['decrypt']: + msgs.append(f'检测到 {len(ncm_files)} 个 .ncm 文件(设置.json 中 decrypt.enabled=false,跳过解密)。') + return ncm_pairs, covers, metas, msgs + msgs.append(f'共 {len(ncm_files)} 个:') + for fn in ncm_files: + src = os.path.join(folder, fn) + try: + _, meta, cover, _ = ncm_decrypt.parse_ncm(src) + out_fn = os.path.splitext(fn)[0] + '.' + (meta.get('format') or 'mp3') + out_path = os.path.join(folder, out_fn) + if os.path.exists(out_path): + msgs.append(f' · {fn}: 已存在 {out_fn},跳过解密') + else: + ncm_decrypt.decrypt_audio(src, out_path) + msgs.append(f' {c("green", "✓")} {fn} -> {out_fn}') + ncm_pairs[out_fn] = fn + metas[out_fn] = meta + if cover and cfg['save_cover']: + name = sanitize(meta.get('album') or '') or sanitize(meta.get('musicName') or '') + if not name: + name = os.path.splitext(fn)[0] + cover_ext = '.png' if cover[:8] == b'\x89PNG\r\n\x1a\n' else '.jpg' + covers[out_fn] = (cover, sanitize(name) + cover_ext) + except Exception as e: + msgs.append(f' {c("red", "✗")} 解密失败 {fn}: {e}') + return ncm_pairs, covers, metas, msgs + + +def self_heal(root, cfg): + """自愈: 各分类「加密原件」中缺少同茎解密音频的 .ncm 重新解密回「歌曲」夹。返回消息列表。""" + msgs = [] + for key in CATEGORY_KEYS: + originals_dir = cat_sub(root, cfg, key, 'originals') + if not os.path.isdir(originals_dir): + continue + songs_dir = cat_sub(root, cfg, key, 'songs') + for fn in sorted(os.listdir(originals_dir)): + if not fn.lower().endswith('.ncm'): + continue + stem = os.path.splitext(fn)[0] + if not re.match(r'^\d+_', stem): + msgs.append(f' · {cfg["categories"][key]}\\{cfg["folders"]["originals"]}\\{fn}: 无编号前缀,跳过自愈') + continue + has_audio = any( + f.lower().startswith(stem.lower() + '.') and f.lower().endswith(AUDIO_EXTS) + for f in os.listdir(songs_dir)) if os.path.isdir(songs_dir) else False + if has_audio: + continue + src = os.path.join(originals_dir, fn) + try: + import ncm_decrypt + _, meta, cover, _ = ncm_decrypt.parse_ncm(src) + fmt = meta.get('format') or 'mp3' + dst = os.path.join(songs_dir, stem + '.' + fmt) + ncm_decrypt.decrypt_audio(src, dst) + msgs.append(f' {c("green", "✓")} {cfg["categories"][key]}\\{cfg["folders"]["originals"]}\\{fn} ' + f'缺少解密音频,已重新解密 -> {cfg["folders"]["songs"]}\\{stem}.{fmt}') + except Exception as e: + msgs.append(f' {c("red", "✗")} 自愈失败 {fn}: {e}') + return msgs + + +def _query_source(name, key, artist): + """单个源的查询(在后台线程中执行),返回 (name, 'ok', anime, date, cands) 或 (name, 'err', msg)。""" + try: + anime, date, cands = globals()[SOURCES[name][0]](key, artist) + return name, 'ok', anime, date, cands + except Exception as e: + return name, 'err', str(e)[:80] + + +def identify(recs, ctx, progress_cb=None, stop_check=None): + """逐文件识别分类,返回联网动漫命中数。 + + 动漫源策略: 按曲名语言调整优先级(动漫歌曲独特适配),所有启用源并行查询 + (墙钟时间 = 最慢源而非各源之和),按优先级取首个命中;源连续失败达 + SOURCE_MAX_FAILS 后本次运行熔断停用(无梯子时不再每首歌白等超时)。 + """ + import concurrent.futures + lookup = ctx['lookup'] + series_list = ctx['series_list'] + enabled = ctx['enabled'] + online_hits = 0 + mb = ctx['mb_state'] + broken = {n: 0 for n in SOURCES} + total = len(recs) + executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(SOURCES)) + try: + for i, rec in enumerate(recs, 1): + if stop_check and stop_check(): + break + key = rec['title'] + result = '' + # 1) 本地映射表(「其他(非动漫)」系列/未知日期不算动漫,走地区分类) + if key in lookup: + series_name, map_title, kind = lookup[key] + s_entry = next((s for s in series_list if s['name'] == series_name), None) + s_date = s_entry.get('date', '9999-01-01') if s_entry else '9999-01-01' + if not s_date.startswith('9999') and series_name != '其他(非动漫)': + rec['category'] = 'anisong' + rec['group'] = series_name + rec['title'] = map_title + rec['date'] = s_date[:7] + rec['source'] = '本地' if kind == 'seed' else '缓存' + result = series_name + else: + # 用户已确认非动漫(其他(非动漫)系列/未知日期): 不再查询动漫源, + # 直接地区分类(歌手缓存命中零网络,避免固定几个文件每次都白等) + rec['skip_anime'] = True + # 2) 联网动漫源: 并行查询,按语言优先级取首个命中 + if not rec['category'] and not rec.get('skip_anime'): + priority = SOURCE_PRIORITY[_title_lang(key)] + actives = [n for n in priority if enabled.get(n) and broken[n] < SOURCE_MAX_FAILS] + hit = None + if actives: + futures = {executor.submit(_query_source, n, key, rec['artist']): n + for n in actives} + outcomes = {n: f.result() for f, n in futures.items()} # 并发等待(墙钟=最慢源) + for n in priority: + if n not in outcomes: + continue + _, status, *rest = outcomes[n] + if status == 'ok': + ctx['source_use'][n]['ok'] += 1 + broken[n] = 0 # 成功清零连续失败 + # 防误识别: 动漫名与歌手名相同多半是抓取错位(如把歌手名当作品名),视为未命中 + if rest[0] and not (rec['artist'] and fold(rest[0]) == fold(rec['artist'])): + hit = (n, rest[0], rest[1]) + break + else: + ctx['source_use'][n]['fail'] += 1 + broken[n] += 1 + if ctx['source_use'][n]['err'] is None: + ctx['source_use'][n]['err'] = rest[0] + if hit: + name, anime, date = hit + rec['category'] = 'anisong' + rec['group'] = anime + rec['date'] = (date or '')[:7] + rec['source'] = name + online_hits += 1 + result = anime + # 并入系列表(供排序与缓存写回) + s = next((x for x in series_list if x['name'] == anime), None) + if s is None: + d = (date or '')[:7] # 日期规范化,防 'YYYY-MM-DD-01' 畸形值 + series_list.append({'name': anime, 'date': (d + '-01' if d else '9999-01-01'), + 'songs': []}) + s = series_list[-1] + if not any(song['title'] == rec['title'] for song in s['songs']): + s['songs'].append({'title': rec['title'], 'keys': [rec['title']]}) + # 3) MusicBrainz 地区分类兜底 + if not rec['category']: + cat = 'other' + if rec['artist']: + try: + cat, info = mb_classify(rec['artist'], ctx) + except MBError as e: + cat = 'other' + ctx['source_use']['musicbrainz']['fail'] += 1 + mb['consecutive'] += 1 + if mb['err'] is None: + mb['err'] = str(e)[:80] + if mb['consecutive'] >= ctx['cfg']['musicbrainz']['max_failures']: + mb['enabled'] = False + except Exception as e: + cat = 'other' + ctx['source_use']['musicbrainz']['fail'] += 1 + mb['consecutive'] += 1 + if mb['err'] is None: + mb['err'] = str(e)[:80] + else: + if cat != 'other': + rec['source'] = 'musicbrainz' + rec['group'] = rec['artist'] + rec['date'] = (info or {}).get('begin') or '' + result = ctx['cfg']['categories'][cat] + rec['category'] = cat + if not result: + result = ctx['cfg']['categories'][rec['category']] + if not rec['group']: + rec['group'] = rec['artist'] or '' + if progress_cb: + label = f'{rec["raw"]} - {rec["artist"]}' if rec['artist'] else rec['raw'] + # 附带结构化信息(供 Web 界面罗列: 分类/系列/组建年份) + progress_cb('identify', i, total, label, result, + {'category': rec['category'], 'group': rec['group'], + 'date': rec['date'] or ''}) + time.sleep(0.3) # 对接口保持礼貌 + finally: + executor.shutdown(wait=False) + ctx['broken'] = broken # 供源状态汇总显示熔断情况 + return online_hits + + +def group_and_order(recs, ctx): + """按分类分组并排序,返回 {cat_key: [REC]}。 + Anisong: 系列按首播日期排序,系列内按映射表歌曲顺序; + 日韩/中国港澳台/中国大陆/欧美: 按歌手分组(同歌手相邻),歌手按成立年份(未知置后)排序,组内按曲名; + 其他: 按 (歌手, 曲名)。""" + by_cat = {k: [] for k in CATEGORY_KEYS} + for rec in recs: + by_cat[rec['category']].append(rec) + + # Anisong: 系列排序 + series_list = ctx['series_list'] + series_list.sort(key=lambda s: (s.get('date', '9999-01-01')[:7], s['name'])) + consumed = set() + ordered = [] + for s in series_list: + for song in s['songs']: + for i, rec in enumerate(by_cat['anisong']): + if i not in consumed and rec['title'] == song['title'] and rec['group'] == s['name']: + ordered.append(rec) + consumed.add(i) + for i, rec in enumerate(by_cat['anisong']): + if i not in consumed: + ordered.append(rec) + by_cat['anisong'] = ordered + + # 地区分类: 歌手分组 + 成立年份排序 + def artist_begin(artist): + return ((ctx['artist_cache'].get(artist) or {}).get('begin') or '').strip() + + for key in ('jp_kr', 'hk_mo_tw', 'cn_mainland', 'western'): + groups = {} + for rec in by_cat[key]: + groups.setdefault(fold(rec['group']), []).append(rec) + for gfold in groups: + groups[gfold].sort(key=lambda r: fold(r['title'])) + def sort_key(gfold): + begin = artist_begin(groups[gfold][0]['group']) + return (not begin, begin, gfold) + by_cat[key] = [r for gfold in sorted(groups, key=sort_key) for r in groups[gfold]] + + by_cat['other'].sort(key=lambda r: (fold(r['group']), fold(r['title']))) + return by_cat + + +def scan_existing_numbers(songs_dir): + """读取歌曲夹现有最大编号(无文件/夹不存在返回 0)。""" + if not os.path.isdir(songs_dir): + return 0 + mx = 0 + for fn in os.listdir(songs_dir): + m = re.match(r'^(\d+)_', fn) + if m: + mx = max(mx, int(m.group(1))) + return mx + + +def assign_numbers(by_cat, root, cfg): + """每分类独立编号(续接现有最大号),lrc/ncm 目标名取自 final_name 茎(永远同号)。""" + for key in CATEGORY_KEYS: + recs = by_cat[key] + if not recs: + continue + songs_dir = cat_sub(root, cfg, key, 'songs') + start = scan_existing_numbers(songs_dir) + 1 + width = max(2, len(str(start + len(recs) - 1))) + used = {fn.lower() for fn in os.listdir(songs_dir)} if os.path.isdir(songs_dir) else set() + for i, rec in enumerate(recs): + base = sanitize(rec['title']) + n = 1 + name = base + while f'{name}{rec["ext"]}'.lower() in used: + n += 1 + name = f'{base} ({n})' + rec['final_name'] = f'{start + i:0{width}d}_{name}{rec["ext"]}' + rec['number'] = start + i + used.add(rec['final_name'].lower()) + return by_cat + + +def build_move_plan(by_cat, ctx): + """构建移动清单,返回 (moves, skips, warnings)。只对含移动条目的分类创建目录(执行时)。""" + folder = ctx['folder'] + root = ctx['root'] + cfg = ctx['cfg'] + moves = [] + warnings = [] + cat_covers_used = {k: set() for k in CATEGORY_KEYS} + + for key in CATEGORY_KEYS: + for rec in by_cat[key]: + stem = os.path.splitext(rec['final_name'])[0] + moves.append({'kind': 'audio', 'src': os.path.join(folder, rec['src']), + 'dst': os.path.join(cat_sub(root, cfg, key, 'songs'), rec['final_name']), + 'category': key, 'number': rec['number'], 'rec_src': rec['src']}) + if rec.get('ncm_pair'): + moves.append({'kind': 'ncm', 'src': os.path.join(folder, rec['ncm_pair']), + 'dst': os.path.join(cat_sub(root, cfg, key, 'originals'), stem + '.ncm'), + 'category': key, 'number': rec['number'], 'rec_src': rec['src']}) + old_lrc = os.path.splitext(rec['src'])[0] + '.lrc' + if os.path.exists(os.path.join(folder, old_lrc)): + rec['lrc_pair'] = old_lrc + moves.append({'kind': 'lrc', 'src': os.path.join(folder, old_lrc), + 'dst': os.path.join(cat_sub(root, cfg, key, 'lyrics'), stem + '.lrc'), + 'category': key, 'number': rec['number'], 'rec_src': rec['src']}) + if rec.get('cover'): + cbytes, cname = rec['cover'] + cdst = os.path.join(cat_sub(root, cfg, key, 'covers'), cname) + if cname in cat_covers_used[key] or os.path.exists(cdst): + continue # 分类内同名封面去重 + cat_covers_used[key].add(cname) + moves.append({'kind': 'cover', 'src': '', 'dst': cdst, + 'category': key, 'number': 0, 'data': cbytes, 'rec_src': rec['src']}) + + # 旧歌词文件夹(v3.1 结构)迁移: 去编号 + 规范化匹配曲名,唯一匹配才迁移 + old_lyrics_dir = os.path.join(folder, cfg['folders']['lyrics']) + if os.path.isdir(old_lyrics_dir): + for lfn in sorted(os.listdir(old_lyrics_dir)): + if not lfn.lower().endswith('.lrc'): + continue + lstem = re.sub(r'^\d+_', '', os.path.splitext(lfn)[0]) + lkey = fold(normalize(lstem)) + hits = [] + for key in CATEGORY_KEYS: + for rec in by_cat[key]: + if rec.get('lrc_pair') is None: + src_stem = re.sub(r'^\d+_', '', os.path.splitext(rec['src'])[0]) + if lkey == fold(rec['title']) or lkey == fold(normalize(src_stem)): + hits.append((key, rec)) + if len(hits) == 1: + key, rec = hits[0] + rec['lrc_pair'] = lfn + moves.append({'kind': 'lrc', 'src': os.path.join(old_lyrics_dir, lfn), + 'dst': os.path.join(cat_sub(root, cfg, key, 'lyrics'), + os.path.splitext(rec['final_name'])[0] + '.lrc'), + 'category': key, 'number': rec['number'], 'rec_src': rec['src']}) + else: + warnings.append(f'旧歌词文件夹中 {lfn} 无法唯一对应歌曲,保持原位。') + + # 旧顶层「封面」文件夹: 无法可靠映射分类,保持原位 + old_covers_dir = os.path.join(folder, cfg['folders']['covers']) + if os.path.isdir(old_covers_dir): + warnings.append(f'检测到旧版顶层「{cfg["folders"]["covers"]}」文件夹:' + f'封面无法可靠对应分类,保持原位不动。') + + # 目标已存在/重复目标/原地不动 -> 跳过 + final_moves, skips = [], [] + seen = set() + for m in moves: + if m['kind'] == 'cover': + final_moves.append(m) + continue + if os.path.normcase(m['src']) == os.path.normcase(m['dst']): + skips.append({**m, 'reason': '已是最新位置'}) + elif os.path.exists(m['dst']): + skips.append({**m, 'reason': '目标已存在'}) + elif m['dst'] in seen: + skips.append({**m, 'reason': '重复目标'}) + else: + seen.add(m['dst']) + final_moves.append(m) + return final_moves, skips, warnings + + +def render_plan(by_cat, moves, ctx, absolute=False): + """渲染预览文本。absolute=True 输出完整绝对路径(撤销日志用)。""" + root = ctx['root'] + folder = ctx['folder'] + cfg = ctx['cfg'] + lines = [] + for key in CATEGORY_KEYS: + recs = by_cat[key] + if not recs: + continue + existing = scan_existing_numbers(cat_sub(root, cfg, key, 'songs')) + lines.append('') + lines.append(f'【{cfg["categories"][key]}】(现有 {existing} 首,新增 {len(recs)} 首)') + for rec in recs: + mark = {'本地': '', '缓存': '[缓存] ', 'animethemes': '[网] ', 'anison': '[网] ', + 'wikipedia': '[网] ', 'moegirl': '[网] ', 'musicbrainz': '[MB] ', + '手动': '[手动] '}.get(rec['source'], '') + if absolute: + lines.append(f' {os.path.join(cat_sub(root, cfg, key, "songs"), rec["final_name"])}' + f' ← {os.path.join(folder, rec["src"])}') + else: + rel = os.path.join(cfg['categories'][key], cfg['folders']['songs'], rec['final_name']) + lines.append(f' {mark}{rel} ← {rec["src"]}') + ncm_n = sum(1 for m in moves if m['category'] == key and m['kind'] == 'ncm') + lrc_n = sum(1 for m in moves if m['category'] == key and m['kind'] == 'lrc') + cover_n = sum(1 for m in moves if m['category'] == key and m['kind'] == 'cover') + note = [] + if ncm_n: + note.append(f'{cfg["folders"]["originals"]} {ncm_n}') + if lrc_n: + note.append(f'{cfg["folders"]["lyrics"]} {lrc_n}') + if cover_n: + note.append(f'{cfg["folders"]["covers"]} {cover_n}') + if note: + lines.append(f' 同步: ' + ', '.join(note) + '(编号与歌曲一致)') + if absolute: # 撤销日志: 附上非音频移动明细 + sub = [m for m in moves if m['kind'] in ('ncm', 'lrc', 'cover')] + if sub: + lines.append('') + lines.append('=== 移动明细(撤销日志) ===') + for m in sub: + if m['kind'] == 'cover': + lines.append(f' {m["dst"]} ← (解密提取的封面)') + else: + lines.append(f' {m["dst"]} ← {m["src"]}') + return '\n'.join(lines).strip() or '无需处理。' + + +def _src_mark(src): + """导览中的来源标记。""" + return {'本地': '', '缓存': '[缓存] ', 'animethemes': '[网] ', 'anison': '[网] ', + 'wikipedia': '[网] ', 'moegirl': '[网] ', 'musicbrainz': '[MB] ', + '手动': '[手动] '}.get(src, '') + + +def render_guide(result): + """渲染导览——还原初代 预览.txt 的简洁格式(带领用户按时间顺序回顾): + + 共 N 个文件,按动漫系列(首播年份)排列: + + 【犬夜叉】(2000) + 09_疾風の如く.flac ← 疾风の如く - 和田薫.flac + 【日韩】 + 10_雪のツバサ.flac ← 雪のツバサ - redballoon.flac + ... + """ + by_cat = result['by_cat'] + cfg = result['ctx']['cfg'] + lines = [f'共 {len(result["recs"])} 个文件,按动漫系列(首播年份)排列:'] + current = None + # Anisong: by_cat 已按系列首播年份排序,系列头带年份 + for rec in by_cat['anisong']: + sn = rec['group'] or '未命名系列' + if sn != current: + entry = next((s for s in result['ctx']['series_list'] if s['name'] == sn), None) + d = (entry.get('date') or '') if entry else '' + year = d[:4] if d and not d.startswith('9999') else '' + lines.append('\n【' + sn + '】' + (f'({year})' if year else '')) + current = sn + src_name = rec.get('orig_src') or rec['src'] # 应用后仍显示最初的来源名 + lines.append(f' {_src_mark(rec["source"])}{rec["final_name"]} ← {src_name}') + # 其余分类: 分类头 + 歌曲行 + for key in CATEGORY_KEYS[1:]: + if not by_cat[key]: + continue + lines.append('\n【' + cfg['categories'][key] + '】') + for rec in by_cat[key]: + src_name = rec.get('orig_src') or rec['src'] + lines.append(f' {_src_mark(rec["source"])}{rec["final_name"]} ← {src_name}') + for w in result['ctx']['warnings']: + lines.append(f'⚠ {w}') + return '\n'.join(lines).strip() or '无需处理。' + + +def write_guide(tool_dir, text): + """导览写入工具目录的 导览.txt,返回文件路径。""" + p = os.path.join(tool_dir, 'data', '导览.txt') + try: + with open(p, 'w', encoding='utf-8-sig') as fp: + fp.write(text + '\n') + except Exception: + pass + return p + + +def move_file(src, dst): + """移动文件: 同盘 os.rename,跨盘回退 shutil.move(复制+删除)。""" + try: + os.rename(src, dst) + except OSError: + import shutil + shutil.move(src, dst) + + +def execute_moves(moves, progress_cb=None, stop_check=None): + """执行移动清单,返回 {'moved','skipped','failed','stopped'}。 + + 每条 move 会被打上结果标记: m['done']=True(成功) / False(跳过或失败), + m['error']=失败原因——供 Web 端把新位置回写记录、支持继续编辑再次应用。 + """ + stats = {'moved': 0, 'skipped': 0, 'failed': 0, 'stopped': False} + for m in moves: + if stop_check and stop_check(): + stats['stopped'] = True + break + dst = m['dst'] + if m['kind'] == 'cover': + try: + os.makedirs(os.path.dirname(dst), exist_ok=True) + with open(dst, 'wb') as f: + f.write(m['data']) + stats['moved'] += 1 + m['done'] = True + except OSError as e: + print(f'封面写入失败: {os.path.basename(dst)} ({e})') + stats['failed'] += 1 + m['done'] = False + m['error'] = str(e)[:80] + continue + if os.path.exists(dst): + print(f'跳过(目标已存在): {dst}') + stats['skipped'] += 1 + m['done'] = False + m['error'] = '目标已存在' + continue + try: + os.makedirs(os.path.dirname(dst), exist_ok=True) + move_file(m['src'], dst) + stats['moved'] += 1 + m['done'] = True + if progress_cb: + progress_cb('apply', stats['moved'], len(moves), os.path.basename(dst)) + except OSError as e: + print(f'移动失败: {os.path.basename(m["src"])} -> {os.path.basename(dst)} ({e})') + stats['failed'] += 1 + m['done'] = False + m['error'] = str(e)[:80] + return stats + + +def print_source_summary(ctx): + """运行结束的源状态汇总(动漫源 + MusicBrainz,含熔断提示)。""" + broken = ctx.get('broken', {}) + for name, (_, needs_proxy) in SOURCES.items(): + u = ctx['source_use'][name] + if ctx['enabled'].get(name) and u['fail']: + hint = '该源需要加速器/代理,请确认已开启后重试' if needs_proxy else '网络异常或服务不可用' + if broken.get(name, 0) >= SOURCE_MAX_FAILS: + hint += f'(连续失败 {SOURCE_MAX_FAILS} 次,本次运行已停用)' + print(f' ✗ 数据源 {name} 连接失败 {u["fail"]} 次 —— {hint}') + elif ctx['enabled'].get(name) and u['ok']: + tag = '(需代理)' if needs_proxy else '' + print(f' ✓ 数据源 {name} 正常 {tag}') + mb = ctx['source_use']['musicbrainz'] + if mb['fail'] and not ctx['mb_state']['enabled']: + print(f' ✗ MusicBrainz 连接失败 {mb["fail"]} 次 —— 该源需要加速器/代理,请确认已开启后重试' + f'(连续失败 {ctx["cfg"]["musicbrainz"]["max_failures"]} 次,本次运行已停用)') + elif mb['fail']: + print(f' ⚠ MusicBrainz 部分失败 {mb["fail"]} 次,对应歌曲已归入' + f'「{ctx["cfg"]["categories"]["other"]}」') + elif mb['ok'] or mb['cache']: + print(f' ✓ MusicBrainz 正常(联网 {mb["ok"]},缓存 {mb["cache"]})') + + +def run_pipeline(folder, opts, progress_cb=None, stop_check=None): + """完整流水线(不执行移动),返回 RESULT。CLI 与 Web 共用。 + + opts: {'offline': bool, 'recursive': bool, 'output': 输出根目录覆盖设置.json} + progress_cb(phase, i, n, text): phase='identify' 逐文件;'decrypt'/'heal' 单行消息。 + """ + tool_dir = tool_root() + cfg = load_settings(tool_dir) + if opts.get('offline'): + cfg['sources'] = {k: False for k in cfg['sources']} + cfg['musicbrainz']['enabled'] = False + recursive = bool(opts.get('recursive')) + if opts.get('output'): + root = os.path.abspath(opts['output']) + elif cfg['output']: + root = os.path.abspath(cfg['output']) + else: + root = folder + + map_path = os.path.join(tool_dir, 'data', 'anime_map.json') + if not os.path.exists(map_path): + raise FileNotFoundError(f'未找到映射文件: {map_path}') + lookup, series_list = load_anime_map(tool_dir) + artist_cache = load_artist_map(tool_dir) + source_use = {n: {'ok': 0, 'fail': 0, 'err': None} for n in SOURCE_ORDER} + source_use['musicbrainz'] = {'ok': 0, 'fail': 0, 'cache': 0, 'err': None} + ctx = {'tool_dir': tool_dir, 'folder': folder, 'root': root, 'cfg': cfg, + 'lookup': lookup, 'series_list': series_list, 'artist_cache': artist_cache, + 'enabled': dict(cfg['sources']), 'source_use': source_use, + 'mb_state': {'enabled': cfg['musicbrainz']['enabled'], 'consecutive': 0, + 'last_ts': 0.0, 'err': None}, + 'warnings': []} + + def emit(text, phase='decrypt'): + if progress_cb: + progress_cb(phase, 0, 0, text) + + def emit_section(title): + if progress_cb: + progress_cb('section', 0, 0, title) + + ncm_pairs, covers, ncm_metas, d_msgs = decrypt_ncm(folder, cfg, recursive) + h_msgs = self_heal(root, cfg) + if d_msgs: + emit_section('② ncm 解密') + for m in d_msgs: + emit(m, 'decrypt') + if h_msgs: + emit_section('③ 自愈检查') + for m in h_msgs: + emit(m, 'heal') + recs = collect_files(folder, cfg, recursive, lookup, ncm_metas) + for rec in recs: + if rec['src'] in ncm_pairs: + rec['ncm_pair'] = ncm_pairs[rec['src']] + if rec['src'] in covers: + rec['cover'] = covers[rec['src']] + if recs: + emit_section('④ 识别') + online_hits = identify(recs, ctx, progress_cb, stop_check) + if not opts.get('offline'): # 联网运行: 重新生成识别缓存(不累积历史) + write_online_cache(tool_dir, recs) + by_cat = group_and_order(recs, ctx) + assign_numbers(by_cat, root, cfg) + moves, skips, warnings = build_move_plan(by_cat, ctx) + ctx['warnings'].extend(warnings) + save_artist_map(tool_dir, artist_cache) + stats = {k: {'moves': 0, 'skips': 0} for k in CATEGORY_KEYS} + for m in moves: + stats[m['category']]['moves'] += 1 + for s in skips: + stats[s['category']]['skips'] += 1 + return {'ctx': ctx, 'recs': recs, 'by_cat': by_cat, 'moves': moves, + 'skips': skips, 'online_hits': online_hits, 'stats': stats} + + +def main(): + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + setup_console() + + if '--web' in sys.argv: + try: + import web_server + except ImportError: + print('缺少 web_server.py 模块(Web 界面未随本版本提供)。') + return 1 + folder = next((a for a in sys.argv[1:] if os.path.isdir(a)), None) + web_server.start(tool_root(), folder) + return 0 + + apply_now = '--apply' in sys.argv + offline = '--offline' in sys.argv + recursive = '--recursive' in sys.argv + tool_dir = tool_root() + folder = next((a for a in sys.argv[1:] if os.path.isdir(a)), None) + if folder is None: + folder = find_target_folder(tool_dir) + if not os.path.isdir(folder): + print(f'文件夹不存在: {folder}') + return 1 + + print_section('① 扫描') + if recursive: + cfg0 = load_settings(tool_dir) + media = _walk_media(folder, cfg0) + audio_n = sum(1 for p in media if p.lower().endswith(AUDIO_EXTS)) + ncm_n = sum(1 for p in media if p.lower().endswith('.ncm')) + print(f' 递归发现 {audio_n} 个音乐文件、{ncm_n} 个 .ncm(处理文件夹: {folder},含子目录)') + else: + audio_n = sum(1 for f in os.listdir(folder) if f.lower().endswith(AUDIO_EXTS)) + ncm_n = sum(1 for f in os.listdir(folder) if f.lower().endswith('.ncm')) + print(f' 发现 {audio_n} 个音乐文件、{ncm_n} 个 .ncm(处理文件夹: {folder})') + # 网易云 VIP 曲目默认存入 VipSongsDownload 子文件夹,提示用户开递归 + vip = os.path.join(folder, 'VipSongsDownload') + if os.path.isdir(vip) and any(f.lower().endswith(PROC_EXTS) for f in os.listdir(vip)): + print(c('yellow', ' ⚠ 检测到 VipSongsDownload 文件夹(网易云 VIP 曲目存放处),' + '本次未包含——请加 --recursive 或勾选「递归整理子目录」。')) + + def progress_bridge(phase, *args): + """CLI 进度回调: 区块标题/识别进度/解密与自愈消息(识别进度附带的详情被忽略)。""" + if phase == 'section': + print_section(args[-1]) + elif phase == 'identify' and len(args) >= 4: + i, n, label, result = args[0], args[1], args[2], args[3] + print(f' [{i}/{n}] {label} ... {c("cyan", "→")} {result}') + elif phase in ('decrypt', 'heal'): + print(f' {args[-1]}') + + try: + result = run_pipeline(folder, {'offline': offline, 'recursive': recursive}, progress_bridge) + except FileNotFoundError as e: + print(e) + return 1 + + print_section('⑤ 导览') + plan_text = render_plan(result['by_cat'], result['moves'], result['ctx']) + print(plan_text) + for w in result['ctx']['warnings']: + print(c('yellow', f' ⚠ {w}')) + if result['online_hits']: + print(f'\n本次联网自动识别 {result["online_hits"]} 首(结果已写入 识别缓存.json,下次离线可用)。') + guide_path = write_guide(tool_dir, render_guide(result)) + print(f'导览已保存到 {guide_path}') + + if not result['moves'] and not result['skips']: + print('无需处理,未做任何修改。') + return 0 + + if apply_now: + answer = 'y' + else: + try: + answer = input('\n确认无误?输入 y 回车执行移动,其他键取消: ').strip().lower() + except EOFError: + answer = '' + if answer != 'y': + print('已取消,未做任何修改。') + print() + print_source_summary(result['ctx']) + return 0 + + print_section('⑥ 执行') + stats = execute_moves(result['moves'], progress_cb=progress_bridge) + write_anime_map(tool_dir, result['recs']) + if recursive: + n_removed = remove_empty_dirs(folder) + if n_removed: + print(f'已清理源文件夹中的 {n_removed} 个空目录。') + print() + for key in CATEGORY_KEYS: + cnt = result['stats'][key] + if not (cnt['moves'] or cnt['skips']): + continue + name = result['ctx']['cfg']['categories'][key] + print(f' {name:16s} 移动 {cnt["moves"]:3d} 跳过 {cnt["skips"]:3d}') + print(f'\n完成: 移动 {stats["moved"]} 个,跳过 {stats["skipped"]} 个,失败 {stats["failed"]} 个。') + if stats['stopped']: + print('(已中途停止)') + print_source_summary(result['ctx']) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/app/ncm_decrypt.py b/app/ncm_decrypt.py new file mode 100644 index 0000000..9b8dc37 --- /dev/null +++ b/app/ncm_decrypt.py @@ -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(' 去 22 字节前缀 -> base64 -> AES-128-ECB(Meta Key) + meta_off = 14 + key_len + meta_len = struct.unpack('= 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 界面。') diff --git a/app/web_ui.html b/app/web_ui.html new file mode 100644 index 0000000..e6f1ca4 --- /dev/null +++ b/app/web_ui.html @@ -0,0 +1,534 @@ + + + + + +Anisong Organizer + + + +
+

Anisong Organizer

+
扫描 → 预览分类计划 → 勾选/拖动调整 → 应用。可多次应用;点击歌曲旁的系列标签可直接更换或标注所属动漫。
+ +
+
+ + +
+
+ + + + + +
+
+ +
+
+
+
+ +
+

已识别的歌曲

+
+
+ +
+ 已选 0 首 → 移到 + + + +
+ +
+ + + +
+ + +
+ + + + diff --git a/data/anime_map.json b/data/anime_map.json new file mode 100644 index 0000000..eee2e8e --- /dev/null +++ b/data/anime_map.json @@ -0,0 +1,638 @@ +{ + "_说明": "keys: 标签/文件名中可能出现的原始曲名变体(程序会自动规范化后再匹配); title: 最终使用的曲名。series 按首播日期排列, 系列内 songs 按发售/播出顺序排列。", + "series": [ + { + "name": "新世紀エヴァンゲリオン", + "date": "1995-10-04", + "songs": [ + { + "title": "残酷な天使のテーゼ", + "keys": [ + "残酷な天使のテーゼ ", + "残酷な天使のテーゼ 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" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/data/artist_map.json b/data/artist_map.json new file mode 100644 index 0000000..0da86f6 --- /dev/null +++ b/data/artist_map.json @@ -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" + } + } +} \ No newline at end of file diff --git a/data/设置.json b/data/设置.json new file mode 100644 index 0000000..0ea7ed4 --- /dev/null +++ b/data/设置.json @@ -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": "封面" + } +}